50 Commits

Author SHA1 Message Date
27dca3df69 refactor(travel): Travel.jsx 리팩토링 — 컴포넌트 분리 + 앨범 카드 기반 UI
모놀리식 Travel.jsx(1024줄)를 정리하여 useTravelData, MiniMap,
AlbumCard, AlbumDetail 등 추출된 컴포넌트를 조합하는 깔끔한
메인 컨테이너로 교체. Travel.css에서 photo-mosaic, photo-card,
lightbox, filmstrip 등 개별 컴포넌트 CSS로 이동된 스타일 제거.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:28:27 +09:00
439844cd14 feat(travel): AlbumDetail 오버레이 — 사진/영상 탭 + 진입/이탈 애니메이션
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:25:30 +09:00
085481e104 feat(travel): HeroLightbox — shared element transition + 스와이프 탐색
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:22:49 +09:00
f9495f0c30 feat(travel): VideoTab 플레이스홀더 — 영상 탭 UI 셸
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:19:45 +09:00
4655e9ab3b feat(travel): MasonryGrid 컴포넌트 — CSS columns Masonry + 무한스크롤
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:19:41 +09:00
5efb9525d5 feat(travel): AlbumCard 컴포넌트 — 대표사진 + 그라디언트 + 메타정보
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:19:37 +09:00
201601dc95 feat(travel): MiniMap 컴포넌트 — 접기/펼치기 + 전체보기
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:19:33 +09:00
1072a5eb21 fix(travel): useTravelData AbortController 및 에러 핸들링 보완
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 01:14:54 +09:00
c9df3e0e88 feat(travel): useTravelData 훅 추출 — API/캐싱/페이지네이션 로직 분리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 01:13:11 +09:00
6ef687378d fix(components): CSS 변수명 수정 + dead code 제거
- --border-line → --line (5개 컴포넌트 8곳)
- --gradient-accent → --grad-accent (FAB)
- --text-default → --text (MobileSheet)
- useSwipe.js 삭제 (미사용 dead code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:15:55 +09:00
ca9929faac fix(a11y): 글로벌 prefers-reduced-motion 추가 + Blog 버튼 위치 수정
- App.css: 글로벌 reduced-motion 블록 (모든 animation/transition 비활성화)
- index.css: scroll-behavior: smooth → auto (reduced-motion)
- BlogMarketing.css: 스피너 reduced-motion 처리
- Blog.css: 플로팅 토글 버튼 bottom-nav 위로 재배치

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:12:24 +09:00
0198fec43c refactor(responsive): Phase 3 코드 품질 개선
- Blog/BlogMarketing/Subscription/MusicStudio: 미사용 useIsMobile 제거
- Subscription: 미사용 Link import 제거
- Blog.css: 중복 display:block 제거
- BlogMarketing: dead prop onGenerate 제거
- Todo: 카드 버튼 터치 타겟 26→36px 확대

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:06:56 +09:00
901cfd7e1b fix(responsive): Phase 3 spec compliance 수정
- Blog: 태그 필터 칩 바 모바일 가로 스크롤 추가
- BlogMarketing: FAB 전 탭에서 표시 + 대시보드 480px 1컬럼
- Subscription: PullToRefresh refreshKey 패턴 적용, FAB→공고 목록 탭 이동
- Todo: FAB 라벨 "할일 추가"로 spec 일치

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:02:12 +09:00
c7cad9da61 feat(effect-lab): 모바일 반응형 — SwordStream 터치 대응
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:55:50 +09:00
28a80b5bd7 feat(agent-office): 모바일 반응형 — 바텀시트 에이전트 상세
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:55:40 +09:00
00f8e00436 feat(todo): 모바일 반응형 — 스와이프 칸반 + FAB + 바텀시트 입력
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:55:05 +09:00
326d54c73f feat(music): 모바일 반응형 — FAB + 풀다운 리프레시 + 1컬럼 라이브러리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:54:04 +09:00
5c10952e39 feat(subscription): 모바일 반응형 — 바텀시트 필터 + FAB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:53:12 +09:00
2b826ed700 feat(blog): 모바일 반응형 — FAB + 풀다운 리프레시 + 칩 필터
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:52:36 +09:00
d5ef77ad17 fix(lotto): 모바일 볼 크기 36px→32px 수정 2026-04-23 14:49:06 +09:00
033b89f87d feat(travel): 모바일 반응형 — 풀다운 리프레시 + 풀스크린 라이트박스
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:47:01 +09:00
e7427ff1d5 feat(stock): 모바일 반응형 — 캐러셀 지표 + 스와이프 탭 + FAB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:46:58 +09:00
fd13f65faa feat(lotto): 모바일 반응형 — 스와이프 탭 전환
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:46:54 +09:00
2c2011659a feat(home): 모바일 반응형 — 스와이프 TODO + 풀다운 리프레시
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:46:51 +09:00
0922261c74 feat: 앱 셸 모바일 레이아웃 — BottomNav 통합 + 사이드바 조건부 렌더링
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:38:49 +09:00
d53108f1c9 feat: MobileSheet 바텀시트 모달 컴포넌트 2026-04-23 14:36:43 +09:00
80921563be feat: FAB 플로팅 액션 버튼 컴포넌트 2026-04-23 14:36:38 +09:00
6875a28e92 feat: SwipeableView 스와이프 탭 전환 컴포넌트 2026-04-23 14:36:35 +09:00
2db0c1b3eb feat: PullToRefresh 풀다운 새로고침 컴포넌트 2026-04-23 14:36:32 +09:00
bce5ae9fac feat: BottomNav 모바일 하단 네비게이션 컴포넌트 2026-04-23 14:34:32 +09:00
a053cf2d71 feat: react-swipeable 설치 + useIsMobile/useSwipe 훅 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:33:15 +09:00
08efaa722a style(responsive): standardize RealEstate breakpoints
- RealEstate.css: 1100px → 1024px; merge 900px into 768px block

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:30:19 +09:00
2cdecd918e style(responsive): standardize Subscription, MusicStudio, BlogMarketing breakpoints
- Subscription.css: 1100px → 1024px; merge 900px into 768px block
- MusicStudio.css: 960px → 1024px; both 640px blocks → 480px
- BlogMarketing.css: 640px → 480px

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:30:07 +09:00
1e60524cfc style(responsive): standardize breakpoints for Home, Lotto, Travel, Blog
- Home.css: 960px → 1024px
- Lotto.css: merge 900px into 768px block; both 640px blocks → 480px
- Travel.css: merge 900px into 768px block; both 640px blocks → 480px
- Blog.css: merge 900px into 768px block (preserving all styles)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:29:34 +09:00
75d1558508 style(responsive): add viewport-fit=cover and safe area CSS variables
Add viewport-fit=cover to meta tag for notched devices.
Add --bottom-nav-h / --safe-area-bottom tokens and body padding-bottom
for mobile bottom navigation safe area support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:28:22 +09:00
188a714372 docs: 로또 페이지 3탭 구조 + 브리핑 API 반영
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:36:08 +09:00
064c983ca1 feat(lotto): 3탭 구조 재배치(브리핑/분석/구매) 2026-04-15 08:33:08 +09:00
bf1c23e66a feat(lotto): 브리핑 컴포넌트 + CSS 2026-04-15 08:31:35 +09:00
a922dd12c0 feat(lotto): useBriefing·useCuratorUsage 훅 2026-04-15 08:30:45 +09:00
1344967118 feat(lotto): 브리핑·큐레이터 API 헬퍼 2026-04-15 08:30:33 +09:00
2840ad7df6 feat(stock): 증권사별 보유 현황에 총 매입 금액 추가 표기
종목수 · 총 매입 · 평가 · 손익 · 예수금 순으로 노출.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 02:17:32 +09:00
ad0a123d0f fix(stock): 브로커 총 매입 금액을 매입가 단순 합계로 수정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 02:10:17 +09:00
18d2cd5a51 feat(stock): 포트폴리오 매입가/평균단가 분리 + 총 매입 금액 반영
- 기존 카드의 "매입가" → "평균단가" (avg_price) 로 라벨 변경
- 신규 "매입가" (purchase_price) 컬럼 추가. 추가/수정 폼에 입력 필드 노출
  (미입력 시 평균단가 값으로 자동 설정)
- 브로커별 총 매입 금액은 purchase_price × quantity 합계 기준
- 손익/수익률은 평균단가(avg_price) 기준 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 01:58:10 +09:00
104a34912f feat(agent-office): 모바일 반응형 세로 스택 + 작업 시간 표기 개선
- 768px 이하에서 대시보드 세로 스택 + 에이전트 카드 아코디언 토글
- waiting/알림 있을 때 자동 펼침 및 좌측 강조 바
- 픽셀 오피스 캔버스 모바일 높이 140px로 축소 후 상단 배치
- 최근 작업 시간: completed_at 우선 + 오늘/어제/MM-DD HH:MM 포맷

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:19:32 +09:00
be46da0a1f feat(subscription): 종료 청약 일괄 삭제 버튼 추가
AnnouncementsTab 툴바에 '🗑 종료 청약 삭제' 버튼 추가.
확인 다이얼로그 → DELETE /api/realestate/announcements/closed 호출 →
삭제 건수 알림 후 목록 새로고침.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 04:14:50 +09:00
6728b2269e feat(agent-office): Blog + Realestate 에이전트 UI 추가
- AGENT_META/IDS에 blog/realestate 추가 (4 컬럼 대시보드)
- SpriteSheet: 블로그(노트북 액센트)/청약(서류가방 액센트) 픽셀 캐릭터
- office-map: 사무실 책상 4개로 확장, blog_desk/realestate_desk waypoint 추가
- AgentColumn/ChatPanel: 에이전트별 퀵 명령 버튼 (키워드 리서치, 매칭 리포트 등)
- CommandColumn: 타겟 선택지 4명, 빠른 명령 6개, 아이콘 맵핑
- DocumentPanel: 에이전트별 탭 4개

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 03:06:19 +09:00
cfc45fc43f feat(agent-office): AI 토큰 사용량 뱃지 표시
- api.js: getAgentTokenUsage 헬퍼 추가
- AgentColumn: 헤더에 오늘 토큰 사용량 뱃지 (🧮 N,NNN)
- 30초 폴링 + state 변경 시 즉시 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 01:44:37 +09:00
a165d6271f refactor(agent-office): dashboard layout with agent columns + CEO command panel
- Restructure layout: dashboard (top, 3 columns) + office canvas (bottom, 280px)
- AgentColumn: per-agent status, quick commands, approval UI, task history
- CommandColumn: CEO command input with agent selector, quick shortcuts, history
- Remove overlay panels (ChatPanel/DocumentPanel) - integrated into dashboard
- Office canvas shrunk to compact strip at bottom

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 15:32:07 +09:00
deb285695a feat(agent-office): notification badges + CEO desk document panel + telegram test
- Add notification state management with badge counts in useAgentManager
- Render exclamation badge on agent sprites (separate from status icons)
- Add CEO desk document icon with click-to-open activity panel
- Create DocumentPanel with unified activity feed + per-agent detail tabs
- Add telegram test button to stock agent ChatPanel
- Remove TaskHistory + bottom toolbar (replaced by DocumentPanel)
- Add getActivityFeed API helper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 15:19:14 +09:00
25715a2198 feat: Agent Office — AI 에이전트 가상 오피스 (#2)
## Summary
- Canvas 2D 픽셀아트 오피스 렌더링 (SpriteSheet + TileMap + AgentSprite)
- WebSocket 실시간 에이전트 상태 동기화 (useAgentManager)
- ChatPanel (명령/승인) + TaskHistory (작업 이력) UI
- 다크 테마 + glassmorphism 패널

## Changes (7 commits)
- API helpers + route + Lab entry
- Canvas engine: SpriteSheet, TileMap, AgentSprite, OfficeRenderer
- React hooks: useAgentManager, useOfficeCanvas
- Components: ChatPanel, TaskHistory
- Main page + CSS
- Code review fixes: claude agent 참조 제거, rejected 배지 추가

Reviewed-on: https://gitea.gahusb.synology.me/gahusb/web-page/pulls/2
2026-04-11 13:35:35 +09:00
82 changed files with 5825 additions and 2870 deletions

View File

@@ -222,7 +222,32 @@ handleGenerate()
## Lotto 고도화 (`/lotto`) ## Lotto 고도화 (`/lotto`)
`src/pages/lotto/Functions.jsx`에 4개 신규 섹션 추가: `src/pages/lotto/Functions.jsx`는 3탭 구조 (`브리핑 / 분석·통계 / 구매·성과`)로 리팩토링되었습니다.
| 탭 | 파일 | 설명 |
|----|------|------|
| 이번 주 브리핑 | `tabs/BriefingTab.jsx` | AI 큐레이터 브리핑 표시 (`components/briefing/` 하위 컴포넌트) |
| 분석·통계 | `tabs/AnalysisTab.jsx` | 시뮬레이션 추천·통계·ReportPanel·수동 추천 |
| 구매·성과 | `tabs/PurchaseTab.jsx` | 구매 내역 CRUD + 성과 통계 |
### 브리핑 전용 컴포넌트 (`components/briefing/`)
| 컴포넌트 | 설명 |
|----------|------|
| `BriefingTab.jsx` | 탭 루트, 브리핑 로드 + 트리거 |
| `BriefingHeader.jsx` | 회차·생성일시 헤더 |
| `BriefingSummary.jsx` | 내러티브 요약 표시 |
| `PickSetCard.jsx` | 번호 세트 1장 카드 |
| `BriefingEmpty.jsx` | 브리핑 없을 때 빈 상태 |
| `CuratorUsageFooter.jsx` | 토큰·비용 집계 푸터 |
### 신규 api.js 헬퍼
- `getLatestBriefing()``GET /api/lotto/briefing/latest`
- `getCuratorUsage(days)``GET /api/lotto/curator/usage?days=N`
- `triggerLottoCurate()``POST /api/agent-office/command` (lotto_agent curate 명령)
### 기존 섹션 (AnalysisTab 내)
| 섹션 | API | 설명 | | 섹션 | API | 설명 |
|------|-----|------| |------|-----|------|

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/main_logo.png" /> <link rel="icon" type="image/svg+xml" href="/main_logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>가후습 개인기록</title> <title>가후습 개인기록</title>
</head> </head>
<body> <body>

10
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.3", "react-router-dom": "^6.30.3",
"react-swipeable": "^7.0.2",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"three": "^0.182.0" "three": "^0.182.0"
}, },
@@ -3088,6 +3089,15 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/react-swipeable": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz",
"integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/recharts": { "node_modules/recharts": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",

View File

@@ -18,6 +18,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.3", "react-router-dom": "^6.30.3",
"react-swipeable": "^7.0.2",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"three": "^0.182.0" "three": "^0.182.0"
}, },

View File

@@ -62,6 +62,7 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.site-main { .site-main {
padding: 16px; padding: 16px;
padding-bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
} }
} }
@@ -491,3 +492,15 @@
flex: none; flex: none;
} }
} }
/* ── Accessibility: Reduced Motion ──────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -1,11 +1,15 @@
import React from 'react'; import React from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import Navbar from './components/Navbar'; import Navbar from './components/Navbar';
import BottomNav from './components/BottomNav';
import PageHeader from './components/PageHeader'; import PageHeader from './components/PageHeader';
import Loading from './components/Loading'; import Loading from './components/Loading';
import { useIsMobile } from './hooks/useIsMobile';
import './App.css'; import './App.css';
function App() { function App() {
const isMobile = useIsMobile();
return ( return (
<div className="app-shell"> <div className="app-shell">
<Navbar /> <Navbar />
@@ -17,6 +21,7 @@ function App() {
</React.Suspense> </React.Suspense>
</main> </main>
</div> </div>
{isMobile && <BottomNav />}
</div> </div>
); );
} }

View File

@@ -598,4 +598,31 @@ export const getPendingTasks = () => apiGet('/api/agent-office/tasks
export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/agent-office/command', { agent, action, params }); export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/agent-office/command', { agent, action, params });
export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback }); export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
export const getAgentStates = () => apiGet('/api/agent-office/states'); export const getAgentStates = () => apiGet('/api/agent-office/states');
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
export const getAgentTokenUsage = (id, days=1) => apiGet(`/api/agent-office/agents/${id}/token-usage?days=${days}`);
// --- Lotto Briefing ---
export async function getLatestBriefing() {
const r = await fetch('/api/lotto/briefing/latest');
if (r.status === 404) return null;
if (!r.ok) throw new Error(`briefing fetch failed: ${r.status}`);
return r.json();
}
export async function getCuratorUsage(days = 30) {
const r = await fetch(`/api/lotto/curator/usage?days=${days}`);
if (!r.ok) throw new Error(`usage fetch failed: ${r.status}`);
return r.json();
}
export async function triggerLottoCurate() {
const r = await fetch('/api/agent-office/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent: 'lotto', action: 'curate_now', params: {} }),
});
if (!r.ok) throw new Error(`curate trigger failed: ${r.status}`);
return r.json();
}

View File

@@ -0,0 +1,167 @@
/* BottomNav — mobile bottom navigation */
.bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--bottom-nav-h);
padding-bottom: var(--safe-area-bottom);
background: var(--bg-secondary);
border-top: 1px solid var(--line);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
z-index: 300;
align-items: stretch;
justify-content: space-around;
}
@media (max-width: 768px) {
.bottom-nav {
display: flex;
}
}
/* Primary nav items */
.bottom-nav__item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
min-width: 48px;
min-height: 48px;
gap: 3px;
color: var(--text-dim);
text-decoration: none;
font-family: var(--font-body);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
transition: color 0.18s var(--ease-out);
-webkit-tap-highlight-color: transparent;
outline: none;
border: none;
background: none;
cursor: pointer;
padding: 4px 2px;
}
.bottom-nav__item:hover,
.bottom-nav__item.is-active,
.bottom-nav__item--active {
color: var(--neon-cyan);
}
.bottom-nav__item:hover .bottom-nav__icon,
.bottom-nav__item.is-active .bottom-nav__icon,
.bottom-nav__item--active .bottom-nav__icon {
filter: drop-shadow(0 0 6px var(--neon-cyan-dim));
}
/* Icon wrapper */
.bottom-nav__icon {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
transition: filter 0.18s var(--ease-out);
}
.bottom-nav__icon svg,
.bottom-nav__icon > * {
width: 22px;
height: 22px;
}
/* Label */
.bottom-nav__label {
line-height: 1;
white-space: nowrap;
}
/* ---- More overlay backdrop ---- */
.bottom-nav__more-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 298;
opacity: 0;
pointer-events: none;
transition: opacity 0.22s var(--ease-out);
}
.bottom-nav__more-overlay.is-open {
opacity: 1;
pointer-events: auto;
}
/* ---- More panel ---- */
.bottom-nav__more-panel {
position: fixed;
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
left: 0;
right: 0;
z-index: 299;
padding: 16px 12px 12px;
background: var(--surface-raised);
border-top: 1px solid var(--line);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
transform: translateY(100%);
transition: transform 0.25s var(--ease-out);
}
.bottom-nav__more-panel.is-open {
transform: translateY(0);
}
/* More panel item */
.bottom-nav__more-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 4px;
color: var(--text-dim);
text-decoration: none;
font-family: var(--font-body);
font-size: 11px;
font-weight: 500;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-md);
transition: color 0.18s var(--ease-out), border-color 0.18s var(--ease-out);
-webkit-tap-highlight-color: transparent;
cursor: pointer;
}
.bottom-nav__more-item:hover,
.bottom-nav__more-item.is-active {
color: var(--neon-cyan);
border-color: var(--neon-cyan-dim);
}
.bottom-nav__more-item:hover .bottom-nav__icon,
.bottom-nav__more-item.is-active .bottom-nav__icon {
filter: drop-shadow(0 0 6px var(--neon-cyan-dim));
}
/* Reduce motion */
@media (prefers-reduced-motion: reduce) {
.bottom-nav__item,
.bottom-nav__icon,
.bottom-nav__more-overlay,
.bottom-nav__more-panel,
.bottom-nav__more-item {
transition: none;
}
}

View File

@@ -0,0 +1,114 @@
import { useState, useCallback } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { navLinks } from '../routes';
import './BottomNav.css';
const PRIMARY_PATHS = ['/', '/lotto', '/stock', '/travel'];
// Vertical dots (three circles) icon for "more"
function MoreDotsIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 22 22"
fill="currentColor"
aria-hidden="true"
>
<circle cx="11" cy="4.5" r="1.8" />
<circle cx="11" cy="11" r="1.8" />
<circle cx="11" cy="17.5" r="1.8" />
</svg>
);
}
const primaryLinks = navLinks.filter((link) =>
PRIMARY_PATHS.includes(link.path)
);
// Preserve the order defined in PRIMARY_PATHS
const orderedPrimaryLinks = PRIMARY_PATHS.map((p) =>
primaryLinks.find((l) => l.path === p)
).filter(Boolean);
const moreLinks = navLinks.filter(
(link) => !PRIMARY_PATHS.includes(link.path)
);
export default function BottomNav() {
const [moreOpen, setMoreOpen] = useState(false);
const location = useLocation();
const openMore = useCallback(() => setMoreOpen(true), []);
const closeMore = useCallback(() => setMoreOpen(false), []);
const toggleMore = useCallback(() => setMoreOpen((prev) => !prev), []);
// Highlight the "more" button when the current path belongs to moreLinks
const isMoreActive =
moreOpen || moreLinks.some((link) => location.pathname === link.path);
return (
<>
{/* Backdrop */}
<div
className={`bottom-nav__more-overlay${moreOpen ? ' is-open' : ''}`}
onClick={closeMore}
aria-hidden="true"
/>
{/* More panel */}
<div
className={`bottom-nav__more-panel${moreOpen ? ' is-open' : ''}`}
role="menu"
aria-label="더보기 메뉴"
>
{moreLinks.map((link) => (
<NavLink
key={link.id}
to={link.path}
className={({ isActive }) =>
`bottom-nav__more-item${isActive ? ' is-active' : ''}`
}
onClick={closeMore}
role="menuitem"
>
<span className="bottom-nav__icon">{link.icon}</span>
<span className="bottom-nav__label">{link.label}</span>
</NavLink>
))}
</div>
{/* Bottom nav bar */}
<nav className="bottom-nav" aria-label="하단 내비게이션">
{orderedPrimaryLinks.map((link) => (
<NavLink
key={link.id}
to={link.path}
end={link.path === '/'}
className={({ isActive }) =>
`bottom-nav__item${isActive ? ' is-active' : ''}`
}
>
<span className="bottom-nav__icon">{link.icon}</span>
<span className="bottom-nav__label">{link.label}</span>
</NavLink>
))}
{/* More button */}
<button
type="button"
className={`bottom-nav__item${isMoreActive ? ' is-active' : ''}`}
onClick={toggleMore}
aria-expanded={moreOpen}
aria-haspopup="menu"
aria-label="더보기"
>
<span className="bottom-nav__icon">
<MoreDotsIcon />
</span>
<span className="bottom-nav__label">더보기</span>
</button>
</nav>
</>
);
}

50
src/components/FAB.css Normal file
View File

@@ -0,0 +1,50 @@
/* FAB — Floating Action Button (mobile-only) */
.fab {
display: none;
position: fixed;
right: 20px;
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom) + 16px);
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--grad-accent);
border: none;
color: #000;
font-size: 24px;
z-index: 250;
box-shadow: 0 0 0 1px var(--neon-cyan-dim), 0 4px 16px rgba(0, 255, 255, 0.25);
align-items: center;
justify-content: center;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out);
}
@media (max-width: 768px) {
.fab {
display: flex;
}
}
.fab:active {
transform: scale(0.92);
}
.fab__icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
/* Variant: positioned above a music mini-player */
.fab--above-player {
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom) + 16px + 56px);
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.fab {
transition: none;
}
}

37
src/components/FAB.jsx Normal file
View File

@@ -0,0 +1,37 @@
import { useIsMobile } from '../hooks/useIsMobile';
import './FAB.css';
const PlusIcon = () => (
<svg
className="fab__icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
export default function FAB({ onClick, icon, label = '액션', className = '' }) {
const isMobile = useIsMobile();
if (!isMobile) return null;
return (
<button
type="button"
className={`fab ${className}`}
onClick={onClick}
aria-label={label}
>
{icon ?? <PlusIcon />}
</button>
);
}

View File

@@ -0,0 +1,125 @@
/* MobileSheet — bottom sheet modal */
/* Backdrop */
.mobile-sheet__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 400;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s var(--ease-out);
}
.mobile-sheet__backdrop.is-open {
opacity: 1;
pointer-events: auto;
}
/* Sheet */
.mobile-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 90vh;
background: var(--bg-secondary);
border-top: 1px solid var(--line);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
z-index: 401;
display: flex;
flex-direction: column;
touch-action: none;
transform: translateY(100%);
transition: transform 0.3s var(--ease-out);
}
.mobile-sheet.is-open {
transform: translateY(0);
}
/* Snap variants */
.mobile-sheet.snap-half {
max-height: 50vh;
}
/* Drag handle area */
.mobile-sheet__handle {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 0 8px;
cursor: grab;
flex-shrink: 0;
}
.mobile-sheet__handle:active {
cursor: grabbing;
}
.mobile-sheet__handle-bar {
display: block;
width: 36px;
height: 4px;
background: var(--text-muted);
border-radius: 2px;
}
/* Header */
.mobile-sheet__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px 12px;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.mobile-sheet__title {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text-bright);
}
.mobile-sheet__close {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
border-radius: var(--radius-sm);
-webkit-tap-highlight-color: transparent;
transition: color 0.18s var(--ease-out);
}
.mobile-sheet__close:hover {
color: var(--text);
}
/* Scrollable body */
.mobile-sheet__body {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
padding-bottom: calc(16px + var(--safe-area-bottom));
overscroll-behavior: contain;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.mobile-sheet__backdrop,
.mobile-sheet {
transition: none;
}
.mobile-sheet__close {
transition: none;
}
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from 'react';
import './MobileSheet.css';
export default function MobileSheet({ open, onClose, title, snap = 'full', children }) {
const [dragY, setDragY] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const startYRef = useRef(null);
const sheetRef = useRef(null);
// Lock body scroll when open
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
// Reset drag state on close
useEffect(() => {
if (!open) {
setDragY(0);
setIsDragging(false);
}
}, [open]);
const handleHandleTouchStart = (e) => {
startYRef.current = e.touches[0].clientY;
setIsDragging(true);
};
const handleHandleTouchMove = (e) => {
if (startYRef.current === null) return;
const delta = e.touches[0].clientY - startYRef.current;
if (delta < 0) return; // no drag up
setDragY(delta);
};
const handleHandleTouchEnd = () => {
setIsDragging(false);
if (dragY > 100) {
setDragY(0);
onClose?.();
} else {
setDragY(0);
}
startYRef.current = null;
};
const sheetTransform = open
? `translateY(${isDragging ? dragY : 0}px)`
: 'translateY(100%)';
return (
<>
<div
className={`mobile-sheet__backdrop ${open ? 'is-open' : ''}`}
onClick={onClose}
aria-hidden="true"
/>
<div
ref={sheetRef}
className={`mobile-sheet snap-${snap} ${open ? 'is-open' : ''}`}
style={{
transform: sheetTransform,
transition: isDragging ? 'none' : undefined,
}}
role="dialog"
aria-modal="true"
aria-label={title}
>
{/* Drag handle */}
<div
className="mobile-sheet__handle"
onTouchStart={handleHandleTouchStart}
onTouchMove={handleHandleTouchMove}
onTouchEnd={handleHandleTouchEnd}
aria-hidden="true"
>
<span className="mobile-sheet__handle-bar" />
</div>
{/* Header */}
<div className="mobile-sheet__header">
<span className="mobile-sheet__title">{title}</span>
<button
type="button"
className="mobile-sheet__close"
onClick={onClose}
aria-label="닫기"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M3 3l12 12M15 3L3 15"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
</button>
</div>
{/* Body */}
<div className="mobile-sheet__body">
{children}
</div>
</div>
</>
);
}

View File

@@ -334,26 +334,6 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .sidebar {
transform: translateX(-100%);
}
.sidebar.is-open {
transform: translateX(0);
}
.sidebar-toggle {
display: flex;
}
}
/* ── 데스크톱: 토글 버튼 숨김 ────────────────────────────────────────── */
@media (min-width: 769px) {
.sidebar-toggle {
display: none;
}
.sidebar__overlay {
display: none; display: none;
} }
} }

View File

@@ -1,92 +1,58 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { navLinks } from '../routes.jsx'; import { navLinks } from '../routes.jsx';
import { useIsMobile } from '../hooks/useIsMobile';
import mainLogo from '../assets/main_logo.png'; import mainLogo from '../assets/main_logo.png';
import './Navbar.css'; import './Navbar.css';
const Navbar = () => { const Navbar = () => {
const [menuOpen, setMenuOpen] = useState(false); const isMobile = useIsMobile();
const closeMenu = () => setMenuOpen(false);
useEffect(() => { // 모바일에서는 BottomNav가 대체하므로 사이드바 미렌더링
document.body.style.overflow = menuOpen ? 'hidden' : ''; if (isMobile) return null;
return () => {
document.body.style.overflow = '';
};
}, [menuOpen]);
return ( return (
<> <aside className="sidebar">
{/* 모바일 오버레이 */} <div className="sidebar__brand">
<div <img src={mainLogo} alt="Logo" className="sidebar__logo" />
className={`sidebar__overlay${menuOpen ? ' is-visible' : ''}`} <div className="sidebar__brand-text">
onClick={closeMenu} <p className="sidebar__brand-name">Jaeoh</p>
aria-hidden="true" <p className="sidebar__brand-sub">MANAGEMENT ROOM</p>
/>
{/* 모바일 토글 버튼 */}
<button
type="button"
className="sidebar-toggle"
onClick={() => setMenuOpen((prev) => !prev)}
aria-label="메뉴 열기/닫기"
aria-expanded={menuOpen}
>
<span className={`sidebar-toggle__icon${menuOpen ? ' is-open' : ''}`}>
<span />
<span />
<span />
</span>
</button>
{/* 사이드바 본체 */}
<aside className={`sidebar${menuOpen ? ' is-open' : ''}`}>
{/* 브랜드 섹션 */}
<div className="sidebar__brand">
<img src={mainLogo} alt="Logo" className="sidebar__logo" />
<div className="sidebar__brand-text">
<p className="sidebar__brand-name">Jaeoh</p>
<p className="sidebar__brand-sub">MANAGEMENT ROOM</p>
</div>
</div> </div>
</div>
{/* 구분선 */} <div className="sidebar__divider" />
<nav className="sidebar__nav">
<p className="sidebar__section-label">NAVIGATION</p>
{navLinks.map((link) => (
<NavLink
key={link.id}
to={link.path}
className={({ isActive }) =>
`sidebar__item${isActive ? ' is-active' : ''}`
}
style={{ '--item-accent': link.accent }}
end={link.path === '/'}
>
<span className="sidebar__item-icon">{link.icon}</span>
<span className="sidebar__item-label">{link.label}</span>
<span className="sidebar__item-dot" />
</NavLink>
))}
</nav>
<div className="sidebar__footer">
<div className="sidebar__divider" /> <div className="sidebar__divider" />
<div className="sidebar__footer-content">
{/* 네비게이션 */} <div className="sidebar__status">
<nav className="sidebar__nav"> <span className="sidebar__status-dot" />
<p className="sidebar__section-label">NAVIGATION</p> <span className="sidebar__status-text">System Online</span>
{navLinks.map((link) => (
<NavLink
key={link.id}
to={link.path}
onClick={closeMenu}
className={({ isActive }) =>
`sidebar__item${isActive ? ' is-active' : ''}`
}
style={{ '--item-accent': link.accent }}
end={link.path === '/'}
>
<span className="sidebar__item-icon">{link.icon}</span>
<span className="sidebar__item-label">{link.label}</span>
<span className="sidebar__item-dot" />
</NavLink>
))}
</nav>
{/* 사이드바 푸터 */}
<div className="sidebar__footer">
<div className="sidebar__divider" />
<div className="sidebar__footer-content">
<div className="sidebar__status">
<span className="sidebar__status-dot" />
<span className="sidebar__status-text">System Online</span>
</div>
<p className="sidebar__version">v2.0.0</p>
</div> </div>
<p className="sidebar__version">v2.0.0</p>
</div> </div>
</aside> </div>
</> </aside>
); );
}; };

View File

@@ -0,0 +1,86 @@
/* PullToRefresh — pull-down-to-refresh wrapper */
.pull-to-refresh {
position: relative;
overscroll-behavior-y: contain;
}
/* Indicator circle */
.pull-to-refresh__indicator {
position: absolute;
top: -48px;
left: 50%;
transform: translateX(-50%);
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--surface-card);
border: 1px solid var(--line);
box-shadow: var(--shadow-md);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.18s var(--ease-out);
z-index: 10;
color: var(--neon-cyan);
}
.pull-to-refresh__indicator.is-visible {
opacity: 1;
}
/* Spinner */
.pull-to-refresh__spinner {
display: block;
width: 20px;
height: 20px;
border: 2px solid var(--line);
border-top-color: var(--neon-cyan);
border-radius: 50%;
animation: ptr-spin 0.7s linear infinite;
}
@keyframes ptr-spin {
to { transform: rotate(360deg); }
}
/* Arrow chevron */
.pull-to-refresh__arrow {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
transition: transform 0.2s var(--ease-out);
}
.pull-to-refresh__arrow.is-ready {
transform: rotate(180deg);
}
/* Content area */
.pull-to-refresh__content {
will-change: transform;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.pull-to-refresh__spinner {
animation: none;
border-top-color: var(--neon-cyan);
}
.pull-to-refresh__arrow {
transition: none;
}
.pull-to-refresh__indicator {
transition: none;
}
.pull-to-refresh__content {
transition: none !important;
}
}

View File

@@ -0,0 +1,99 @@
import { useRef, useState, useCallback } from 'react';
import { useIsMobile } from '../hooks/useIsMobile';
import './PullToRefresh.css';
const THRESHOLD = 60;
const MAX_PULL = 120;
const RESISTANCE = 0.5;
const CONTENT_SHIFT_FACTOR = 0.3;
export default function PullToRefresh({ onRefresh, children, className = '' }) {
const isMobile = useIsMobile();
const [pullY, setPullY] = useState(0);
const [state, setState] = useState('idle'); // idle | pulling | ready | refreshing
const startYRef = useRef(null);
const containerRef = useRef(null);
const handleTouchStart = useCallback((e) => {
const el = containerRef.current;
if (!el) return;
if (el.scrollTop > 0) return; // only trigger at top
startYRef.current = e.touches[0].clientY;
}, []);
const handleTouchMove = useCallback((e) => {
if (startYRef.current === null) return;
const delta = e.touches[0].clientY - startYRef.current;
if (delta <= 0) {
setPullY(0);
setState('idle');
return;
}
const clamped = Math.min(delta * RESISTANCE, MAX_PULL);
setPullY(clamped);
setState(clamped >= THRESHOLD ? 'ready' : 'pulling');
}, []);
const handleTouchEnd = useCallback(async () => {
if (startYRef.current === null) return;
startYRef.current = null;
if (state === 'ready') {
setState('refreshing');
setPullY(THRESHOLD);
try {
await onRefresh?.();
} finally {
setState('idle');
setPullY(0);
}
} else {
setState('idle');
setPullY(0);
}
}, [state, onRefresh]);
if (!isMobile) {
return <div className={className}>{children}</div>;
}
const indicatorVisible = state !== 'idle';
const contentShift = pullY * CONTENT_SHIFT_FACTOR;
return (
<div
ref={containerRef}
className={`pull-to-refresh ${className}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div
className={`pull-to-refresh__indicator ${indicatorVisible ? 'is-visible' : ''}`}
style={{ transform: `translateY(${pullY}px)` }}
aria-hidden="true"
>
{state === 'refreshing' ? (
<span className="pull-to-refresh__spinner" />
) : (
<span className={`pull-to-refresh__arrow ${state === 'ready' ? 'is-ready' : ''}`}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M9 3v10M4 8l5 5 5-5"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</div>
<div
className="pull-to-refresh__content"
style={{ transform: `translateY(${contentShift}px)`, transition: state === 'idle' ? 'transform 0.3s var(--ease-out)' : 'none' }}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
/* SwipeableView — swipeable tab container */
.swipeable-view {
overflow: hidden;
position: relative;
width: 100%;
}
/* Tab bar */
.swipeable-view__tabs {
display: flex;
gap: 4px;
padding: 4px;
background: var(--surface);
border-radius: var(--radius-md);
border: 1px solid var(--line);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
margin-bottom: 8px;
}
.swipeable-view__tabs::-webkit-scrollbar {
display: none;
}
/* Individual tab button */
.swipeable-view__tab {
flex: 1;
min-width: fit-content;
padding: 8px 16px;
background: none;
border: none;
color: var(--text-dim);
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
border-radius: calc(var(--radius-md) - 2px);
cursor: pointer;
white-space: nowrap;
transition: color 0.18s var(--ease-out), background 0.18s var(--ease-out);
-webkit-tap-highlight-color: transparent;
outline: none;
}
.swipeable-view__tab.is-active {
background: var(--surface-raised);
color: var(--neon-cyan);
}
/* Sliding track */
.swipeable-view__track {
display: flex;
width: 100%;
transition: transform 0.3s var(--ease-out);
will-change: transform;
}
.swipeable-view__track.is-swiping {
transition: none;
}
/* Each panel */
.swipeable-view__panel {
flex: 0 0 100%;
min-width: 0;
overflow-y: auto;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.swipeable-view__track {
transition: none;
}
.swipeable-view__tab {
transition: none;
}
}

View File

@@ -0,0 +1,92 @@
import { useState, useRef } from 'react';
import { useSwipeable } from 'react-swipeable';
import { useIsMobile } from '../hooks/useIsMobile';
import './SwipeableView.css';
export default function SwipeableView({
tabs = [],
activeIndex: controlledIndex,
onTabChange,
showTabs = true,
}) {
const isMobile = useIsMobile();
const [internalIndex, setInternalIndex] = useState(0);
const [swipeOffset, setSwipeOffset] = useState(0);
const [isSwiping, setIsSwiping] = useState(false);
const trackRef = useRef(null);
const activeIndex = controlledIndex !== undefined ? controlledIndex : internalIndex;
const setIndex = (idx) => {
const clamped = Math.max(0, Math.min(tabs.length - 1, idx));
if (controlledIndex === undefined) setInternalIndex(clamped);
onTabChange?.(clamped);
};
const handlers = useSwipeable({
onSwiping: ({ deltaX }) => {
if (!isMobile) return;
setIsSwiping(true);
setSwipeOffset(deltaX);
},
onSwipedLeft: ({ deltaX }) => {
if (!isMobile) return;
setIsSwiping(false);
setSwipeOffset(0);
if (Math.abs(deltaX) > 30) setIndex(activeIndex + 1);
},
onSwipedRight: ({ deltaX }) => {
if (!isMobile) return;
setIsSwiping(false);
setSwipeOffset(0);
if (Math.abs(deltaX) > 30) setIndex(activeIndex - 1);
},
onTouchEndOrOnMouseUp: () => {
setIsSwiping(false);
setSwipeOffset(0);
},
trackMouse: false,
trackTouch: true,
delta: 30,
preventScrollOnSwipe: false,
});
const trackTranslate = -activeIndex * 100 + (isSwiping ? (swipeOffset / (trackRef.current?.offsetWidth || 1)) * 100 : 0);
return (
<div className="swipeable-view">
{showTabs && (
<div className="swipeable-view__tabs" role="tablist">
{tabs.map((tab, i) => (
<button
key={tab.key}
role="tab"
aria-selected={i === activeIndex}
className={`swipeable-view__tab ${i === activeIndex ? 'is-active' : ''}`}
onClick={() => setIndex(i)}
>
{tab.label}
</button>
))}
</div>
)}
<div
{...(isMobile ? handlers : {})}
ref={trackRef}
className={`swipeable-view__track ${isSwiping ? 'is-swiping' : ''}`}
style={{ transform: `translateX(${trackTranslate}%)` }}
>
{tabs.map((tab, i) => (
<div
key={tab.key}
role="tabpanel"
aria-hidden={i !== activeIndex}
className="swipeable-view__panel"
>
{tab.content}
</div>
))}
</div>
</div>
);
}

18
src/hooks/useIsMobile.js Normal file
View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(
() => window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches
);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
const handler = (e) => setIsMobile(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return isMobile;
}

View File

@@ -72,6 +72,8 @@
/* ── Layout ──────────────────────────────────────────────────────── */ /* ── Layout ──────────────────────────────────────────────────────── */
--sidebar-w: 240px; --sidebar-w: 240px;
--topbar-h: 56px; --topbar-h: 56px;
--bottom-nav-h: 64px;
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
/* ── Typography ──────────────────────────────────────────────────── */ /* ── Typography ──────────────────────────────────────────────────── */
--font-display: 'Space Grotesk', 'Noto Sans KR', system-ui, serif; --font-display: 'Space Grotesk', 'Noto Sans KR', system-ui, serif;
@@ -113,6 +115,10 @@ html {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
} }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
body { body {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
@@ -240,5 +246,6 @@ select option {
body { body {
overflow: auto; overflow: auto;
background-attachment: scroll; background-attachment: scroll;
padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
} }
} }

View File

@@ -11,13 +11,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px 20px; padding: 8px 20px;
background: #1a1a2e; background: #1a1a2e;
border-bottom: 1px solid #2a2a4a; border-bottom: 1px solid #2a2a4a;
flex-shrink: 0;
} }
.ao-title { .ao-title {
font-size: 1.4rem; font-size: 1.2rem;
color: #8b5cf6; color: #8b5cf6;
margin: 0; margin: 0;
letter-spacing: 2px; letter-spacing: 2px;
@@ -27,7 +28,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 0.85rem; font-size: 0.8rem;
color: #888; color: #888;
} }
@@ -39,152 +40,245 @@
.ao-dot--on { background: #34d399; } .ao-dot--on { background: #34d399; }
.ao-dot--off { background: #f87171; } .ao-dot--off { background: #f87171; }
.ao-workspace { /* Dashboard */
.ao-dashboard {
display: flex;
gap: 1px;
background: #2a2a4a;
flex: 1; flex: 1;
position: relative; min-height: 0;
overflow: hidden; overflow: hidden;
} }
/* Agent Column */
.ao-col {
flex: 1;
display: flex;
flex-direction: column;
background: #0d0d1a;
min-width: 0;
overflow-y: auto;
}
.ao-col-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-top: 3px solid;
background: #1a1a2e;
flex-shrink: 0;
}
.ao-col-chevron {
display: none;
color: #666;
font-size: 0.8rem;
margin-left: 4px;
}
.ao-col-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.ao-col-name {
font-weight: bold;
font-size: 0.9rem;
}
.ao-col-state {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 8px;
text-transform: uppercase;
margin-left: auto;
}
.ao-col-state--idle { background: #333; color: #888; }
.ao-col-state--working { background: #3730a3; color: #a5b4fc; }
.ao-col-state--waiting { background: #92400e; color: #fbbf24; }
.ao-col-state--reporting { background: #065f46; color: #34d399; }
.ao-col-state--break { background: #4c1d95; color: #c4b5fd; }
.ao-col-state--offline { background: #1f1f1f; color: #555; }
.ao-col-tokens {
font-size: 0.7rem;
color: #8b5cf6;
background: rgba(139, 92, 246, 0.12);
padding: 2px 8px;
border-radius: 8px;
margin-left: 6px;
cursor: help;
font-family: 'Courier New', monospace;
white-space: nowrap;
}
.ao-col-badge {
background: #f43f5e;
color: #fff;
font-size: 0.65rem;
padding: 1px 5px;
border-radius: 6px;
font-weight: bold;
}
.ao-col-detail {
padding: 6px 12px;
font-size: 0.8rem;
color: #a78bfa;
background: rgba(139, 92, 246, 0.05);
border-bottom: 1px solid #2a2a4a;
flex-shrink: 0;
}
.ao-col-approval {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(251, 191, 36, 0.08);
border-bottom: 1px solid #2a2a4a;
font-size: 0.8rem;
color: #fbbf24;
flex-shrink: 0;
}
.ao-col-commands {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid #1a1a2e;
flex-shrink: 0;
}
.ao-col-input {
display: flex;
gap: 6px;
padding: 6px 12px;
border-bottom: 1px solid #1a1a2e;
flex-shrink: 0;
}
.ao-col-tasks {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.ao-col-tasks-title {
padding: 4px 12px;
font-size: 0.7rem;
color: #555;
text-transform: uppercase;
letter-spacing: 1px;
}
.ao-col-empty {
padding: 12px;
text-align: center;
color: #444;
font-size: 0.8rem;
}
.ao-col-task {
padding: 6px 12px;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.ao-col-task-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.ao-col-task-type {
font-size: 0.8rem;
color: #ccc;
}
.ao-col-task-badge {
font-size: 0.65rem;
padding: 1px 6px;
border-radius: 4px;
color: #fff;
}
.ao-col-task-time {
font-size: 0.7rem;
color: #555;
margin-top: 2px;
}
.ao-col-task-detail {
margin-top: 4px;
font-size: 0.7rem;
}
.ao-col-task-detail summary {
cursor: pointer;
color: #8b5cf6;
}
.ao-col-task-detail pre {
color: #888;
white-space: pre-wrap;
margin: 4px 0 0;
max-height: 120px;
overflow-y: auto;
}
/* Command Column */
.ao-cmd-form {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid #1a1a2e;
flex-shrink: 0;
}
.ao-cmd-row {
display: flex;
gap: 6px;
}
.ao-cmd-select {
flex: 1;
padding: 6px 8px;
background: #111;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.8rem;
font-family: inherit;
}
.ao-cmd-select:focus { border-color: #8b5cf6; outline: none; }
.ao-cmd-send {
width: 100%;
}
/* Office Section */
.ao-office-section {
height: 280px;
flex-shrink: 0;
border-top: 2px solid #2a2a4a;
position: relative;
}
.ao-canvas-container { .ao-canvas-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.ao-agent-bar { /* Shared */
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.6);
border-radius: 20px;
backdrop-filter: blur(8px);
}
.ao-agent-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border: 1px solid #333;
border-radius: 12px;
background: transparent;
color: #ccc;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
}
.ao-agent-chip:hover { border-color: #8b5cf6; }
.ao-agent-chip--selected { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.15); }
.ao-agent-chip--alert { animation: ao-pulse 1s infinite; }
@keyframes ao-pulse {
0%, 100% { border-color: #fbbf24; }
50% { border-color: #f59e0b; box-shadow: 0 0 8px rgba(251, 191, 36, 0.4); }
}
.ao-chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.ao-chip-dot--idle { background: #666; }
.ao-chip-dot--working { background: #818cf8; }
.ao-chip-dot--waiting { background: #fbbf24; }
.ao-chip-dot--reporting { background: #34d399; }
.ao-chip-dot--break { background: #a78bfa; }
.ao-chip-badge {
background: #f87171;
color: #fff;
font-size: 0.65rem;
padding: 0 4px;
border-radius: 4px;
font-weight: bold;
}
.ao-pending-count {
color: #fbbf24;
font-size: 0.75rem;
align-self: center;
}
.ao-chat-panel {
position: absolute;
right: 16px;
top: 60px;
width: 340px;
max-height: calc(100% - 80px);
background: rgba(26, 26, 46, 0.95);
border: 1px solid #333;
border-radius: 12px;
overflow-y: auto;
backdrop-filter: blur(12px);
}
.ao-chat-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid #2a2a4a;
}
.ao-chat-title {
flex: 1;
font-weight: bold;
color: #e0e0e0;
}
.ao-chat-state {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 8px;
text-transform: uppercase;
}
.ao-chat-state--idle { background: #333; }
.ao-chat-state--working { background: #3730a3; }
.ao-chat-state--waiting { background: #92400e; }
.ao-chat-state--break { background: #4c1d95; }
.ao-chat-close {
background: none;
border: none;
color: #888;
font-size: 1.2rem;
cursor: pointer;
}
.ao-chat-close:hover { color: #fff; }
.ao-chat-detail {
padding: 8px 16px;
color: #aaa;
font-size: 0.85rem;
}
.ao-chat-approval {
padding: 12px 16px;
background: rgba(251, 191, 36, 0.1);
border-top: 1px solid #2a2a4a;
border-bottom: 1px solid #2a2a4a;
}
.ao-chat-approval p {
margin: 0 0 8px;
color: #fbbf24;
font-size: 0.85rem;
}
.ao-chat-approval-btns {
display: flex;
gap: 8px;
}
.ao-btn { .ao-btn {
padding: 6px 16px; padding: 4px 12px;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
font-size: 0.85rem; font-size: 0.8rem;
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
} }
@@ -195,137 +289,112 @@
.ao-btn--send { background: #4c1d95; color: #c4b5fd; } .ao-btn--send { background: #4c1d95; color: #c4b5fd; }
.ao-btn--send:hover { background: #5b21b6; } .ao-btn--send:hover { background: #5b21b6; }
.ao-chat-commands {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px 16px;
}
.ao-cmd-btn { .ao-cmd-btn {
padding: 6px 12px; padding: 4px 10px;
border: 1px solid #333; border: 1px solid #333;
border-radius: 8px; border-radius: 6px;
background: transparent; background: transparent;
color: #ccc; color: #ccc;
font-size: 0.8rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
} }
.ao-cmd-btn:hover { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.1); } .ao-cmd-btn:hover { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.1); }
.ao-chat-input-area {
display: flex;
gap: 8px;
padding: 8px 16px 12px;
}
.ao-chat-input { .ao-chat-input {
flex: 1; flex: 1;
padding: 8px 12px; padding: 6px 10px;
background: #111; background: #111;
border: 1px solid #333; border: 1px solid #333;
border-radius: 6px; border-radius: 6px;
color: #e0e0e0; color: #e0e0e0;
font-size: 0.85rem; font-size: 0.8rem;
font-family: inherit; font-family: inherit;
min-width: 0;
} }
.ao-chat-input:focus { border-color: #8b5cf6; outline: none; } .ao-chat-input:focus { border-color: #8b5cf6; outline: none; }
.ao-chat-result { .ao-doc-tg-status {
padding: 8px 16px;
border-top: 1px solid #2a2a4a;
}
.ao-chat-result h4 {
margin: 0 0 8px;
font-size: 0.8rem;
color: #888;
}
.ao-chat-result pre {
font-size: 0.75rem;
color: #aaa;
overflow-x: auto;
white-space: pre-wrap;
margin: 0;
}
.ao-history-panel {
position: absolute;
left: 16px;
top: 60px;
width: 340px;
max-height: calc(100% - 80px);
background: rgba(26, 26, 46, 0.95);
border: 1px solid #333;
border-radius: 12px;
overflow-y: auto;
backdrop-filter: blur(12px);
}
.ao-history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #2a2a4a;
font-weight: bold;
}
.ao-history-list { padding: 8px; }
.ao-history-empty { text-align: center; color: #666; padding: 20px; }
.ao-history-item {
padding: 10px 12px;
border-bottom: 1px solid #1a1a2e;
}
.ao-history-item:last-child { border-bottom: none; }
.ao-history-item-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.ao-history-type { font-size: 0.85rem; color: #ccc; }
.ao-history-badge {
font-size: 0.7rem; font-size: 0.7rem;
padding: 2px 8px; margin-left: 4px;
border-radius: 4px;
color: #fff;
}
.ao-history-time {
font-size: 0.75rem;
color: #666;
margin-top: 4px;
}
.ao-history-detail {
margin-top: 6px;
font-size: 0.75rem;
}
.ao-history-detail summary {
cursor: pointer;
color: #8b5cf6;
}
.ao-history-detail pre {
color: #aaa;
white-space: pre-wrap;
margin: 4px 0 0;
} }
.ao-toolbar { /* Mobile: vertical stack + accordion */
display: flex; @media (max-width: 768px) {
gap: 8px; .ao-page {
padding: 8px 20px; height: auto;
background: #1a1a2e; min-height: 100vh;
border-top: 1px solid #2a2a4a; }
}
.ao-tool-btn { .ao-dashboard {
padding: 6px 14px; flex-direction: column;
border: 1px solid #333; gap: 1px;
border-radius: 6px; overflow: visible;
background: transparent; flex: none;
color: #aaa; }
font-size: 0.8rem;
cursor: pointer; .ao-col {
font-family: inherit; flex: none;
overflow: visible;
}
.ao-col-header {
cursor: pointer;
user-select: none;
padding: 12px 14px;
}
.ao-col-chevron {
display: inline;
}
.ao-col--collapsed .ao-col-body {
display: none;
}
.ao-col--attention {
box-shadow: inset 3px 0 0 #fbbf24;
}
.ao-col-tasks {
max-height: 260px;
}
.ao-office-section {
height: 140px;
order: -1;
border-top: none;
border-bottom: 2px solid #2a2a4a;
}
.ao-title {
font-size: 1rem;
letter-spacing: 1px;
}
.ao-header {
padding: 8px 12px;
}
.ao-col-commands {
gap: 6px;
}
.ao-cmd-btn,
.ao-btn {
padding: 6px 12px;
font-size: 0.8rem;
}
/* 명령 입력 하단 고정 */
.ao-cmd-form {
position: fixed;
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px));
left: 0;
right: 0;
padding: 8px 16px;
background: var(--bg-secondary, #12122a);
border-top: 1px solid #2a2a4a;
z-index: 200;
}
} }
.ao-tool-btn:hover { border-color: #8b5cf6; color: #e0e0e0; }

View File

@@ -1,22 +1,38 @@
import React, { useRef, useState, useCallback, useEffect } from 'react'; import React, { useRef, useState, useCallback, useEffect } from 'react';
import { useAgentManager } from './hooks/useAgentManager'; import { useAgentManager } from './hooks/useAgentManager';
import { useOfficeCanvas } from './hooks/useOfficeCanvas'; import { useOfficeCanvas } from './hooks/useOfficeCanvas';
import ChatPanel from './components/ChatPanel'; import AgentColumn from './components/AgentColumn';
import TaskHistory from './components/TaskHistory'; import CommandColumn from './components/CommandColumn';
import { useIsMobile } from '../../hooks/useIsMobile';
import MobileSheet from '../../components/MobileSheet';
import './AgentOffice.css'; import './AgentOffice.css';
const AGENT_META = {
stock: { name: '주식 트레이더', color: '#4488cc' },
music: { name: '음악 프로듀서', color: '#44aa88' },
blog: { name: '블로그 마케터', color: '#d97706' },
realestate: { name: '청약 애널리스트', color: '#c026d3' },
};
const AGENT_IDS = ['stock', 'music', 'blog', 'realestate'];
export function Component() { export function Component() {
const canvasContainerRef = useRef(null); const canvasContainerRef = useRef(null);
const [selectedAgent, setSelectedAgent] = useState(null); const isMobile = useIsMobile();
const [showHistory, setShowHistory] = useState(null); const [agentDetailSheet, setAgentDetailSheet] = useState(null); // agentId or null
const { agents, pendingTasks, connected, sendCommand, sendApproval } = useAgentManager(); const { agents, pendingTasks, connected, notifications, sendCommand, sendApproval, clearNotifications } = useAgentManager();
const handleAgentClick = useCallback((agentId) => { const handleAgentClick = useCallback((agentId) => {
setSelectedAgent(prev => prev === agentId ? null : agentId); clearNotifications(agentId);
}, []); if (isMobile) {
setAgentDetailSheet(agentId);
}
}, [clearNotifications, isMobile]);
const { updateAgentState, moveAgent } = useOfficeCanvas(canvasContainerRef, handleAgentClick); const handleCeoClick = useCallback(() => {}, []);
const { updateAgentState, setAgentNotification, setCeoDocBadge } = useOfficeCanvas(canvasContainerRef, handleAgentClick, handleCeoClick);
useEffect(() => { useEffect(() => {
for (const [id, info] of Object.entries(agents)) { for (const [id, info] of Object.entries(agents)) {
@@ -24,6 +40,20 @@ export function Component() {
} }
}, [agents, updateAgentState]); }, [agents, updateAgentState]);
useEffect(() => {
for (const [id, count] of Object.entries(notifications)) {
setAgentNotification(id, count);
}
for (const id of Object.keys(agents)) {
if (!notifications[id]) setAgentNotification(id, 0);
}
}, [notifications, agents, setAgentNotification]);
useEffect(() => {
const total = Object.values(notifications).reduce((s, n) => s + n, 0);
setCeoDocBadge(total);
}, [notifications, setCeoDocBadge]);
return ( return (
<div className="ao-page"> <div className="ao-page">
<div className="ao-header"> <div className="ao-header">
@@ -34,52 +64,47 @@ export function Component() {
</div> </div>
</div> </div>
<div className="ao-workspace"> <div className="ao-dashboard">
<div className="ao-canvas-container" ref={canvasContainerRef} /> {AGENT_IDS.map(id => (
<AgentColumn
<div className="ao-agent-bar"> key={id}
{Object.entries(agents).map(([id, info]) => ( agentId={id}
<button meta={AGENT_META[id]}
key={id} agentState={agents[id]}
className={`ao-agent-chip ${info.state === 'waiting' ? 'ao-agent-chip--alert' : ''} ${selectedAgent === id ? 'ao-agent-chip--selected' : ''}`} notification={notifications[id] || 0}
onClick={() => handleAgentClick(id)}
>
<span className={`ao-chip-dot ao-chip-dot--${info.state}`} />
{id}
{info.state === 'waiting' && <span className="ao-chip-badge">!</span>}
</button>
))}
{pendingTasks.length > 0 && (
<span className="ao-pending-count">{pendingTasks.length} pending</span>
)}
</div>
{selectedAgent && (
<ChatPanel
agentId={selectedAgent}
agentState={agents[selectedAgent]}
onCommand={sendCommand} onCommand={sendCommand}
onApproval={sendApproval} onApproval={sendApproval}
onClose={() => setSelectedAgent(null)} onClearNotification={() => clearNotifications(id)}
/> />
)}
{showHistory && (
<TaskHistory
agentId={showHistory}
onClose={() => setShowHistory(null)}
/>
)}
</div>
<div className="ao-toolbar">
{Object.keys(agents).map(id => (
<button key={id} className="ao-tool-btn"
onClick={() => setShowHistory(prev => prev === id ? null : id)}>
📋 {id} 이력
</button>
))} ))}
<CommandColumn
agents={agents}
onCommand={sendCommand}
/>
</div> </div>
<div className="ao-office-section">
<div className="ao-canvas-container" ref={canvasContainerRef} />
</div>
{/* 모바일: 에이전트 상세 바텀시트 */}
<MobileSheet
open={!!agentDetailSheet}
onClose={() => setAgentDetailSheet(null)}
title={agentDetailSheet ? (AGENT_META[agentDetailSheet]?.name ?? agentDetailSheet) : ''}
>
{agentDetailSheet && (
<AgentColumn
agentId={agentDetailSheet}
meta={AGENT_META[agentDetailSheet]}
agentState={agents[agentDetailSheet]}
notification={notifications[agentDetailSheet] || 0}
onCommand={sendCommand}
onApproval={sendApproval}
onClearNotification={() => clearNotifications(agentDetailSheet)}
/>
)}
</MobileSheet>
</div> </div>
); );
} }

View File

@@ -23,8 +23,8 @@
"furniture": [ "furniture": [
{"type": "desk", "x": 2, "y": 1, "label": "Stock"}, {"type": "desk", "x": 2, "y": 1, "label": "Stock"},
{"type": "desk", "x": 7, "y": 1, "label": "Music"}, {"type": "desk", "x": 7, "y": 1, "label": "Music"},
{"type": "desk", "x": 12, "y": 1, "label": "Claude"}, {"type": "desk", "x": 12, "y": 1, "label": "Blog"},
{"type": "desk", "x": 17, "y": 1, "label": "(빈)"}, {"type": "desk", "x": 17, "y": 1, "label": "Realestate"},
{"type": "table", "x": 8, "y": 6, "w": 4, "h": 2, "label": "회의 테이블"}, {"type": "table", "x": 8, "y": 6, "w": 4, "h": 2, "label": "회의 테이블"},
{"type": "sofa", "x": 1, "y": 10, "label": "휴게실"}, {"type": "sofa", "x": 1, "y": 10, "label": "휴게실"},
{"type": "coffee", "x": 3, "y": 10, "label": "☕"}, {"type": "coffee", "x": 3, "y": 10, "label": "☕"},
@@ -33,7 +33,8 @@
"waypoints": { "waypoints": {
"stock_desk": {"x": 2, "y": 2}, "stock_desk": {"x": 2, "y": 2},
"music_desk": {"x": 7, "y": 2}, "music_desk": {"x": 7, "y": 2},
"claude_desk": {"x": 12, "y": 2}, "blog_desk": {"x": 12, "y": 2},
"realestate_desk": {"x": 17, "y": 2},
"meeting_table": {"x": 9, "y": 7}, "meeting_table": {"x": 9, "y": 7},
"break_room": {"x": 2, "y": 11}, "break_room": {"x": 2, "y": 11},
"ceo_desk": {"x": 16, "y": 11} "ceo_desk": {"x": 16, "y": 11}

View File

@@ -6,6 +6,7 @@ export class AgentSprite {
this.waypoints = waypoints; this.waypoints = waypoints;
this.state = 'idle'; this.state = 'idle';
this.detail = ''; this.detail = '';
this.notificationCount = 0;
const deskKey = `${agentId}_desk`; const deskKey = `${agentId}_desk`;
const desk = waypoints[deskKey] || { x: 5, y: 3 }; const desk = waypoints[deskKey] || { x: 5, y: 3 };
@@ -20,6 +21,10 @@ export class AgentSprite {
this._moveSpeed = 0.05; this._moveSpeed = 0.05;
} }
setNotification(count) {
this.notificationCount = count;
}
setState(newState, detail = '') { setState(newState, detail = '') {
this.state = newState; this.state = newState;
this.detail = detail; this.detail = detail;

View File

@@ -1,6 +1,6 @@
import { drawTileMap } from './TileMap'; import { drawTileMap } from './TileMap';
import { AgentSprite } from './AgentSprite'; import { AgentSprite } from './AgentSprite';
import { getCharLabel } from './SpriteSheet'; import { getCharLabel, drawNotificationBadge } from './SpriteSheet';
const STATUS_ICONS = { const STATUS_ICONS = {
idle: null, idle: null,
@@ -19,8 +19,10 @@ export class OfficeRenderer {
this.agents = {}; this.agents = {};
this._animId = null; this._animId = null;
this._onClick = null; this._onClick = null;
this._onCeoClick = null;
this._ceoDocBadge = 0;
const agentIds = ['stock', 'music']; const agentIds = ['stock', 'music', 'blog', 'realestate'];
for (const id of agentIds) { for (const id of agentIds) {
this.agents[id] = new AgentSprite(id, mapData.waypoints); this.agents[id] = new AgentSprite(id, mapData.waypoints);
} }
@@ -56,6 +58,21 @@ export class OfficeRenderer {
return id; return id;
} }
} }
// CEO desk click detection
const ceo = this.mapData.waypoints.ceo_desk;
if (ceo) {
const { scale, offsetX, offsetY, tileSize } = this.renderInfo;
const cx = offsetX + ceo.x * tileSize * scale;
const cy = offsetY + ceo.y * tileSize * scale;
const hitW = 5 * tileSize * scale;
const hitH = 2 * tileSize * scale;
if (canvasX >= cx - tileSize * scale && canvasY >= cy - tileSize * scale &&
canvasX <= cx + hitW && canvasY <= cy + hitH) {
if (this._onCeoClick) this._onCeoClick();
return 'ceo_desk';
}
}
return null; return null;
} }
@@ -76,6 +93,19 @@ export class OfficeRenderer {
} }
} }
setOnCeoClick(handler) {
this._onCeoClick = handler;
}
setCeoDocBadge(count) {
this._ceoDocBadge = count;
}
setAgentNotification(agentId, count) {
const sprite = this.agents[agentId];
if (sprite) sprite.setNotification(count);
}
_loop(timestamp) { _loop(timestamp) {
const { ctx, canvas, mapData } = this; const { ctx, canvas, mapData } = this;
@@ -95,6 +125,9 @@ export class OfficeRenderer {
this._drawOverlay(ctx, sprite, id); this._drawOverlay(ctx, sprite, id);
} }
// CEO desk document icon
this._drawCeoDoc(ctx);
this._animId = requestAnimationFrame(this._loop); this._animId = requestAnimationFrame(this._loop);
} }
@@ -111,6 +144,11 @@ export class OfficeRenderer {
ctx.fillText(icon, cx, cy - 15 * scale); ctx.fillText(icon, cx, cy - 15 * scale);
} }
// Notification badge (separate from status icon)
if (sprite.notificationCount > 0) {
drawNotificationBadge(ctx, cx, cy - 15 * scale, sprite.notificationCount, scale * 1.5);
}
ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = `${8 * scale}px monospace`; ctx.font = `${8 * scale}px monospace`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
@@ -126,4 +164,48 @@ export class OfficeRenderer {
ctx.fillText(sprite.detail, cx, bubbleY); ctx.fillText(sprite.detail, cx, bubbleY);
} }
} }
_drawCeoDoc(ctx) {
if (!this.renderInfo) return;
const ceo = this.mapData.waypoints.ceo_desk;
if (!ceo) return;
const { scale, offsetX, offsetY, tileSize } = this.renderInfo;
const dx = offsetX + (ceo.x - 1) * tileSize * scale;
const dy = offsetY + (ceo.y - 1) * tileSize * scale;
const docW = 12 * scale;
const docH = 16 * scale;
// Paper
ctx.fillStyle = '#e8e0d0';
ctx.fillRect(dx, dy, docW, docH);
// Lines on paper
ctx.fillStyle = '#bbb';
for (let i = 0; i < 4; i++) {
ctx.fillRect(dx + 2 * scale, dy + (3 + i * 3) * scale, 8 * scale, 1);
}
// Folded corner
ctx.fillStyle = '#d0c8b8';
ctx.beginPath();
ctx.moveTo(dx + docW - 3 * scale, dy);
ctx.lineTo(dx + docW, dy + 3 * scale);
ctx.lineTo(dx + docW - 3 * scale, dy + 3 * scale);
ctx.fill();
// Badge on document
if (this._ceoDocBadge > 0) {
const bx = dx + docW;
const by = dy;
const r = 4 * scale;
ctx.beginPath();
ctx.arc(bx, by, r, 0, Math.PI * 2);
ctx.fillStyle = '#f43f5e';
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = `bold ${5 * scale}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this._ceoDocBadge > 9 ? '9+' : String(this._ceoDocBadge), bx, by);
}
}
} }

View File

@@ -1,6 +1,8 @@
const PIXEL_CHARS = { const PIXEL_CHARS = {
stock: { body: '#4488cc', accent: '#cc4444', label: '주식', hair: '#332222' }, stock: { body: '#4488cc', accent: '#cc4444', label: '주식', hair: '#332222' },
music: { body: '#44aa88', accent: '#ffaa00', label: '음악', hair: '#443322' }, music: { body: '#44aa88', accent: '#ffaa00', label: '음악', hair: '#443322' },
blog: { body: '#d97706', accent: '#fde68a', label: '블로그', hair: '#3b2a1a' },
realestate: { body: '#c026d3', accent: '#86efac', label: '청약', hair: '#2a2a3a' },
claude: { body: '#8855cc', accent: '#cc88ff', label: 'Claude', hair: '#554466' }, claude: { body: '#8855cc', accent: '#cc88ff', label: 'Claude', hair: '#554466' },
}; };
@@ -57,6 +59,14 @@ export function drawAgent(ctx, agentId, x, y, state, frameIndex, scale = 2) {
ctx.fillRect(-4 * s, -4 * s, 1 * s, 4 * s); ctx.fillRect(-4 * s, -4 * s, 1 * s, 4 * s);
ctx.fillRect(3 * s, -4 * s, 1 * s, 4 * s); ctx.fillRect(3 * s, -4 * s, 1 * s, 4 * s);
ctx.fillRect(-4 * s, -5 * s, 8 * s, 1 * s); ctx.fillRect(-4 * s, -5 * s, 8 * s, 1 * s);
} else if (agentId === 'blog') {
// 노트북 액센트 (무릎 위)
ctx.fillRect(-3 * s, 6 * s, 6 * s, 1 * s);
ctx.fillRect(-3 * s, 7 * s, 6 * s, 2 * s);
} else if (agentId === 'realestate') {
// 서류 가방 액센트 (손 옆)
ctx.fillRect(3 * s, 4 * s, 2 * s, 3 * s);
ctx.fillRect(3 * s, 3 * s, 2 * s, 1 * s);
} else if (agentId === 'claude') { } else if (agentId === 'claude') {
ctx.globalAlpha = 0.3 + 0.2 * Math.sin(Date.now() / 500); ctx.globalAlpha = 0.3 + 0.2 * Math.sin(Date.now() / 500);
ctx.fillRect(-4 * s, -6 * s, 8 * s, 1 * s); ctx.fillRect(-4 * s, -6 * s, 8 * s, 1 * s);
@@ -87,3 +97,25 @@ export function getAnimSpeed(state) {
export function getCharLabel(agentId) { export function getCharLabel(agentId) {
return (PIXEL_CHARS[agentId] || {}).label || agentId; return (PIXEL_CHARS[agentId] || {}).label || agentId;
} }
export function drawNotificationBadge(ctx, x, y, count, scale = 2) {
const s = scale;
const badgeX = x + 5 * s;
const badgeY = y - 8 * s;
const radius = 5 * s;
ctx.beginPath();
ctx.arc(badgeX, badgeY, radius, 0, Math.PI * 2);
ctx.fillStyle = '#f43f5e';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = `bold ${7 * s}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('!', badgeX, badgeY);
}

View File

@@ -0,0 +1,203 @@
import React, { useState, useEffect } from 'react';
import { getAgentTasks, getAgentTokenUsage } from '../../../api';
const STATUS_BADGE = {
pending: { label: '대기', bg: '#92400e' },
approved: { label: '승인됨', bg: '#1e40af' },
working: { label: '진행중', bg: '#3730a3' },
succeeded: { label: '완료', bg: '#065f46' },
failed: { label: '실패', bg: '#7f1d1d' },
rejected: { label: '거절됨', bg: '#9a3412' },
};
const AGENT_COMMANDS = {
stock: [
{ action: 'fetch_news', label: '뉴스 수집', icon: '📰' },
{ action: 'list_alerts', label: '알람 목록', icon: '🔔' },
{ action: 'test_telegram', label: 'TG 테스트', icon: '📨' },
],
music: [
{ action: 'compose', label: '작곡 시작', icon: '🎵', needsInput: true },
{ action: 'credits', label: '크레딧', icon: '💳' },
],
blog: [
{ action: 'research', label: '키워드 리서치', icon: '🔍', needsInput: true },
{ action: 'list_trend_keywords', label: '트렌드 목록', icon: '📋' },
],
realestate: [
{ action: 'fetch_matches', label: '매칭 리포트', icon: '🏢' },
{ action: 'dashboard', label: '대시보드', icon: '📊' },
],
};
const AgentColumn = ({ agentId, meta, agentState, notification, onCommand, onApproval, onClearNotification }) => {
const [tasks, setTasks] = useState([]);
const [input, setInput] = useState('');
const [activeCommand, setActiveCommand] = useState(null);
const [tokenUsage, setTokenUsage] = useState(null);
const [expanded, setExpanded] = useState(false);
const state = agentState || { state: 'offline' };
const commands = AGENT_COMMANDS[agentId] || [];
const needsAttention = state.state === 'waiting' || notification > 0;
const isOpen = expanded || needsAttention;
useEffect(() => {
getAgentTasks(agentId, 10)
.then(d => setTasks(d.tasks || []))
.catch(() => setTasks([]));
}, [agentId]);
// Refresh tasks when state changes to idle (task likely completed)
useEffect(() => {
if (state.state === 'idle' && state.detail) {
getAgentTasks(agentId, 10)
.then(d => setTasks(d.tasks || []))
.catch(() => {});
}
}, [agentId, state.state, state.detail]);
// 오늘자 AI 토큰 사용량 폴링 (30초 간격 + 작업 완료 시 즉시 갱신)
useEffect(() => {
let cancelled = false;
const fetchUsage = () => {
getAgentTokenUsage(agentId, 1)
.then(d => { if (!cancelled) setTokenUsage(d); })
.catch(() => {});
};
fetchUsage();
const interval = setInterval(fetchUsage, 30000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [agentId, state.state, state.detail]);
const handleQuickAction = (cmd) => {
if (cmd.needsInput) {
setActiveCommand(cmd.action);
} else {
onCommand(agentId, cmd.action, {});
}
onClearNotification();
};
const handleSend = () => {
if (!input.trim() || !activeCommand) return;
const params = activeCommand === 'compose' ? { prompt: input }
: activeCommand === 'research' ? { keyword: input }
: { message: input };
onCommand(agentId, activeCommand, params);
setInput('');
setActiveCommand(null);
};
const formatTaskTime = (task) => {
const iso = task.completed_at || task.created_at;
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const sameDay = d.toDateString() === now.toDateString();
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
const isYesterday = d.toDateString() === yesterday.toDateString();
if (sameDay) return `오늘 ${hm}`;
if (isYesterday) return `어제 ${hm}`;
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hm}`;
};
const handleHeaderClick = (e) => {
e.stopPropagation();
setExpanded(v => !v);
onClearNotification();
};
return (
<div className={`ao-col ${isOpen ? 'ao-col--open' : 'ao-col--collapsed'} ${needsAttention ? 'ao-col--attention' : ''}`} onClick={onClearNotification}>
<div className="ao-col-header" style={{ borderColor: meta.color }} onClick={handleHeaderClick}>
<span className="ao-col-name" style={{ color: meta.color }}>{meta.name}</span>
{tokenUsage && tokenUsage.total_tokens > 0 && (
<span
className="ao-col-tokens"
title={`오늘 ${tokenUsage.task_count}건 작업 · ${tokenUsage.total_tokens.toLocaleString()} 토큰`}
>
🧮 {tokenUsage.total_tokens.toLocaleString()}
</span>
)}
<span className={`ao-col-state ao-col-state--${state.state}`}>{state.state}</span>
{notification > 0 && <span className="ao-col-badge">{notification}</span>}
<span className="ao-col-chevron" aria-hidden="true">{isOpen ? '▾' : '▸'}</span>
</div>
<div className="ao-col-body">
{state.detail && (
<div className="ao-col-detail">{state.detail}</div>
)}
{state.state === 'waiting' && state.taskId && (
<div className="ao-col-approval">
<span>승인 대기</span>
<button className="ao-btn ao-btn--approve" onClick={() => onApproval(agentId, state.taskId, true)}>승인</button>
<button className="ao-btn ao-btn--reject" onClick={() => onApproval(agentId, state.taskId, false)}>거절</button>
</div>
)}
<div className="ao-col-commands">
{commands.map(cmd => (
<button key={cmd.action} className="ao-cmd-btn" onClick={() => handleQuickAction(cmd)}>
{cmd.icon} {cmd.label}
</button>
))}
</div>
{activeCommand && (
<div className="ao-col-input">
<input
className="ao-chat-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder="입력..."
autoFocus
/>
<button className="ao-btn ao-btn--send" onClick={handleSend}>전송</button>
</div>
)}
<div className="ao-col-tasks">
<div className="ao-col-tasks-title">최근 작업</div>
{tasks.length === 0 && <div className="ao-col-empty">이력 없음</div>}
{tasks.map(task => {
const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending;
return (
<div key={task.id} className="ao-col-task">
<div className="ao-col-task-row">
<span className="ao-col-task-type">{task.task_type}</span>
<span className="ao-col-task-badge" style={{ background: badge.bg }}>{badge.label}</span>
</div>
<div className="ao-col-task-time">
{formatTaskTime(task)}
{task.result_data?.telegram_sent !== undefined && (
<span className="ao-doc-tg-status">{task.result_data.telegram_sent ? ' TG OK' : ' TG Fail'}</span>
)}
</div>
{task.result_data && (
<details className="ao-col-task-detail">
<summary>결과</summary>
<pre>{JSON.stringify(task.result_data, null, 2)}</pre>
</details>
)}
</div>
);
})}
</div>
</div>
</div>
);
};
export default AgentColumn;

View File

@@ -4,11 +4,27 @@ const AGENT_COMMANDS = {
stock: [ stock: [
{ action: 'fetch_news', label: '뉴스 수집', icon: '📰' }, { action: 'fetch_news', label: '뉴스 수집', icon: '📰' },
{ action: 'list_alerts', label: '알람 목록', icon: '🔔' }, { action: 'list_alerts', label: '알람 목록', icon: '🔔' },
{ action: 'test_telegram', label: '텔레그램 테스트', icon: '📨' },
], ],
music: [ music: [
{ action: 'compose', label: '작곡 시작', icon: '🎵', needsInput: true }, { action: 'compose', label: '작곡 시작', icon: '🎵', needsInput: true },
{ action: 'credits', label: '크레딧 확인', icon: '💳' }, { action: 'credits', label: '크레딧 확인', icon: '💳' },
], ],
blog: [
{ action: 'research', label: '키워드 리서치', icon: '🔍', needsInput: true },
{ action: 'list_trend_keywords', label: '트렌드 목록', icon: '📋' },
],
realestate: [
{ action: 'fetch_matches', label: '매칭 리포트', icon: '🏢' },
{ action: 'dashboard', label: '대시보드', icon: '📊' },
],
};
const AGENT_NAMES = {
stock: '주식 트레이더',
music: '음악 프로듀서',
blog: '블로그 마케터',
realestate: '청약 애널리스트',
}; };
const ChatPanel = ({ agentId, agentState, onCommand, onApproval, onClose }) => { const ChatPanel = ({ agentId, agentState, onCommand, onApproval, onClose }) => {
@@ -20,8 +36,8 @@ const ChatPanel = ({ agentId, agentState, onCommand, onApproval, onClose }) => {
const handleSend = () => { const handleSend = () => {
if (!input.trim() || !activeCommand) return; if (!input.trim() || !activeCommand) return;
const params = activeCommand === 'compose' const params = activeCommand === 'compose' ? { prompt: input }
? { prompt: input } : activeCommand === 'research' ? { keyword: input }
: { message: input }; : { message: input };
onCommand(agentId, activeCommand, params); onCommand(agentId, activeCommand, params);
setInput(''); setInput('');
@@ -40,8 +56,7 @@ const ChatPanel = ({ agentId, agentState, onCommand, onApproval, onClose }) => {
<div className="ao-chat-panel"> <div className="ao-chat-panel">
<div className="ao-chat-header"> <div className="ao-chat-header">
<span className="ao-chat-title"> <span className="ao-chat-title">
{agentId === 'stock' ? '주식 트레이더' : {AGENT_NAMES[agentId] || agentId}
agentId === 'music' ? '음악 프로듀서' : agentId}
</span> </span>
<span className={`ao-chat-state ao-chat-state--${state.state || 'idle'}`}> <span className={`ao-chat-state ao-chat-state--${state.state || 'idle'}`}>
{state.state || 'idle'} {state.state || 'idle'}
@@ -86,7 +101,11 @@ const ChatPanel = ({ agentId, agentState, onCommand, onApproval, onClose }) => {
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()} onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder={activeCommand === 'compose' ? '프롬프트 입력...' : '메시지 입력...'} placeholder={
activeCommand === 'compose' ? '프롬프트 입력...'
: activeCommand === 'research' ? '키워드 입력...'
: '메시지 입력...'
}
autoFocus autoFocus
/> />
<button className="ao-btn ao-btn--send" onClick={handleSend}>전송</button> <button className="ao-btn ao-btn--send" onClick={handleSend}>전송</button>

View File

@@ -0,0 +1,115 @@
import React, { useState } from 'react';
const TARGETS = [
{ id: 'stock', name: '주식 트레이더' },
{ id: 'music', name: '음악 프로듀서' },
{ id: 'blog', name: '블로그 마케터' },
{ id: 'realestate', name: '청약 애널리스트' },
];
const TARGET_ICONS = {
stock: '📈',
music: '🎵',
blog: '✍️',
realestate: '🏢',
};
const QUICK_COMMANDS = [
{ target: 'stock', action: 'fetch_news', label: '뉴스 수집' },
{ target: 'stock', action: 'test_telegram', label: 'TG 테스트' },
{ target: 'music', action: 'credits', label: '크레딧 확인' },
{ target: 'blog', action: 'list_trend_keywords', label: '트렌드 목록' },
{ target: 'realestate', action: 'fetch_matches', label: '매칭 리포트' },
{ target: 'realestate', action: 'dashboard', label: '청약 대시보드' },
];
const CommandColumn = ({ agents, onCommand }) => {
const [target, setTarget] = useState('stock');
const [action, setAction] = useState('');
const [params, setParams] = useState('');
const [history, setHistory] = useState([]);
const handleSend = () => {
if (!action.trim()) return;
let parsedParams = {};
if (params.trim()) {
try { parsedParams = JSON.parse(params); }
catch { parsedParams = { message: params }; }
}
onCommand(target, action, parsedParams);
setHistory(prev => [{
time: new Date().toLocaleTimeString(),
target,
action,
params: parsedParams,
}, ...prev].slice(0, 20));
setAction('');
setParams('');
};
const handleQuick = (cmd) => {
onCommand(cmd.target, cmd.action, {});
setHistory(prev => [{
time: new Date().toLocaleTimeString(),
target: cmd.target,
action: cmd.action,
params: {},
}, ...prev].slice(0, 20));
};
return (
<div className="ao-col ao-col--command">
<div className="ao-col-header" style={{ borderColor: '#8b5cf6' }}>
<span className="ao-col-name" style={{ color: '#8b5cf6' }}>CEO 명령</span>
</div>
<div className="ao-cmd-form">
<div className="ao-cmd-row">
<select className="ao-cmd-select" value={target} onChange={e => setTarget(e.target.value)}>
{TARGETS.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<input
className="ao-chat-input"
value={action}
onChange={e => setAction(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder="명령어 (fetch_news, compose...)"
/>
<input
className="ao-chat-input"
value={params}
onChange={e => setParams(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder="파라미터 (JSON 또는 텍스트)"
/>
<button className="ao-btn ao-btn--send ao-cmd-send" onClick={handleSend}>전송</button>
</div>
<div className="ao-col-commands">
{QUICK_COMMANDS.map((cmd, i) => (
<button key={i} className="ao-cmd-btn" onClick={() => handleQuick(cmd)}>
{TARGET_ICONS[cmd.target] || '🤖'} {cmd.label}
</button>
))}
</div>
<div className="ao-col-tasks">
<div className="ao-col-tasks-title">명령 이력</div>
{history.length === 0 && <div className="ao-col-empty">이력 없음</div>}
{history.map((h, i) => (
<div key={i} className="ao-col-task">
<div className="ao-col-task-row">
<span className="ao-col-task-type">{h.target}.{h.action}</span>
<span className="ao-col-task-time">{h.time}</span>
</div>
</div>
))}
</div>
</div>
);
};
export default CommandColumn;

View File

@@ -0,0 +1,195 @@
import React, { useState, useEffect, useCallback } from 'react';
import { getActivityFeed, getAgentTasks, getAgentLogs } from '../../../api';
const STATUS_BADGE = {
pending: { label: '대기', color: '#fbbf24' },
approved: { label: '승인됨', color: '#60a5fa' },
working: { label: '진행중', color: '#818cf8' },
succeeded: { label: '완료', color: '#34d399' },
failed: { label: '실패', color: '#f87171' },
rejected: { label: '거절됨', color: '#fb923c' },
};
const LOG_LEVEL_COLOR = {
info: '#60a5fa',
warning: '#fbbf24',
error: '#f87171',
};
const DocumentPanel = ({ onClose }) => {
const [tab, setTab] = useState('feed');
const [feed, setFeed] = useState([]);
const [feedLoading, setFeedLoading] = useState(false);
const [selectedAgent, setSelectedAgent] = useState('stock');
const [detailTab, setDetailTab] = useState('tasks');
const [tasks, setTasks] = useState([]);
const [logs, setLogs] = useState([]);
const [detailLoading, setDetailLoading] = useState(false);
const loadFeed = useCallback(() => {
setFeedLoading(true);
getActivityFeed(80)
.then(data => setFeed(data.items || []))
.catch(() => setFeed([]))
.finally(() => setFeedLoading(false));
}, []);
const loadDetail = useCallback(() => {
setDetailLoading(true);
Promise.all([
getAgentTasks(selectedAgent, 30).then(d => d.tasks || []).catch(() => []),
getAgentLogs(selectedAgent, 50).then(d => d.logs || []).catch(() => []),
]).then(([t, l]) => {
setTasks(t);
setLogs(l);
}).finally(() => setDetailLoading(false));
}, [selectedAgent]);
useEffect(() => {
if (tab === 'feed') loadFeed();
else loadDetail();
}, [tab, loadFeed, loadDetail]);
const formatTime = (t) => t ? t.replace('T', ' ').slice(0, 19) : '';
return (
<div className="ao-doc-panel">
<div className="ao-doc-header">
<span className="ao-doc-title">CEO 보고서</span>
<button className="ao-chat-close" onClick={onClose}>&times;</button>
</div>
<div className="ao-doc-tabs">
<button
className={`ao-doc-tab ${tab === 'feed' ? 'ao-doc-tab--active' : ''}`}
onClick={() => setTab('feed')}
>활동 피드</button>
<button
className={`ao-doc-tab ${tab === 'detail' ? 'ao-doc-tab--active' : ''}`}
onClick={() => setTab('detail')}
>에이전트별</button>
</div>
{tab === 'feed' && (
<div className="ao-doc-feed">
<div className="ao-doc-feed-toolbar">
<button className="ao-cmd-btn" onClick={loadFeed}>새로고침</button>
</div>
{feedLoading && <p className="ao-history-empty">로딩 ...</p>}
{!feedLoading && feed.length === 0 && <p className="ao-history-empty">활동 없음</p>}
{feed.map((item, i) => (
<div key={i} className="ao-doc-feed-item">
<div className="ao-doc-feed-row">
<span className={`ao-doc-agent-tag ao-doc-agent-tag--${item.agent_id}`}>
{item.agent_id}
</span>
{item.type === 'task' ? (
<span className="ao-history-badge" style={{ background: (STATUS_BADGE[item.status] || STATUS_BADGE.pending).color }}>
{(STATUS_BADGE[item.status] || STATUS_BADGE.pending).label}
</span>
) : (
<span className="ao-doc-log-level" style={{ color: LOG_LEVEL_COLOR[item.level] || '#888' }}>
[{item.level}]
</span>
)}
{item.telegram_sent !== undefined && (
<span className="ao-doc-tg-status">{item.telegram_sent ? 'TG OK' : 'TG Fail'}</span>
)}
</div>
<div className="ao-doc-feed-msg">{item.message}</div>
<div className="ao-doc-feed-time">
{formatTime(item.created_at)}
{item.duration_seconds != null && ` · ${item.duration_seconds}s`}
</div>
</div>
))}
</div>
)}
{tab === 'detail' && (
<div className="ao-doc-detail">
<div className="ao-doc-agent-select">
{[
{ id: 'stock', name: '주식 트레이더' },
{ id: 'music', name: '음악 프로듀서' },
{ id: 'blog', name: '블로그 마케터' },
{ id: 'realestate', name: '청약 애널리스트' },
].map(a => (
<button key={a.id}
className={`ao-doc-tab ${selectedAgent === a.id ? 'ao-doc-tab--active' : ''}`}
onClick={() => setSelectedAgent(a.id)}
>{a.name}</button>
))}
</div>
<div className="ao-doc-detail-tabs">
<button
className={`ao-doc-tab ${detailTab === 'tasks' ? 'ao-doc-tab--active' : ''}`}
onClick={() => setDetailTab('tasks')}
>작업 ({tasks.length})</button>
<button
className={`ao-doc-tab ${detailTab === 'logs' ? 'ao-doc-tab--active' : ''}`}
onClick={() => setDetailTab('logs')}
>로그 ({logs.length})</button>
<button className="ao-cmd-btn" onClick={loadDetail} style={{marginLeft:'auto'}}>새로고침</button>
</div>
{detailLoading && <p className="ao-history-empty">로딩 ...</p>}
{!detailLoading && detailTab === 'tasks' && (
<div className="ao-history-list">
{tasks.length === 0 && <p className="ao-history-empty">이력 없음</p>}
{tasks.map(task => {
const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending;
return (
<div key={task.id} className="ao-history-item">
<div className="ao-history-item-header">
<span className="ao-history-type">{task.task_type}</span>
<span className="ao-history-badge" style={{ background: badge.color }}>
{badge.label}
</span>
</div>
<div className="ao-history-time">
{formatTime(task.created_at)}
{task.completed_at && `${formatTime(task.completed_at)}`}
</div>
{task.result_data && (
<details className="ao-history-detail">
<summary>
결과 보기
{task.result_data.telegram_sent !== undefined && (
<span className="ao-doc-tg-status">
{task.result_data.telegram_sent ? ' TG OK' : ' TG Fail'}
</span>
)}
</summary>
<pre>{JSON.stringify(task.result_data, null, 2)}</pre>
</details>
)}
</div>
);
})}
</div>
)}
{!detailLoading && detailTab === 'logs' && (
<div className="ao-history-list">
{logs.length === 0 && <p className="ao-history-empty">로그 없음</p>}
{logs.map(log => (
<div key={log.id} className="ao-doc-log-item">
<span className="ao-doc-log-level" style={{ color: LOG_LEVEL_COLOR[log.level] || '#888' }}>
[{log.level}]
</span>
<span className="ao-doc-log-msg">{log.message}</span>
<span className="ao-doc-feed-time">{formatTime(log.created_at)}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
export default DocumentPanel;

View File

@@ -1,62 +0,0 @@
import React, { useState, useEffect } from 'react';
import { getAgentTasks } from '../../../api';
const STATUS_BADGE = {
pending: { label: '대기', color: '#fbbf24' },
approved: { label: '승인됨', color: '#60a5fa' },
working: { label: '진행중', color: '#818cf8' },
succeeded: { label: '완료', color: '#34d399' },
failed: { label: '실패', color: '#f87171' },
rejected: { label: '거절됨', color: '#fb923c' },
};
const TaskHistory = ({ agentId, onClose }) => {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!agentId) return;
setLoading(true);
getAgentTasks(agentId, 30)
.then(data => setTasks(data.tasks || []))
.catch(() => setTasks([]))
.finally(() => setLoading(false));
}, [agentId]);
return (
<div className="ao-history-panel">
<div className="ao-history-header">
<span>작업 이력 {agentId}</span>
<button className="ao-chat-close" onClick={onClose}>&times;</button>
</div>
<div className="ao-history-list">
{loading && <p className="ao-history-empty">로딩 ...</p>}
{!loading && tasks.length === 0 && <p className="ao-history-empty">이력 없음</p>}
{tasks.map(task => {
const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending;
return (
<div key={task.id} className="ao-history-item">
<div className="ao-history-item-header">
<span className="ao-history-type">{task.task_type}</span>
<span className="ao-history-badge" style={{ background: badge.color }}>
{badge.label}
</span>
</div>
<div className="ao-history-time">
{task.created_at?.replace('T', ' ').slice(0, 19)}
</div>
{task.result_data && (
<details className="ao-history-detail">
<summary>결과 보기</summary>
<pre>{JSON.stringify(task.result_data, null, 2)}</pre>
</details>
)}
</div>
);
})}
</div>
</div>
);
};
export default TaskHistory;

View File

@@ -4,6 +4,7 @@ export function useAgentManager() {
const [agents, setAgents] = useState({}); const [agents, setAgents] = useState({});
const [pendingTasks, setPendingTasks] = useState([]); const [pendingTasks, setPendingTasks] = useState([]);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [notifications, setNotifications] = useState({});
const wsRef = useRef(null); const wsRef = useRef(null);
const reconnectTimer = useRef(null); const reconnectTimer = useRef(null);
@@ -58,6 +59,12 @@ export function useAgentManager() {
[msg.agent]: { ...prev[msg.agent], lastCommand: msg.result }, [msg.agent]: { ...prev[msg.agent], lastCommand: msg.result },
})); }));
break; break;
case 'notification':
setNotifications(prev => ({
...prev,
[msg.agent]: (prev[msg.agent] || 0) + 1,
}));
break;
default: default:
break; break;
} }
@@ -84,5 +91,13 @@ export function useAgentManager() {
} }
}, []); }, []);
return { agents, pendingTasks, connected, sendCommand, sendApproval }; const clearNotifications = useCallback((agentId) => {
setNotifications(prev => {
const next = { ...prev };
delete next[agentId];
return next;
});
}, []);
return { agents, pendingTasks, connected, notifications, sendCommand, sendApproval, clearNotifications };
} }

View File

@@ -2,7 +2,7 @@ import { useRef, useEffect, useCallback } from 'react';
import { OfficeRenderer } from '../canvas/OfficeRenderer'; import { OfficeRenderer } from '../canvas/OfficeRenderer';
import officeMap from '../assets/office-map.json'; import officeMap from '../assets/office-map.json';
export function useOfficeCanvas(containerRef, onAgentClick) { export function useOfficeCanvas(containerRef, onAgentClick, onCeoClick) {
const rendererRef = useRef(null); const rendererRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -30,6 +30,10 @@ export function useOfficeCanvas(containerRef, onAgentClick) {
if (onAgentClick) onAgentClick(agentId); if (onAgentClick) onAgentClick(agentId);
}); });
renderer.setOnCeoClick(() => {
if (onCeoClick) onCeoClick();
});
const handleClick = (e) => { const handleClick = (e) => {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
@@ -48,7 +52,7 @@ export function useOfficeCanvas(containerRef, onAgentClick) {
containerRef.current.removeChild(canvas); containerRef.current.removeChild(canvas);
} }
}; };
}, [containerRef, onAgentClick]); }, [containerRef, onAgentClick, onCeoClick]);
const updateAgentState = useCallback((agentId, state, detail) => { const updateAgentState = useCallback((agentId, state, detail) => {
rendererRef.current?.updateAgentState(agentId, state, detail); rendererRef.current?.updateAgentState(agentId, state, detail);
@@ -58,5 +62,13 @@ export function useOfficeCanvas(containerRef, onAgentClick) {
rendererRef.current?.moveAgent(agentId, target); rendererRef.current?.moveAgent(agentId, target);
}, []); }, []);
return { updateAgentState, moveAgent }; const setAgentNotification = useCallback((agentId, count) => {
rendererRef.current?.setAgentNotification(agentId, count);
}, []);
const setCeoDocBadge = useCallback((count) => {
rendererRef.current?.setCeoDocBadge(count);
}, []);
return { updateAgentState, moveAgent, setAgentNotification, setCeoDocBadge };
} }

View File

@@ -125,14 +125,30 @@
.bm-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,.25); font-size: 0.85rem; } .bm-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,.25); font-size: 0.85rem; }
/* ── 모바일 ───────────────────────────────────────────────────────────────── */ /* ── 모바일 ───────────────────────────────────────────────────────────────── */
@media (max-width: 640px) { @media (max-width: 768px) {
.bm-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.bm-tabs > * {
flex-shrink: 0;
white-space: nowrap;
}
}
@media (max-width: 480px) {
.bm { padding: 16px 10px 60px; } .bm { padding: 16px 10px 60px; }
.bm-header h1 { font-size: 1.2rem; } .bm-header h1 { font-size: 1.2rem; }
.bm-status { display: none; } .bm-status { display: none; }
.bm-tab { padding: 6px 10px; font-size: 0.8rem; } .bm-tab { padding: 6px 10px; font-size: 0.8rem; }
.bm-dash-cards { grid-template-columns: repeat(2, 1fr); } .bm-dash-cards { grid-template-columns: 1fr; }
.bm-research-form { flex-direction: column; } .bm-research-form { flex-direction: column; }
.bm-analysis-card__scores { gap: 10px; } .bm-analysis-card__scores { gap: 10px; }
.bm-write-actions { flex-direction: column; } .bm-write-actions { flex-direction: column; }
.bm-post-card__actions { flex-wrap: wrap; } .bm-post-card__actions { flex-wrap: wrap; }
} }
@media (prefers-reduced-motion: reduce) {
.bm-spinner { animation: none; }
}

View File

@@ -1,4 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import { import {
getBlogMarketingStatus, getBlogMarketingStatus,
startResearch, startResearch,
@@ -84,10 +86,14 @@ export default function BlogMarketing() {
const [tab, setTab] = useState('dashboard'); const [tab, setTab] = useState('dashboard');
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
useEffect(() => { const loadStatus = useCallback(() => {
getBlogMarketingStatus().then(setStatus).catch(() => {}); return getBlogMarketingStatus().then(setStatus).catch(() => {});
}, []); }, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
const tabs = [ const tabs = [
{ id: 'dashboard', label: 'Dashboard' }, { id: 'dashboard', label: 'Dashboard' },
{ id: 'research', label: 'Research' }, { id: 'research', label: 'Research' },
@@ -96,6 +102,7 @@ export default function BlogMarketing() {
]; ];
return ( return (
<PullToRefresh onRefresh={loadStatus}>
<div className="bm"> <div className="bm">
<header className="bm-header"> <header className="bm-header">
<h1>Blog Lab</h1> <h1>Blog Lab</h1>
@@ -124,10 +131,13 @@ export default function BlogMarketing() {
</nav> </nav>
{tab === 'dashboard' && <DashboardTab />} {tab === 'dashboard' && <DashboardTab />}
{tab === 'research' && <ResearchTab onGenerate={(id) => { setTab('write'); }} />} {tab === 'research' && <ResearchTab />}
{tab === 'write' && <WriteTab />} {tab === 'write' && <WriteTab />}
{tab === 'posts' && <PostsTab />} {tab === 'posts' && <PostsTab />}
<FAB onClick={() => setTab('research')} label="키워드 분석" />
</div> </div>
</PullToRefresh>
); );
} }

View File

@@ -81,7 +81,7 @@
display: none; display: none;
position: fixed; position: fixed;
/* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */ /* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */
bottom: 24px; bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
right: 24px; right: 24px;
top: auto; top: auto;
left: auto; left: auto;
@@ -451,9 +451,8 @@
color: var(--muted); color: var(--muted);
} }
@media (max-width: 900px) { @media (max-width: 768px) {
.blog-header, .blog-header {
.blog-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -469,10 +468,10 @@
.blog-list { .blog-list {
display: none; display: none;
gap: 10px;
} }
.blog-list.is-visible { .blog-list.is-visible {
display: block;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@@ -490,6 +489,13 @@
.blog-list.is-visible .blog-category-filter { .blog-list.is-visible .blog-category-filter {
margin-bottom: 8px; margin-bottom: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
}
.blog-list.is-visible .blog-category-filter > * {
flex-shrink: 0;
} }
.blog-list.is-visible .blog-pagination { .blog-list.is-visible .blog-pagination {
@@ -498,22 +504,18 @@
.blog-article { .blog-article {
width: 100%; width: 100%;
padding: 18px;
} }
}
@media (max-width: 768px) {
.blog-header h1 { .blog-header h1 {
font-size: clamp(24px, 6vw, 32px); font-size: clamp(24px, 6vw, 32px);
} }
.blog-grid { .blog-grid {
grid-template-columns: 1fr;
gap: 18px; gap: 18px;
} }
.blog-list {
gap: 10px;
}
.blog-list__item-btn { .blog-list__item-btn {
padding: 14px; padding: 14px;
} }
@@ -526,10 +528,6 @@
font-size: 12px; font-size: 12px;
} }
.blog-article {
padding: 18px;
}
.blog-article__body h1 { .blog-article__body h1 {
font-size: 24px; font-size: 24px;
} }
@@ -766,4 +764,19 @@
align-self: stretch; align-self: stretch;
text-align: center; text-align: center;
} }
/* 태그/카테고리 필터 가로 스크롤 */
.blog-categories,
.blog-category-list {
display: flex;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
gap: 8px;
}
.blog-categories > *,
.blog-category-list > * {
flex-shrink: 0;
}
} }

View File

@@ -6,6 +6,8 @@ import {
updateBlogPost, updateBlogPost,
deleteBlogPost, deleteBlogPost,
} from '../../api'; } from '../../api';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import './Blog.css'; import './Blog.css';
// ── 마크다운 렌더러 ────────────────────────────────────────────────────────── // ── 마크다운 렌더러 ──────────────────────────────────────────────────────────
@@ -359,9 +361,8 @@ const Blog = () => {
const [editorPost, setEditorPost] = useState(null); // null=닫힘, {}=새글, post=수정 const [editorPost, setEditorPost] = useState(null); // null=닫힘, {}=새글, post=수정
const [isEditorOpen, setIsEditorOpen] = useState(false); const [isEditorOpen, setIsEditorOpen] = useState(false);
// API 글 불러오기 const fetchPosts = useCallback(() => {
useEffect(() => { return getBlogPostsApi()
getBlogPostsApi()
.then((data) => { .then((data) => {
const posts = Array.isArray(data) ? data : (data?.posts ?? []); const posts = Array.isArray(data) ? data : (data?.posts ?? []);
setApiPosts(posts.map((p) => ({ ...p, slug: `api-${p.id}` }))); setApiPosts(posts.map((p) => ({ ...p, slug: `api-${p.id}` })));
@@ -369,6 +370,11 @@ const Blog = () => {
.catch(() => setApiError(true)); .catch(() => setApiError(true));
}, []); }, []);
// API 글 불러오기
useEffect(() => {
fetchPosts();
}, [fetchPosts]);
// 정적 + API 글 병합 (API 글이 앞에 표시) // 정적 + API 글 병합 (API 글이 앞에 표시)
const allPosts = useMemo(() => { const allPosts = useMemo(() => {
const combined = [...apiPosts, ...staticPosts]; const combined = [...apiPosts, ...staticPosts];
@@ -450,6 +456,7 @@ const Blog = () => {
const closeEditor = useCallback(() => { setIsEditorOpen(false); setEditorPost(null); }, []); const closeEditor = useCallback(() => { setIsEditorOpen(false); setEditorPost(null); }, []);
return ( return (
<PullToRefresh onRefresh={fetchPosts}>
<div className="blog"> <div className="blog">
<header className="blog-header"> <header className="blog-header">
<div> <div>
@@ -651,7 +658,10 @@ const Blog = () => {
onClose={closeEditor} onClose={closeEditor}
/> />
)} )}
<FAB onClick={openNewEditor} label="글 쓰기" />
</div> </div>
</PullToRefresh>
); );
}; };

View File

@@ -80,3 +80,14 @@
max-width: 400px; max-width: 400px;
line-height: 1.5; line-height: 1.5;
} }
@media (max-width: 768px) {
.sword-stream {
touch-action: none;
}
.sword-stream__overlay {
padding: 12px;
font-size: 12px;
}
}

View File

@@ -727,7 +727,7 @@
/* ── Responsive ──────────────────────────────────────────────────────── */ /* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 960px) { @media (max-width: 1024px) {
.home-hero { .home-hero {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -803,15 +803,27 @@
.home-profile__name { .home-profile__name {
font-size: 16px; font-size: 16px;
} }
.home-hero__stats {
grid-template-columns: 1fr;
}
.home-grid {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.home-card {
min-height: 80px;
}
.home-posts {
grid-template-columns: 1fr;
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.home-grid { .home-grid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.home-hero__stats {
grid-template-columns: 1fr;
gap: 10px;
}
} }

View File

@@ -1,10 +1,13 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { navLinks } from '../../routes.jsx'; import { navLinks } from '../../routes.jsx';
import { getBlogPosts } from '../../data/blog'; import { getBlogPosts } from '../../data/blog';
import { getTodos } from '../../api'; import { getTodos } from '../../api';
import { getCurrentTheme } from '../../data/heroConfig'; import { getCurrentTheme } from '../../data/heroConfig';
import myPhoto from '../../assets/myPhoto.jpg'; import myPhoto from '../../assets/myPhoto.jpg';
import { useIsMobile } from '../../hooks/useIsMobile';
import SwipeableView from '../../components/SwipeableView';
import PullToRefresh from '../../components/PullToRefresh';
import './Home.css'; import './Home.css';
const TODO_COLUMNS = [ const TODO_COLUMNS = [
@@ -17,22 +20,24 @@ const Home = () => {
const posts = getBlogPosts().slice(0, 3); const posts = getBlogPosts().slice(0, 3);
const highlights = navLinks.filter((link) => link.id !== 'home'); const highlights = navLinks.filter((link) => link.id !== 'home');
const theme = getCurrentTheme(); const theme = getCurrentTheme();
const isMobile = useIsMobile();
const [todosByStatus, setTodosByStatus] = useState({ todo: [], in_progress: [], done: [] }); const [todosByStatus, setTodosByStatus] = useState({ todo: [], in_progress: [], done: [] });
useEffect(() => { const loadTodos = useCallback(async () => {
getTodos() const data = await getTodos();
.then((data) => { if (!Array.isArray(data)) return;
if (!Array.isArray(data)) return; setTodosByStatus({
setTodosByStatus({ todo: data.filter((t) => t.status === 'todo'),
todo: data.filter((t) => t.status === 'todo'), in_progress: data.filter((t) => t.status === 'in_progress'),
in_progress: data.filter((t) => t.status === 'in_progress'), done: data.filter((t) => t.status === 'done'),
done: data.filter((t) => t.status === 'done'), });
});
})
.catch(() => { /* 조용히 실패 */ });
}, []); }, []);
useEffect(() => {
loadTodos().catch(() => { /* 조용히 실패 */ });
}, [loadTodos]);
const totalTasks = todosByStatus.todo.length + todosByStatus.in_progress.length + todosByStatus.done.length; const totalTasks = todosByStatus.todo.length + todosByStatus.in_progress.length + todosByStatus.done.length;
const doneTasks = todosByStatus.done.length; const doneTasks = todosByStatus.done.length;
const inProgress = todosByStatus.in_progress.length; const inProgress = todosByStatus.in_progress.length;
@@ -132,7 +137,79 @@ const Home = () => {
<h2>TODO</h2> <h2>TODO</h2>
<p>계획 · 진행 · 완료 태스크를 한눈에 확인합니다.</p> <p>계획 · 진행 · 완료 태스크를 한눈에 확인합니다.</p>
</div> </div>
<TodoBoard todosByStatus={todosByStatus} /> <PullToRefresh onRefresh={loadTodos}>
{isMobile ? (
<SwipeableView
tabs={[
{
key: 'todo',
label: 'TODO',
content: (
<div className="home-todo-col__body">
{(todosByStatus.todo || []).length === 0 ? (
<p className="home-todo-col__empty">태스크가 없습니다.</p>
) : (todosByStatus.todo || []).map((todo) => (
<div key={todo.id} className="home-todo-card">
<p className="home-todo-card__title">{todo.title}</p>
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
<p className="home-todo-card__date">
{todo.updated_at
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
: ''}
</p>
</div>
))}
</div>
),
},
{
key: 'in_progress',
label: '진행중',
content: (
<div className="home-todo-col__body">
{(todosByStatus.in_progress || []).length === 0 ? (
<p className="home-todo-col__empty">태스크가 없습니다.</p>
) : (todosByStatus.in_progress || []).map((todo) => (
<div key={todo.id} className="home-todo-card">
<p className="home-todo-card__title">{todo.title}</p>
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
<p className="home-todo-card__date">
{todo.updated_at
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
: ''}
</p>
</div>
))}
</div>
),
},
{
key: 'done',
label: '완료',
content: (
<div className="home-todo-col__body">
{(todosByStatus.done || []).length === 0 ? (
<p className="home-todo-col__empty">태스크가 없습니다.</p>
) : (todosByStatus.done || []).map((todo) => (
<div key={todo.id} className="home-todo-card">
<p className="home-todo-card__title">{todo.title}</p>
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
<p className="home-todo-card__date">
{todo.updated_at
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
: ''}
</p>
</div>
))}
</div>
),
},
]}
/>
) : (
<TodoBoard todosByStatus={todosByStatus} />
)}
</PullToRefresh>
</section> </section>
<section className="home-section"> <section className="home-section">

View File

@@ -1,460 +1,56 @@
import React, { useMemo } from 'react'; import { useCallback, useState } from 'react';
import { import BriefingTab from './tabs/BriefingTab';
fmtKST, Ball, NumberRow, copyNumbers, import AnalysisTab from './tabs/AnalysisTab';
buildMetricsFromFrequency, BEST_PICKS_DEFAULT_SHOW, import PurchaseTab from './tabs/PurchaseTab';
} from './lottoUtils'; import { useIsMobile } from '../../hooks/useIsMobile';
import SwipeableView from '../../components/SwipeableView';
/* ── hooks ──────────────────────────────────────────────────────── */ const TABS = [
import useLottoData from './hooks/useLottoData'; { id: 'briefing', label: '🗓 이번 주 브리핑' },
import usePurchases from './hooks/usePurchases'; { id: 'analysis', label: '📊 분석·통계' },
import useManualRecommend from './hooks/useManualRecommend'; { id: 'purchase', label: '💰 구매·성과' },
];
/* ── components ─────────────────────────────────────────────────── */
import MetricBlock from './components/MetricBlock';
import FrequencyChart from './components/FrequencyChart';
import PerformanceBanner from './components/PerformanceBanner';
import CombinedRecommendPanel from './components/CombinedRecommendPanel';
import ReportPanel from './components/ReportPanel';
import PersonalAnalysisPanel from './components/PersonalAnalysisPanel';
import PurchasePanel from './components/PurchasePanel';
/* ── component ──────────────────────────────────────────────────── */
export default function Functions() { export default function Functions() {
const ld = useLottoData(); const [tab, setTab] = useState('briefing');
const pur = usePurchases(); const isMobile = useIsMobile();
const mr = useManualRecommend();
/* ── derived ────────────────────────────────────────────────── */ const tabIndex = TABS.findIndex(t => t.id === tab);
const overallMetrics = useMemo(() => buildMetricsFromFrequency(ld.stats?.frequency), [ld.stats]);
const visibleBestPicks = ld.bestPicksExpanded ? ld.bestPicks : ld.bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
/* ── merged error ───────────────────────────────────────────── */ const handleTabChange = useCallback((index) => {
const error = ld.error || mr.error; setTab(TABS[index].id);
const clearError = () => { ld.setError(''); mr.setError(''); }; }, []);
/* ── render ──────────────────────────────────────────────────── */
return ( return (
<div className="lotto-functions"> <div className="lotto-functions">
{error ? ( {isMobile ? (
<div className="lotto-alert"> <SwipeableView
<div> tabs={TABS.map(t => ({
<p className="lotto-alert__title">오류</p> key: t.id,
<p className="lotto-alert__message">{error}</p> label: t.label,
</div> content: t.id === 'briefing' ? <BriefingTab /> : t.id === 'analysis' ? <AnalysisTab /> : <PurchaseTab />,
<button className="button ghost small" onClick={clearError}>닫기</button> }))}
</div> activeIndex={tabIndex}
) : null} onTabChange={handleTabChange}
/>
{/* ── 신뢰도 배너 ── */} ) : (
<PerformanceBanner perf={ld.perfStats} /> <>
<nav className="lotto-tabs">
{/* ── 종합 추론 번호 추천 ── */} {TABS.map(t => (
<CombinedRecommendPanel <button
combined={ld.combined} key={t.id}
history={ld.combinedHistory} className={tab === t.id ? 'active' : ''}
loading={ld.combinedLoading} onClick={() => setTab(t.id)}
histLoading={ld.combinedHistLoading} >{t.label}</button>
onRun={ld.runCombinedRecommend}
onCopy={copyNumbers}
/>
{/* ── 최신 회차 + 시뮬레이션 추천 ── */}
<div className="lotto-grid">
{/* Latest Draw */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Latest Draw</p>
<h3>최신 회차</h3>
<p className="lotto-panel__sub">최신 회차와 번호를 빠르게 확인할 있습니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.latest ? <span className="lotto-chip">로딩 </span> : null}
<button className="button ghost small" onClick={ld.refreshLatest} disabled={ld.loading.latest}>
새로고침
</button>
</div>
</div>
{ld.latest ? (
<>
<div className="lotto-meta">
<div>
<p className="lotto-meta__title">{ld.latest.drawNo}</p>
<p className="lotto-meta__date">{ld.latest.date}</p>
</div>
<button className="button small" onClick={() => copyNumbers(ld.latest.numbers)}>
번호 복사
</button>
</div>
<NumberRow nums={ld.latest.numbers} />
<p className="lotto-bonus">보너스 <strong>{ld.latest.bonus}</strong></p>
{overallMetrics && (
<MetricBlock title="당첨 통계 (전체 회차)" metrics={overallMetrics} />
)}
</>
) : (
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
)}
</section>
{/* Simulation Picks */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Simulation Picks</p>
<h3>시뮬레이션 추천</h3>
<p className="lotto-panel__sub">
하루 6 몬테카를로 시뮬레이션으로 선별된 최적 번호입니다.
</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.bestPicks ? <span className="lotto-chip">로딩 </span> : null}
{ld.simulating ? <span className="lotto-chip lotto-chip--active">분석 </span> : null}
<button className="button ghost small" onClick={ld.refreshBestPicks}
disabled={ld.loading.bestPicks || ld.simulating}>
새로고침
</button>
<button className="button small" onClick={ld.onSimulate}
disabled={ld.simulating || ld.loading.bestPicks}>
{ld.simulating ? '실행 중...' : '지금 실행'}
</button>
</div>
</div>
{ld.simResult && (
<div className="lotto-sim-result">
<p>완료: {ld.simResult.total_generated?.toLocaleString()} 후보 상위 {ld.simResult.best_n_saved} 저장</p>
<p>최고 점수 {((ld.simResult.best_score ?? 0) * 100).toFixed(1)}% / {((ld.simResult.avg_score ?? 0) * 100).toFixed(1)}%</p>
</div>
)}
{ld.bestPicks.length === 0 ? (
<p className="lotto-empty">
{ld.loading.bestPicks ? '불러오는 중...' : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}
</p>
) : (
<>
<div className="lotto-picks">
{visibleBestPicks.map((pick) => (
<div key={pick.id} className="lotto-pick">
<span className="lotto-pick__rank">#{pick.rank}</span>
<div className="lotto-pick__content">
<NumberRow nums={pick.numbers} />
<div className="lotto-pick__score">
<span className="lotto-pick__score-label">
{((pick.score_total ?? 0) * 100).toFixed(1)}%
</span>
<div className="lotto-pick__bar">
<span style={{ width: `${(pick.score_total ?? 0) * 100}%` }} />
</div>
</div>
</div>
<button className="button ghost small" onClick={() => copyNumbers(pick.numbers)}>
복사
</button>
</div>
))}
</div>
{ld.bestPicks.length > BEST_PICKS_DEFAULT_SHOW && (
<button
className="button ghost small lotto-history-toggle"
onClick={() => ld.setBestPicksExpanded((p) => !p)}
aria-expanded={ld.bestPicksExpanded}
>
{ld.bestPicksExpanded ? '접기' : `모두 보기 (${ld.bestPicks.length}개)`}
<span className={`lotto-history-toggle__icon ${ld.bestPicksExpanded ? 'is-open' : ''}`} aria-hidden></span>
</button>
)}
<p className="lotto-panel__sub">
갱신: {fmtKST(ld.bestPicks[0]?.created_at) || '-'}
{ld.bestPicks[0]?.based_on_draw ? ` · ${ld.bestPicks[0].based_on_draw}회 기준` : ''}
</p>
</>
)}
</section>
</div>
{/* ── 이번 주 공략 리포트 ── */}
<ReportPanel
report={ld.report}
history={ld.reportHistory}
loading={ld.reportLoading}
onRefresh={ld.refreshReport}
onSelectDrw={ld.loadSpecificReport}
/>
{/* ── 통계 분석 ── */}
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Analysis</p>
<h3>통계 분석</h3>
<p className="lotto-panel__sub">빈도, Z-score, 분석으로 번호를 분류합니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.analysis ? <span className="lotto-chip">로딩 </span> : null}
<button className="button ghost small" onClick={ld.refreshAnalysis} disabled={ld.loading.analysis}>
새로고침
</button>
</div>
</div>
{ld.analysis ? (
<div className="lotto-analysis">
<div className="lotto-analysis__row">
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">🔥 번호 <span>출현 빈도 상위 10</span></p>
<div className="lotto-row">
{(ld.analysis.hot_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
</div>
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">🧊 콜드 번호 <span>출현 빈도 하위 10</span></p>
<div className="lotto-row">
{(ld.analysis.cold_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
</div>
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label"> 오버듀 번호 <span>오래 나온 번호 (회차 )</span></p>
<div className="lotto-row">
{(ld.analysis.overdue_numbers ?? []).map((n) => {
const stat = (ld.analysis.number_stats ?? []).find((s) => s.number === n);
return (
<div key={n} className="lotto-overdue">
<Ball n={n} />
<span className="lotto-overdue__gap">{stat?.gap ?? '-'}</span>
</div>
);
})}
</div>
</div>
</div>
<div className="lotto-analysis__stats">
<span>역대 합계 평균 <strong>{ld.analysis.mean_sum}</strong></span>
<span>표준편차 <strong>±{ld.analysis.std_sum}</strong></span>
<span>분석 회차 <strong>{ld.analysis.total_draws?.toLocaleString()}</strong></span>
<span>
홀수 3:짝수 3 확률{' '}
<strong>
{ld.analysis.odd_distribution?.['3'] ? `${ld.analysis.odd_distribution['3']}%` : '-'}
</strong>
</span>
</div>
</div>
) : (
<p className="lotto-empty">
{ld.loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'}
</p>
)}
</section>
{/* ── 전체 번호 분포 ── */}
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Distribution</p>
<h3>전체 회차 번호 분포</h3>
<p className="lotto-panel__sub">1~45 번호가 등장한 횟수를 기준으로 분포를 표시합니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.statsLoading ? <span className="lotto-chip">로딩 </span> : null}
{ld.stats?.total_draws ? (
<span className="lotto-chip">{ld.stats.total_draws}회차</span>
) : null}
<button className="button ghost small" onClick={ld.refreshStats} disabled={ld.statsLoading}>
새로고침
</button>
</div>
</div>
{ld.statsError ? <p className="lotto-empty">{ld.statsError}</p> : null}
{ld.stats ? (
<FrequencyChart stats={ld.stats} />
) : (
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
)}
</section>
{/* ── 내 번호 패턴 ── */}
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
{/* ── 구매 기록 ── */}
<PurchasePanel
records={pur.purchases}
stats={pur.purchaseStats}
loading={pur.purchaseLoading}
formOpen={pur.purchaseFormOpen}
form={pur.purchaseForm}
formSaving={pur.purchaseFormSaving}
formError={pur.purchaseFormError}
editId={pur.purchaseEditId}
onFormOpen={pur.handlePurchaseFormOpen}
onFormClose={pur.handlePurchaseFormClose}
onFormChange={pur.handlePurchaseFormChange}
onFormSubmit={pur.handlePurchaseFormSubmit}
onEditStart={pur.handlePurchaseEditStart}
onDelete={pur.handlePurchaseDelete}
/>
{/* ── 수동 추천 ── */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
<h3>수동 추천</h3>
<p className="lotto-panel__sub">파라미터를 직접 조정해 번호를 추천받을 있습니다.</p>
</div>
<div className="lotto-panel__actions">
{mr.loading.recommend ? <span className="lotto-chip">계산 </span> : null}
</div>
</div>
<div className="lotto-presets">
{mr.presets.map((preset) => (
<button key={preset.name} className="button ghost small"
onClick={() => mr.setParams({
recent_window: preset.recent_window,
recent_weight: preset.recent_weight,
avoid_recent_k: preset.avoid_recent_k,
})}>
{preset.name}
</button>
))}
</div>
<div className="lotto-form">
<label className="lotto-field">
recent_window <span>최근 N회차 가중치 범위</span>
<input type="number" min={20} max={1000} value={mr.params.recent_window}
onChange={(e) => mr.setParams((s) => ({ ...s, recent_window: Number(e.target.value) }))} />
</label>
<label className="lotto-field">
recent_weight <span>최근 회차 가중치</span>
<input type="number" step="0.1" min={0.5} max={10} value={mr.params.recent_weight}
onChange={(e) => mr.setParams((s) => ({ ...s, recent_weight: Number(e.target.value) }))} />
</label>
<label className="lotto-field">
avoid_recent_k <span>최근 K회차 중복 회피</span>
<input type="number" min={0} max={50} value={mr.params.avoid_recent_k}
onChange={(e) => mr.setParams((s) => ({ ...s, avoid_recent_k: Number(e.target.value) }))} />
</label>
</div>
<button className="button primary" onClick={mr.onRecommend} disabled={mr.loading.recommend}>
추천 받기
</button>
{mr.result ? (
<div className="lotto-result">
<div className="lotto-result__meta">
<div>
<p className="lotto-result__id">추천 ID #{mr.result.id}</p>
<p className="lotto-result__based">기준 회차 {mr.result.based_on_latest_draw ?? '-'}</p>
</div>
<button className="button small" onClick={() => copyNumbers(mr.result.numbers)}>
번호 복사
</button>
</div>
{mr.result.numbers && <NumberRow nums={mr.result.numbers} />}
{mr.historyMetrics && (
<div className="lotto-compare">
<MetricBlock title="추천 통계 (히스토리)" metrics={mr.historyMetrics} />
</div>
)}
{Array.isArray(mr.result.items) && mr.result.items.length ? (
<details className="lotto-details">
<summary>추천 후보 보기</summary>
<div className="lotto-batch">
{mr.result.items.map((item, idx) => (
<div key={item.id ?? `candidate-${idx}`} className="lotto-batch__item">
<div className="lotto-batch__meta">
<div>
<p className="lotto-batch__title">후보 #{item.id ?? idx + 1}</p>
<p className="lotto-batch__sub">기준 회차 {item.based_on_draw ?? '-'}</p>
</div>
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
복사
</button>
</div>
<NumberRow nums={item.numbers} />
{item.metrics && <MetricBlock title="후보 통계" metrics={item.metrics} />}
</div>
))}
</div>
</details>
) : null}
{mr.result.explain && (
<details className="lotto-details">
<summary>설명 보기</summary>
<pre>{JSON.stringify(mr.result.explain, null, 2)}</pre>
</details>
)}
</div>
) : (
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
)}
</section>
{/* ── 추천 히스토리 ── */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">History</p>
<h3>추천 히스토리</h3>
<p className="lotto-panel__sub">수동 추천 결과를 모아서 확인할 있습니다.</p>
</div>
<div className="lotto-panel__actions">
<span className="lotto-chip">{mr.history.length}</span>
{mr.history.length > 5 && (
<button className="button ghost small lotto-history-toggle"
onClick={() => mr.setHistoryExpanded((p) => !p)}
aria-expanded={mr.historyExpanded}>
{mr.historyExpanded ? '접기' : '더보기'}
<span className={`lotto-history-toggle__icon ${mr.historyExpanded ? 'is-open' : ''}`} aria-hidden></span>
</button>
)}
<button className="button ghost small" onClick={mr.refreshHistory} disabled={mr.loading.history}>
새로고침
</button>
</div>
</div>
{mr.loading.history ? <p className="lotto-empty">불러오는 ...</p> : null}
{mr.history.length === 0 ? (
<p className="lotto-empty">저장된 히스토리가 없습니다.</p>
) : (
<div className="lotto-history">
{mr.visibleHistory.map((item) => (
<div key={item.id} className="lotto-history__item">
<div className="lotto-history__meta">
<p>#{item.id}</p>
<p>{fmtKST(item.created_at)}</p>
<p>기준 회차 {item.based_on_draw ?? '-'}</p>
</div>
<div className="lotto-history__body">
<NumberRow nums={item.numbers} />
<p className="lotto-history__params">
window={item.params?.recent_window}, weight={item.params?.recent_weight},
avoid_k={item.params?.avoid_recent_k}
</p>
</div>
<div className="lotto-history__actions">
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
복사
</button>
<button className="button danger small" onClick={() => mr.onDelete(item.id)}>
삭제
</button>
</div>
</div>
))} ))}
<span ref={mr.historyEndRef} /> </nav>
<div className="lotto-tab-body">
{tab === 'briefing' && <BriefingTab />}
{tab === 'analysis' && <AnalysisTab />}
{tab === 'purchase' && <PurchaseTab />}
</div> </div>
)} </>
</section> )}
<footer className="lotto-foot">
backend: FastAPI / nginx proxy / DB: sqlite ·{' '}
<a className="lotto-foot__link" href="/lotto-api.md" download>API 스펙 다운로드</a>
</footer>
</div> </div>
); );
} }

View File

@@ -1074,41 +1074,7 @@
/* ── 반응형 ─────────────────────────────────────────────────────────────── */ /* ── 반응형 ─────────────────────────────────────────────────────────────── */
@media (max-width: 900px) { @media (max-width: 480px) {
.lotto-header {
grid-template-columns: 1fr;
}
.lotto-history__item {
grid-template-columns: 1fr;
}
.lotto-analysis__row {
grid-template-columns: 1fr;
gap: 16px;
}
.lotto-pick {
grid-template-columns: 24px minmax(0, 1fr) auto;
gap: 8px;
}
.lotto-report-top {
grid-template-columns: 1fr;
}
.lotto-purchase-list__head,
.lotto-purchase-row {
grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px;
}
.lotto-purchase-list__head span:nth-child(4),
.lotto-purchase-row span:nth-child(4) {
display: none;
}
}
@media (max-width: 640px) {
.lotto-purchase-stats { .lotto-purchase-stats {
flex-direction: column; flex-direction: column;
} }
@@ -1157,6 +1123,34 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.lotto-header {
grid-template-columns: 1fr;
}
.lotto-analysis__row {
grid-template-columns: 1fr;
gap: 16px;
}
.lotto-pick {
grid-template-columns: 24px minmax(0, 1fr) auto;
gap: 8px;
}
.lotto-report-top {
grid-template-columns: 1fr;
}
.lotto-purchase-list__head,
.lotto-purchase-row {
grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px;
}
.lotto-purchase-list__head span:nth-child(4),
.lotto-purchase-row span:nth-child(4) {
display: none;
}
.lotto-header h1 { .lotto-header h1 {
font-size: clamp(24px, 6vw, 32px); font-size: clamp(24px, 6vw, 32px);
} }
@@ -1181,9 +1175,9 @@
} }
.lotto-ball { .lotto-ball {
width: 36px; width: 32px;
height: 36px; height: 32px;
font-size: 14px; font-size: 13px;
} }
.lotto-meta__title { .lotto-meta__title {
@@ -1191,6 +1185,7 @@
} }
.lotto-history__item { .lotto-history__item {
grid-template-columns: 1fr;
padding: 14px; padding: 14px;
gap: 12px; gap: 12px;
} }
@@ -1459,7 +1454,7 @@
flex-shrink: 0; flex-shrink: 0;
} }
@media (max-width: 640px) { @media (max-width: 480px) {
.lotto-combined__method { .lotto-combined__method {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -1475,3 +1470,59 @@
gap: 10px; gap: 10px;
} }
} }
/* ── Briefing UI ──────────────────────────────────────────────────────────── */
.briefing-header { padding: 16px; border-radius: 12px; background: rgba(129,140,248,0.08); margin-bottom: 16px; }
.briefing-header-row { display: flex; justify-content: space-between; align-items: center; }
.briefing-meta { display: flex; gap: 12px; color: #94a3b8; font-size: 0.85rem; margin-top: 4px; flex-wrap: wrap; }
.briefing-confidence strong { color: #e2e8f0; }
.briefing-tokens { font-family: monospace; }
.briefing-confidence-bar { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; margin-top: 8px; overflow: hidden; }
.briefing-confidence-bar > div { height: 100%; background: linear-gradient(90deg, #818cf8, #34d399); transition: width .3s; }
.briefing-summary { padding: 12px 16px; background: rgba(0,0,0,0.2); border-radius: 10px; margin-bottom: 16px; }
.briefing-summary h3 { margin: 0 0 8px; }
.briefing-3lines { margin: 0; padding-left: 20px; }
.briefing-hotcold { color: #fbbf24; margin-top: 8px; }
.briefing-warning { color: #f87171; margin-top: 8px; }
.pick-card { padding: 12px; border-radius: 10px; background: rgba(255,255,255,0.04); border-left: 3px solid #64748b; margin-bottom: 8px; }
.pick-card--안정 { border-left-color: #34d399; }
.pick-card--균형 { border-left-color: #fbbf24; }
.pick-card--공격 { border-left-color: #f87171; }
.pick-card-header { display: flex; justify-content: space-between; font-size: 0.85rem; color: #94a3b8; margin-bottom: 6px; }
.pick-card-balls { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
.ball { width: 32px; height: 32px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; color: #fff; }
.ball--1 { background: #fbbf24; } .ball--2 { background: #60a5fa; } .ball--3 { background: #f87171; }
.ball--4 { background: #94a3b8; } .ball--5 { background: #34d399; }
.pick-card-reason { margin: 0; font-size: 0.85rem; color: #cbd5e1; }
.briefing-empty { text-align: center; padding: 40px 20px; color: #94a3b8; }
.briefing-empty button { margin-top: 12px; padding: 8px 20px; }
.briefing-empty-hint { font-size: 0.85rem; }
.briefing-error { color: #f87171; margin-top: 8px; }
.curator-usage-footer { display: flex; gap: 12px; padding: 10px 14px; background: rgba(0,0,0,0.25); border-radius: 8px; font-size: 0.8rem; color: #94a3b8; margin-top: 24px; flex-wrap: wrap; font-family: monospace; }
@media (max-width: 768px) {
.briefing-meta { font-size: 0.75rem; }
.briefing-tokens { width: 100%; }
.pick-card-balls { justify-content: center; }
}
/* ── Tab navigation ───────────────────────────────────────────────────────── */
.lotto-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(255,255,255,0.1); }
.lotto-tabs button { padding: 8px 16px; background: transparent; border: none; color: #94a3b8; cursor: pointer; border-bottom: 2px solid transparent; }
.lotto-tabs button.active { color: #e2e8f0; border-bottom-color: #818cf8; }
.lotto-tab-body { padding-top: 8px; display: grid; gap: 24px; }
@media (max-width: 768px) {
.lotto-tabs { overflow-x: auto; }
.lotto-tabs button { white-space: nowrap; }
/* 구매 이력 테이블 가로 스크롤 */
.purchase-list {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.lotto-ball {
width: 32px;
height: 32px;
font-size: 13px;
}
}

View File

@@ -0,0 +1,12 @@
export default function BriefingEmpty({ regenerating, onRegenerate, error }) {
return (
<div className="briefing-empty">
<p>아직 이번 브리핑이 없습니다.</p>
<p className="briefing-empty-hint">매주 월요일 07:00 자동 생성됩니다.</p>
<button onClick={onRegenerate} disabled={regenerating}>
{regenerating ? '⏳ 생성 중...' : '지금 생성'}
</button>
{error && <p className="briefing-error"> {error}</p>}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { estimateCost, fmtUsd, fmtTokens } from './pricing';
export default function BriefingHeader({ briefing, regenerating, onRegenerate }) {
const cost = estimateCost(briefing);
const genDate = new Date(briefing.generated_at).toLocaleString('ko-KR');
return (
<div className="briefing-header">
<div className="briefing-header-row">
<h2>🗓 #{briefing.draw_no} 브리핑</h2>
<button onClick={onRegenerate} disabled={regenerating}>
{regenerating ? '⏳ 생성 중...' : '🔄 다시 생성'}
</button>
</div>
<div className="briefing-meta">
<span>{genDate}</span>
<span className="briefing-confidence">
신뢰도 <strong>{briefing.confidence}</strong>/100
</span>
<span className="briefing-tokens">
{fmtTokens(briefing.tokens_input)} in · {fmtTokens(briefing.tokens_output)} out · {fmtUsd(cost)}
</span>
</div>
<div className="briefing-confidence-bar">
<div style={{ width: `${briefing.confidence}%` }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function BriefingSummary({ narrative }) {
return (
<div className="briefing-summary">
<h3>{narrative.headline}</h3>
<ul className="briefing-3lines">
{narrative.summary_3lines.map((line, i) => <li key={i}>{line}</li>)}
</ul>
{narrative.hot_cold_comment && (
<p className="briefing-hotcold">🔥 {narrative.hot_cold_comment}</p>
)}
{narrative.warnings && (
<p className="briefing-warning"> {narrative.warnings}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,17 @@
import useCuratorUsage from '../../hooks/useCuratorUsage';
import { estimateCost, fmtUsd, fmtTokens } from './pricing';
export default function CuratorUsageFooter() {
const { usage } = useCuratorUsage(30);
if (!usage) return null;
const cost = estimateCost(usage);
return (
<div className="curator-usage-footer">
<span>최근 30 큐레이터:</span>
<span>{usage.calls} 호출</span>
<span>{fmtTokens(usage.tokens_input + usage.tokens_output)} tokens</span>
<span>{fmtUsd(cost)}</span>
<span>캐시 {(usage.cache_hit_rate * 100).toFixed(0)}%</span>
</div>
);
}

View File

@@ -0,0 +1,18 @@
const RISK_BADGE = { '안정': '🟢', '균형': '🟡', '공격': '🔴' };
export default function PickSetCard({ pick, index }) {
return (
<div className={`pick-card pick-card--${pick.risk_tag}`}>
<div className="pick-card-header">
<span className="pick-card-index">Set {index + 1}</span>
<span className="pick-card-risk">{RISK_BADGE[pick.risk_tag] || '⚪'} {pick.risk_tag}</span>
</div>
<div className="pick-card-balls">
{pick.numbers.map(n => (
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
))}
</div>
<p className="pick-card-reason">{pick.reason}</p>
</div>
);
}

View File

@@ -0,0 +1,23 @@
const IN_PER_M = 3.00;
const OUT_PER_M = 15.00;
const CACHE_READ_PER_M = 0.30;
const CACHE_WRITE_PER_M = 3.75;
export function estimateCost({ tokens_input = 0, tokens_output = 0, cache_read = 0, cache_write = 0 }) {
const usd =
(tokens_input / 1_000_000) * IN_PER_M +
(tokens_output / 1_000_000) * OUT_PER_M +
(cache_read / 1_000_000) * CACHE_READ_PER_M +
(cache_write / 1_000_000) * CACHE_WRITE_PER_M;
return usd;
}
export function fmtUsd(usd) {
if (usd < 0.01) return `$${usd.toFixed(4)}`;
return `$${usd.toFixed(3)}`;
}
export function fmtTokens(n) {
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n);
}

View File

@@ -0,0 +1,56 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getLatestBriefing, triggerLottoCurate } from '../../../api';
export default function useBriefing() {
const [briefing, setBriefing] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [regenerating, setRegenerating] = useState(false);
const pollingRef = useRef(null);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await getLatestBriefing();
setBriefing(data);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const regenerate = useCallback(async () => {
setRegenerating(true); setError('');
try {
const prevGen = briefing?.generated_at;
await triggerLottoCurate();
let attempts = 0;
pollingRef.current = setInterval(async () => {
attempts += 1;
try {
const data = await getLatestBriefing();
if (data && data.generated_at !== prevGen) {
setBriefing(data);
setRegenerating(false);
clearInterval(pollingRef.current);
}
} catch {}
if (attempts >= 40) {
clearInterval(pollingRef.current);
setRegenerating(false);
setError('재생성 타임아웃 (2분)');
}
}, 3000);
} catch (e) {
setError(e.message);
setRegenerating(false);
}
}, [briefing?.generated_at]);
useEffect(() => () => { if (pollingRef.current) clearInterval(pollingRef.current); }, []);
return { briefing, loading, error, regenerating, reload: load, regenerate };
}

View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
import { getCuratorUsage } from '../../../api';
export default function useCuratorUsage(days = 30) {
const [usage, setUsage] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
let alive = true;
getCuratorUsage(days)
.then(d => { if (alive) setUsage(d); })
.catch(e => { if (alive) setError(e.message); });
return () => { alive = false; };
}, [days]);
return { usage, error };
}

View File

@@ -0,0 +1,428 @@
import React, { useMemo } from 'react';
import {
fmtKST, Ball, NumberRow, copyNumbers,
buildMetricsFromFrequency, BEST_PICKS_DEFAULT_SHOW,
} from '../lottoUtils';
import useLottoData from '../hooks/useLottoData';
import useManualRecommend from '../hooks/useManualRecommend';
import MetricBlock from '../components/MetricBlock';
import FrequencyChart from '../components/FrequencyChart';
import PerformanceBanner from '../components/PerformanceBanner';
import CombinedRecommendPanel from '../components/CombinedRecommendPanel';
import ReportPanel from '../components/ReportPanel';
import PersonalAnalysisPanel from '../components/PersonalAnalysisPanel';
export default function AnalysisTab() {
const ld = useLottoData();
const mr = useManualRecommend();
const overallMetrics = useMemo(() => buildMetricsFromFrequency(ld.stats?.frequency), [ld.stats]);
const visibleBestPicks = ld.bestPicksExpanded ? ld.bestPicks : ld.bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
const error = ld.error || mr.error;
const clearError = () => { ld.setError(''); mr.setError(''); };
return (
<>
{error ? (
<div className="lotto-alert">
<div>
<p className="lotto-alert__title">오류</p>
<p className="lotto-alert__message">{error}</p>
</div>
<button className="button ghost small" onClick={clearError}>닫기</button>
</div>
) : null}
{/* 신뢰도 배너 */}
<PerformanceBanner perf={ld.perfStats} />
{/* 종합 추론 번호 추천 */}
<CombinedRecommendPanel
combined={ld.combined}
history={ld.combinedHistory}
loading={ld.combinedLoading}
histLoading={ld.combinedHistLoading}
onRun={ld.runCombinedRecommend}
onCopy={copyNumbers}
/>
{/* 최신 회차 + 시뮬레이션 추천 */}
<div className="lotto-grid">
{/* Latest Draw */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Latest Draw</p>
<h3>최신 회차</h3>
<p className="lotto-panel__sub">최신 회차와 번호를 빠르게 확인할 있습니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.latest ? <span className="lotto-chip">로딩 </span> : null}
<button className="button ghost small" onClick={ld.refreshLatest} disabled={ld.loading.latest}>
새로고침
</button>
</div>
</div>
{ld.latest ? (
<>
<div className="lotto-meta">
<div>
<p className="lotto-meta__title">{ld.latest.drawNo}</p>
<p className="lotto-meta__date">{ld.latest.date}</p>
</div>
<button className="button small" onClick={() => copyNumbers(ld.latest.numbers)}>
번호 복사
</button>
</div>
<NumberRow nums={ld.latest.numbers} />
<p className="lotto-bonus">보너스 <strong>{ld.latest.bonus}</strong></p>
{overallMetrics && (
<MetricBlock title="당첨 통계 (전체 회차)" metrics={overallMetrics} />
)}
</>
) : (
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
)}
</section>
{/* Simulation Picks */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Simulation Picks</p>
<h3>시뮬레이션 추천</h3>
<p className="lotto-panel__sub">
하루 6 몬테카를로 시뮬레이션으로 선별된 최적 번호입니다.
</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.bestPicks ? <span className="lotto-chip">로딩 </span> : null}
{ld.simulating ? <span className="lotto-chip lotto-chip--active">분석 </span> : null}
<button className="button ghost small" onClick={ld.refreshBestPicks}
disabled={ld.loading.bestPicks || ld.simulating}>
새로고침
</button>
<button className="button small" onClick={ld.onSimulate}
disabled={ld.simulating || ld.loading.bestPicks}>
{ld.simulating ? '실행 중...' : '지금 실행'}
</button>
</div>
</div>
{ld.simResult && (
<div className="lotto-sim-result">
<p>완료: {ld.simResult.total_generated?.toLocaleString()} 후보 상위 {ld.simResult.best_n_saved} 저장</p>
<p>최고 점수 {((ld.simResult.best_score ?? 0) * 100).toFixed(1)}% / {((ld.simResult.avg_score ?? 0) * 100).toFixed(1)}%</p>
</div>
)}
{ld.bestPicks.length === 0 ? (
<p className="lotto-empty">
{ld.loading.bestPicks ? '불러오는 중...' : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}
</p>
) : (
<>
<div className="lotto-picks">
{visibleBestPicks.map((pick) => (
<div key={pick.id} className="lotto-pick">
<span className="lotto-pick__rank">#{pick.rank}</span>
<div className="lotto-pick__content">
<NumberRow nums={pick.numbers} />
<div className="lotto-pick__score">
<span className="lotto-pick__score-label">
{((pick.score_total ?? 0) * 100).toFixed(1)}%
</span>
<div className="lotto-pick__bar">
<span style={{ width: `${(pick.score_total ?? 0) * 100}%` }} />
</div>
</div>
</div>
<button className="button ghost small" onClick={() => copyNumbers(pick.numbers)}>
복사
</button>
</div>
))}
</div>
{ld.bestPicks.length > BEST_PICKS_DEFAULT_SHOW && (
<button
className="button ghost small lotto-history-toggle"
onClick={() => ld.setBestPicksExpanded((p) => !p)}
aria-expanded={ld.bestPicksExpanded}
>
{ld.bestPicksExpanded ? '접기' : `모두 보기 (${ld.bestPicks.length}개)`}
<span className={`lotto-history-toggle__icon ${ld.bestPicksExpanded ? 'is-open' : ''}`} aria-hidden></span>
</button>
)}
<p className="lotto-panel__sub">
갱신: {fmtKST(ld.bestPicks[0]?.created_at) || '-'}
{ld.bestPicks[0]?.based_on_draw ? ` · ${ld.bestPicks[0].based_on_draw}회 기준` : ''}
</p>
</>
)}
</section>
</div>
{/* 이번 주 공략 리포트 */}
<ReportPanel
report={ld.report}
history={ld.reportHistory}
loading={ld.reportLoading}
onRefresh={ld.refreshReport}
onSelectDrw={ld.loadSpecificReport}
/>
{/* 통계 분석 */}
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Analysis</p>
<h3>통계 분석</h3>
<p className="lotto-panel__sub">빈도, Z-score, 분석으로 번호를 분류합니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.analysis ? <span className="lotto-chip">로딩 </span> : null}
<button className="button ghost small" onClick={ld.refreshAnalysis} disabled={ld.loading.analysis}>
새로고침
</button>
</div>
</div>
{ld.analysis ? (
<div className="lotto-analysis">
<div className="lotto-analysis__row">
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">🔥 번호 <span>출현 빈도 상위 10</span></p>
<div className="lotto-row">
{(ld.analysis.hot_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
</div>
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">🧊 콜드 번호 <span>출현 빈도 하위 10</span></p>
<div className="lotto-row">
{(ld.analysis.cold_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
</div>
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label"> 오버듀 번호 <span>오래 나온 번호 (회차 )</span></p>
<div className="lotto-row">
{(ld.analysis.overdue_numbers ?? []).map((n) => {
const stat = (ld.analysis.number_stats ?? []).find((s) => s.number === n);
return (
<div key={n} className="lotto-overdue">
<Ball n={n} />
<span className="lotto-overdue__gap">{stat?.gap ?? '-'}</span>
</div>
);
})}
</div>
</div>
</div>
<div className="lotto-analysis__stats">
<span>역대 합계 평균 <strong>{ld.analysis.mean_sum}</strong></span>
<span>표준편차 <strong>±{ld.analysis.std_sum}</strong></span>
<span>분석 회차 <strong>{ld.analysis.total_draws?.toLocaleString()}</strong></span>
<span>
홀수 3:짝수 3 확률{' '}
<strong>
{ld.analysis.odd_distribution?.['3'] ? `${ld.analysis.odd_distribution['3']}%` : '-'}
</strong>
</span>
</div>
</div>
) : (
<p className="lotto-empty">
{ld.loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'}
</p>
)}
</section>
{/* 전체 번호 분포 */}
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Distribution</p>
<h3>전체 회차 번호 분포</h3>
<p className="lotto-panel__sub">1~45 번호가 등장한 횟수를 기준으로 분포를 표시합니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.statsLoading ? <span className="lotto-chip">로딩 </span> : null}
{ld.stats?.total_draws ? (
<span className="lotto-chip">{ld.stats.total_draws}회차</span>
) : null}
<button className="button ghost small" onClick={ld.refreshStats} disabled={ld.statsLoading}>
새로고침
</button>
</div>
</div>
{ld.statsError ? <p className="lotto-empty">{ld.statsError}</p> : null}
{ld.stats ? (
<FrequencyChart stats={ld.stats} />
) : (
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
)}
</section>
{/* 내 번호 패턴 */}
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
{/* 수동 추천 */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
<h3>수동 추천</h3>
<p className="lotto-panel__sub">파라미터를 직접 조정해 번호를 추천받을 있습니다.</p>
</div>
<div className="lotto-panel__actions">
{mr.loading.recommend ? <span className="lotto-chip">계산 </span> : null}
</div>
</div>
<div className="lotto-presets">
{mr.presets.map((preset) => (
<button key={preset.name} className="button ghost small"
onClick={() => mr.setParams({
recent_window: preset.recent_window,
recent_weight: preset.recent_weight,
avoid_recent_k: preset.avoid_recent_k,
})}>
{preset.name}
</button>
))}
</div>
<div className="lotto-form">
<label className="lotto-field">
recent_window <span>최근 N회차 가중치 범위</span>
<input type="number" min={20} max={1000} value={mr.params.recent_window}
onChange={(e) => mr.setParams((s) => ({ ...s, recent_window: Number(e.target.value) }))} />
</label>
<label className="lotto-field">
recent_weight <span>최근 회차 가중치</span>
<input type="number" step="0.1" min={0.5} max={10} value={mr.params.recent_weight}
onChange={(e) => mr.setParams((s) => ({ ...s, recent_weight: Number(e.target.value) }))} />
</label>
<label className="lotto-field">
avoid_recent_k <span>최근 K회차 중복 회피</span>
<input type="number" min={0} max={50} value={mr.params.avoid_recent_k}
onChange={(e) => mr.setParams((s) => ({ ...s, avoid_recent_k: Number(e.target.value) }))} />
</label>
</div>
<button className="button primary" onClick={mr.onRecommend} disabled={mr.loading.recommend}>
추천 받기
</button>
{mr.result ? (
<div className="lotto-result">
<div className="lotto-result__meta">
<div>
<p className="lotto-result__id">추천 ID #{mr.result.id}</p>
<p className="lotto-result__based">기준 회차 {mr.result.based_on_latest_draw ?? '-'}</p>
</div>
<button className="button small" onClick={() => copyNumbers(mr.result.numbers)}>
번호 복사
</button>
</div>
{mr.result.numbers && <NumberRow nums={mr.result.numbers} />}
{mr.historyMetrics && (
<div className="lotto-compare">
<MetricBlock title="추천 통계 (히스토리)" metrics={mr.historyMetrics} />
</div>
)}
{Array.isArray(mr.result.items) && mr.result.items.length ? (
<details className="lotto-details">
<summary>추천 후보 보기</summary>
<div className="lotto-batch">
{mr.result.items.map((item, idx) => (
<div key={item.id ?? `candidate-${idx}`} className="lotto-batch__item">
<div className="lotto-batch__meta">
<div>
<p className="lotto-batch__title">후보 #{item.id ?? idx + 1}</p>
<p className="lotto-batch__sub">기준 회차 {item.based_on_draw ?? '-'}</p>
</div>
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
복사
</button>
</div>
<NumberRow nums={item.numbers} />
{item.metrics && <MetricBlock title="후보 통계" metrics={item.metrics} />}
</div>
))}
</div>
</details>
) : null}
{mr.result.explain && (
<details className="lotto-details">
<summary>설명 보기</summary>
<pre>{JSON.stringify(mr.result.explain, null, 2)}</pre>
</details>
)}
</div>
) : (
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
)}
</section>
{/* 추천 히스토리 */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">History</p>
<h3>추천 히스토리</h3>
<p className="lotto-panel__sub">수동 추천 결과를 모아서 확인할 있습니다.</p>
</div>
<div className="lotto-panel__actions">
<span className="lotto-chip">{mr.history.length}</span>
{mr.history.length > 5 && (
<button className="button ghost small lotto-history-toggle"
onClick={() => mr.setHistoryExpanded((p) => !p)}
aria-expanded={mr.historyExpanded}>
{mr.historyExpanded ? '접기' : '더보기'}
<span className={`lotto-history-toggle__icon ${mr.historyExpanded ? 'is-open' : ''}`} aria-hidden></span>
</button>
)}
<button className="button ghost small" onClick={mr.refreshHistory} disabled={mr.loading.history}>
새로고침
</button>
</div>
</div>
{mr.loading.history ? <p className="lotto-empty">불러오는 ...</p> : null}
{mr.history.length === 0 ? (
<p className="lotto-empty">저장된 히스토리가 없습니다.</p>
) : (
<div className="lotto-history">
{mr.visibleHistory.map((item) => (
<div key={item.id} className="lotto-history__item">
<div className="lotto-history__meta">
<p>#{item.id}</p>
<p>{fmtKST(item.created_at)}</p>
<p>기준 회차 {item.based_on_draw ?? '-'}</p>
</div>
<div className="lotto-history__body">
<NumberRow nums={item.numbers} />
<p className="lotto-history__params">
window={item.params?.recent_window}, weight={item.params?.recent_weight},
avoid_k={item.params?.avoid_recent_k}
</p>
</div>
<div className="lotto-history__actions">
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
복사
</button>
<button className="button danger small" onClick={() => mr.onDelete(item.id)}>
삭제
</button>
</div>
</div>
))}
<span ref={mr.historyEndRef} />
</div>
)}
</section>
</>
);
}

View File

@@ -0,0 +1,25 @@
import useBriefing from '../hooks/useBriefing';
import BriefingHeader from '../components/briefing/BriefingHeader';
import BriefingSummary from '../components/briefing/BriefingSummary';
import PickSetCard from '../components/briefing/PickSetCard';
import BriefingEmpty from '../components/briefing/BriefingEmpty';
import CuratorUsageFooter from '../components/briefing/CuratorUsageFooter';
export default function BriefingTab() {
const { briefing, loading, error, regenerating, regenerate } = useBriefing();
if (loading) return <div className="briefing-empty"><p>로딩 ...</p></div>;
if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />;
return (
<div className="briefing-tab">
<BriefingHeader briefing={briefing} regenerating={regenerating} onRegenerate={regenerate} />
<BriefingSummary narrative={briefing.narrative} />
<div className="briefing-picks">
<h3>이번 5세트</h3>
{briefing.picks.map((p, i) => <PickSetCard key={i} pick={p} index={i} />)}
</div>
<CuratorUsageFooter />
</div>
);
}

View File

@@ -0,0 +1,25 @@
import usePurchases from '../hooks/usePurchases';
import PurchasePanel from '../components/PurchasePanel';
export default function PurchaseTab() {
const pur = usePurchases();
return (
<PurchasePanel
records={pur.purchases}
stats={pur.purchaseStats}
loading={pur.purchaseLoading}
formOpen={pur.purchaseFormOpen}
form={pur.purchaseForm}
formSaving={pur.purchaseFormSaving}
formError={pur.purchaseFormError}
editId={pur.purchaseEditId}
onFormOpen={pur.handlePurchaseFormOpen}
onFormClose={pur.handlePurchaseFormClose}
onFormChange={pur.handlePurchaseFormChange}
onFormSubmit={pur.handlePurchaseFormSubmit}
onEditStart={pur.handlePurchaseEditStart}
onDelete={pur.handlePurchaseDelete}
/>
);
}

View File

@@ -317,7 +317,7 @@
align-items: start; align-items: start;
} }
@media (max-width: 960px) { @media (max-width: 1024px) {
.ms-layout { .ms-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -487,7 +487,7 @@
gap: 8px; gap: 8px;
} }
@media (max-width: 640px) { @media (max-width: 480px) {
.ms-genre-grid { .ms-genre-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@@ -1696,7 +1696,19 @@
/* ═══════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════
MOBILE MOBILE
═══════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════ */
@media (max-width: 640px) { @media (max-width: 768px) {
.ms-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.ms-tabs > * {
flex-shrink: 0;
white-space: nowrap;
}
}
@media (max-width: 480px) {
.ms-header__title { .ms-header__title {
font-size: clamp(44px, 14vw, 70px); font-size: clamp(44px, 14vw, 70px);
} }

View File

@@ -16,6 +16,8 @@ import {
generateStyleBoost, generateStyleBoost,
generateVideo, generateVideo,
} from '../../api'; } from '../../api';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import './MusicStudio.css'; import './MusicStudio.css';
import AudioPlayer from './components/AudioPlayer'; import AudioPlayer from './components/AudioPlayer';
import { fmtTime } from './components/AudioPlayer'; import { fmtTime } from './components/AudioPlayer';
@@ -1123,6 +1125,7 @@ export default function MusicStudio() {
{/* ═══ LIBRARY TAB ═══ */} {/* ═══ LIBRARY TAB ═══ */}
{tab === 'library' && ( {tab === 'library' && (
<PullToRefresh onRefresh={loadLibrary}>
<Library <Library
tracks={library} tracks={library}
loading={libLoading} loading={libLoading}
@@ -1137,6 +1140,7 @@ export default function MusicStudio() {
onVideoGenerate={handleVideoGenerate} onVideoGenerate={handleVideoGenerate}
isGenerating={isGenerating} isGenerating={isGenerating}
/> />
</PullToRefresh>
)} )}
{/* ═══ LYRICS TAB ═══ */} {/* ═══ LYRICS TAB ═══ */}
@@ -1760,6 +1764,10 @@ export default function MusicStudio() {
accentColor={accentColor} accentColor={accentColor}
/> />
)} )}
{tab === 'library' && (
<FAB onClick={() => setTab('create')} label="음악 생성" />
)}
</div> </div>
); );
} }

View File

@@ -952,13 +952,13 @@
/* ── 반응형 ───────────────────────────────────────────────────────────── */ /* ── 반응형 ───────────────────────────────────────────────────────────── */
@media (max-width: 1100px) { @media (max-width: 1024px) {
.re-list-layout { .re-list-layout {
grid-template-columns: 1fr 340px; grid-template-columns: 1fr 340px;
} }
} }
@media (max-width: 900px) { @media (max-width: 768px) {
.re-list-layout { .re-list-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -967,9 +967,6 @@
position: static; position: static;
max-height: none; max-height: none;
} }
}
@media (max-width: 768px) {
.re-header { .re-header {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -2943,3 +2943,41 @@
justify-content: flex-end; justify-content: flex-end;
} }
} }
@media (max-width: 768px) {
/* 필터 가로 스크롤 */
.stock-filter-row {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
}
.stock-filter-row > * {
flex-shrink: 0;
}
/* 지표 카드 가로 스크롤 캐러셀 */
.stock-snapshot {
display: flex;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
gap: 12px;
padding-bottom: 8px;
scroll-snap-type: x mandatory;
}
.stock-snapshot > * {
flex: 0 0 200px;
scroll-snap-align: start;
}
/* 뉴스 1컬럼 */
.stock-news-grid {
grid-template-columns: 1fr;
}
/* 매크로 지표 1컬럼 */
.stock-macro-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,8 +1,11 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { getStockIndices, getStockNews, getFearAndGreed, getVix, getTreasury10Y, getWTI, getBrent } from '../../api'; import { getStockIndices, getStockNews, getFearAndGreed, getVix, getTreasury10Y, getWTI, getBrent } from '../../api';
import Loading from '../../components/Loading'; import Loading from '../../components/Loading';
import FearGreedGauge from '../../components/FearGreedGauge'; import FearGreedGauge from '../../components/FearGreedGauge';
import { useIsMobile } from '../../hooks/useIsMobile';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import './Stock.css'; import './Stock.css';
const formatDate = (value) => { const formatDate = (value) => {
@@ -109,6 +112,7 @@ const getVixLevel = (score) => {
}; };
const Stock = () => { const Stock = () => {
const isMobile = useIsMobile();
const [newsDomestic, setNewsDomestic] = useState([]); const [newsDomestic, setNewsDomestic] = useState([]);
const [newsOverseas, setNewsOverseas] = useState([]); const [newsOverseas, setNewsOverseas] = useState([]);
const [newsCategory, setNewsCategory] = useState('domestic'); const [newsCategory, setNewsCategory] = useState('domestic');
@@ -146,6 +150,10 @@ const Stock = () => {
} }
}; };
const handleRefresh = useCallback(async () => {
await loadNews();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const loadIndices = async () => { const loadIndices = async () => {
setIndicesLoading(true); setIndicesLoading(true);
setIndicesError(''); setIndicesError('');
@@ -217,6 +225,7 @@ const Stock = () => {
newsCategory === 'domestic' ? newsDomestic : newsOverseas; newsCategory === 'domestic' ? newsDomestic : newsOverseas;
return ( return (
<PullToRefresh onRefresh={handleRefresh}>
<div className="stock"> <div className="stock">
<header className="stock-header"> <header className="stock-header">
<div> <div>
@@ -559,6 +568,13 @@ const Stock = () => {
)} )}
</section> </section>
</div> </div>
<FAB onClick={loadNews} label="뉴스 새로고침" icon={
<svg className="fab__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
} />
</PullToRefresh>
); );
}; };

View File

@@ -1,6 +1,8 @@
import React, { useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import './Stock.css'; import './Stock.css';
import { useIsMobile } from '../../hooks/useIsMobile';
import SwipeableView from '../../components/SwipeableView';
import { import {
formatNumber, formatPercent, formatNumber, formatPercent,
toNumeric, profitColorClass, toNumeric, profitColorClass,
@@ -28,6 +30,12 @@ import SellHistoryDrawer from './components/SellHistoryDrawer';
const StockTrade = () => { const StockTrade = () => {
const [activeTab, setActiveTab] = React.useState(TAB_REPORT); const [activeTab, setActiveTab] = React.useState(TAB_REPORT);
const isMobile = useIsMobile();
const TAB_ORDER = [TAB_PORTFOLIO, TAB_AI, TAB_REPORT, TAB_ADVISOR];
const tabLabels = ['포트폴리오', 'AI 트레이드', '리포트', '어드바이저'];
const tabIndex = TAB_ORDER.indexOf(activeTab);
const handleTabChange = useCallback((idx) => setActiveTab(TAB_ORDER[idx]), []); // eslint-disable-line react-hooks/exhaustive-deps
/* ── hooks ────────────────────────────────────────────────────── */ /* ── hooks ────────────────────────────────────────────────────── */
const pf = usePortfolio(); const pf = usePortfolio();
@@ -166,35 +174,54 @@ const StockTrade = () => {
</div> </div>
</header> </header>
{/* Tab bar */} {/* Tab bar + Tab content */}
<div className="stock-main-tabs"> {isMobile ? (
{[ <SwipeableView
{ id: TAB_PORTFOLIO, icon: '💼', label: '쟁승토리 계좌', badge: pf.portfolioHoldings.length || null }, tabs={TAB_ORDER.map((tabId, i) => ({
{ id: TAB_AI, icon: '🤖', label: 'AI 투자', sub: '모의투자' }, key: tabId,
{ id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' }, label: tabLabels[i],
{ id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' }, content: tabId === TAB_PORTFOLIO
].map(({ id, icon, label, sub, badge, className: cls }) => ( ? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
<button : tabId === TAB_AI
key={id} ? <AiTradeTab aib={aib} />
type="button" : tabId === TAB_REPORT
className={`stock-main-tab ${cls ?? ''} ${activeTab === id ? 'is-active' : ''}`} ? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
onClick={() => setActiveTab(id)} : <AdvisorTab pf={pf} advisor={advisor} />,
> }))}
<span className="stock-main-tab__icon">{icon}</span> activeIndex={tabIndex}
<span className="stock-main-tab__label">{label}</span> onTabChange={handleTabChange}
{sub && <span className="stock-main-tab__sub">{sub}</span>} />
{badge > 0 && <span className="stock-main-tab__badge">{badge}</span>} ) : (
</button> <>
))} <div className="stock-main-tabs">
</div> {[
{ id: TAB_PORTFOLIO, icon: '💼', label: '쟁승토리 계좌', badge: pf.portfolioHoldings.length || null },
{ id: TAB_AI, icon: '🤖', label: 'AI 투자', sub: '모의투자' },
{ id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' },
{ id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' },
].map(({ id, icon, label, sub, badge, className: cls }) => (
<button
key={id}
type="button"
className={`stock-main-tab ${cls ?? ''} ${activeTab === id ? 'is-active' : ''}`}
onClick={() => setActiveTab(id)}
>
<span className="stock-main-tab__icon">{icon}</span>
<span className="stock-main-tab__label">{label}</span>
{sub && <span className="stock-main-tab__sub">{sub}</span>}
{badge > 0 && <span className="stock-main-tab__badge">{badge}</span>}
</button>
))}
</div>
{/* Tab content */} {activeTab === TAB_PORTFOLIO && (
{activeTab === TAB_PORTFOLIO && ( <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
<PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} /> )}
{activeTab === TAB_AI && <AiTradeTab aib={aib} />}
{activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />}
{activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />}
</>
)} )}
{activeTab === TAB_AI && <AiTradeTab aib={aib} />}
{activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />}
{activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />}
{/* Sell history drawer (always mounted) */} {/* Sell history drawer (always mounted) */}
<SellHistoryDrawer <SellHistoryDrawer

View File

@@ -96,7 +96,7 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
/> />
</label> </label>
<label> <label>
평균 매입 () 평균 ()
<input <input
type="number" type="number"
min={0} min={0}
@@ -108,6 +108,19 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
required required
/> />
</label> </label>
<label>
매입가 ()
<input
type="number"
min={0}
step={1}
value={pf.addForm.purchase_price}
onChange={(e) =>
pf.setAddForm((p) => ({ ...p, purchase_price: e.target.value }))
}
placeholder="미입력 시 평균단가로 자동 설정"
/>
</label>
<button <button
className="button primary" className="button primary"
type="submit" type="submit"
@@ -386,7 +399,8 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
</p> </p>
<h3>{broker} 보유 현황</h3> <h3>{broker} 보유 현황</h3>
<p className="stock-panel__sub"> <p className="stock-panel__sub">
{items.length}종목 · 평가{' '} {items.length}종목 · 매입{' '}
{formatNumber(bSummary.totalBuy)} · 평가{' '}
{formatNumber(bSummary.totalEval)} · 손익{' '} {formatNumber(bSummary.totalEval)} · 손익{' '}
<span className={`stock-profit ${profitColorClass(bSummary.totalProfit)}`}> <span className={`stock-profit ${profitColorClass(bSummary.totalProfit)}`}>
{formatNumber(bSummary.totalProfit)} ( {formatNumber(bSummary.totalProfit)} (
@@ -435,7 +449,7 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
/> />
</label> </label>
<label> <label>
평균매입 평균
<input <input
type="number" type="number"
min={0} min={0}
@@ -448,6 +462,20 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
} }
/> />
</label> </label>
<label>
매입가
<input
type="number"
min={0}
value={pf.editForm.purchase_price ?? ''}
onChange={(e) =>
pf.setEditForm((p) => ({
...p,
purchase_price: Number(e.target.value),
}))
}
/>
</label>
</div> </div>
<div className="pf-edit-actions"> <div className="pf-edit-actions">
<button <button
@@ -480,9 +508,13 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
<strong>{formatNumber(item.quantity)}</strong> <strong>{formatNumber(item.quantity)}</strong>
</div> </div>
<div className="stock-holdings__metric"> <div className="stock-holdings__metric">
<span>매입</span> <span>평균단</span>
<strong>{formatNumber(item.avg_price)}</strong> <strong>{formatNumber(item.avg_price)}</strong>
</div> </div>
<div className="stock-holdings__metric">
<span>매입가</span>
<strong>{formatNumber(item.purchase_price ?? item.avg_price)}</strong>
</div>
<div className="stock-holdings__metric"> <div className="stock-holdings__metric">
<span>현재가</span> <span>현재가</span>
<strong className={item.current_price == null ? 'pf-null-price' : ''}> <strong className={item.current_price == null ? 'pf-null-price' : ''}>

View File

@@ -70,14 +70,20 @@ export default function usePortfolio() {
}, [brokerGroups]); }, [brokerGroups]);
const getBrokerSummary = (items) => { const getBrokerSummary = (items) => {
let totalBuy = 0, totalEvalAmt = 0, hasNullPrice = false; // totalBuy: 요약 표시용 (매입가 purchase_price 기준)
// totalCostBasis: 손익 계산용 (평균단가 avg_price 기준)
let totalBuy = 0, totalCostBasis = 0, totalEvalAmt = 0, hasNullPrice = false;
for (const item of items) { for (const item of items) {
totalBuy += (item.avg_price ?? 0) * (item.quantity ?? 0); const qty = item.quantity ?? 0;
const purchase = item.purchase_price ?? item.avg_price ?? 0;
// 총 매입 = 종목별 매입가의 단순 합 (수량 미곱산)
totalBuy += purchase;
totalCostBasis += (item.avg_price ?? 0) * qty;
if (item.eval_amount != null) totalEvalAmt += item.eval_amount; if (item.eval_amount != null) totalEvalAmt += item.eval_amount;
else hasNullPrice = true; else hasNullPrice = true;
} }
const totalProfit = totalEvalAmt - totalBuy; const totalProfit = totalEvalAmt - totalCostBasis;
const totalProfitRate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0; const totalProfitRate = totalCostBasis > 0 ? (totalProfit / totalCostBasis) * 100 : 0;
return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice }; return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice };
}; };
@@ -108,6 +114,9 @@ export default function usePortfolio() {
name: addForm.name.trim(), name: addForm.name.trim(),
quantity: Number(addForm.quantity), quantity: Number(addForm.quantity),
avg_price: Number(addForm.avg_price), avg_price: Number(addForm.avg_price),
purchase_price: addForm.purchase_price === '' || addForm.purchase_price == null
? Number(addForm.avg_price)
: Number(addForm.purchase_price),
}); });
setAddForm({ ...emptyPortfolioForm }); setAddForm({ ...emptyPortfolioForm });
setAddFormOpen(false); setAddFormOpen(false);
@@ -121,7 +130,13 @@ export default function usePortfolio() {
const handleEditStart = (item) => { const handleEditStart = (item) => {
setEditingId(item.id); setEditingId(item.id);
const data = { quantity: item.quantity, avg_price: item.avg_price, broker: item.broker, name: item.name }; const data = {
quantity: item.quantity,
avg_price: item.avg_price,
purchase_price: item.purchase_price ?? item.avg_price,
broker: item.broker,
name: item.name,
};
setEditForm(data); setEditForm(data);
editOrigRef.current = { ...data }; editOrigRef.current = { ...data };
}; };

View File

@@ -95,6 +95,7 @@ export const emptyPortfolioForm = {
name: '', name: '',
quantity: '', quantity: '',
avg_price: '', avg_price: '',
purchase_price: '',
}; };
/* ── empty sell-history form ─────────────────────────────────────── */ /* ── empty sell-history form ─────────────────────────────────────── */

View File

@@ -1139,19 +1139,16 @@
/* ── 반응형 ───────────────────────────────────────────────────────────── */ /* ── 반응형 ───────────────────────────────────────────────────────────── */
@media (max-width: 1100px) { @media (max-width: 1024px) {
.sub-list-layout { grid-template-columns: 1fr 360px; } .sub-list-layout { grid-template-columns: 1fr 360px; }
} }
@media (max-width: 900px) { @media (max-width: 768px) {
.sub-list-layout { grid-template-columns: 1fr; } .sub-list-layout { grid-template-columns: 1fr; }
.sub-detail-panel { position: static; } .sub-detail-panel { position: static; }
.sub-profile-card { grid-template-columns: 1fr; } .sub-profile-card { grid-template-columns: 1fr; }
.sub-profile-card__right { flex-direction: column; align-items: flex-start; } .sub-profile-card__right { flex-direction: column; align-items: flex-start; }
.sub-profile-score__breakdown { min-width: 0; width: 100%; } .sub-profile-score__breakdown { min-width: 0; width: 100%; }
}
@media (max-width: 768px) {
.sub-header { grid-template-columns: 1fr; } .sub-header { grid-template-columns: 1fr; }
.sub-stats-bar { display: grid; grid-template-columns: repeat(2, 1fr); } .sub-stats-bar { display: grid; grid-template-columns: repeat(2, 1fr); }
.sub-stat-item { border-right: none; border-bottom: 1px solid var(--line); } .sub-stat-item { border-right: none; border-bottom: 1px solid var(--line); }
@@ -1164,4 +1161,20 @@
.sub-tabs-bar { flex-direction: column; align-items: flex-start; } .sub-tabs-bar { flex-direction: column; align-items: flex-start; }
.sub-sched-row { grid-template-columns: 90px 10px 1fr; gap: 0 10px; } .sub-sched-row { grid-template-columns: 90px 10px 1fr; gap: 0 10px; }
.sub-compare__row { grid-template-columns: 70px 1fr 1fr 16px; } .sub-compare__row { grid-template-columns: 70px 1fr 1fr 16px; }
/* 공고 카드 1컬럼 */
.sub-card-grid {
grid-template-columns: 1fr;
}
/* 탭 가로 스크롤 */
.sub-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.sub-tabs > * {
flex-shrink: 0;
white-space: nowrap;
}
} }

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { apiGet, apiPost, apiPut, apiDelete } from '../../api'; import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import './Subscription.css'; import './Subscription.css';
// ── 상수 ─────────────────────────────────────────────────────────────────────── // ── 상수 ───────────────────────────────────────────────────────────────────────
@@ -636,6 +637,19 @@ function AnnouncementsTab() {
} }
}; };
const handleDeleteClosed = async () => {
if (!confirm('종료된(완료) 청약 공고를 모두 삭제할까요?')) return;
try {
const res = await apiDelete('/api/realestate/announcements/closed');
alert(`${res.deleted || 0}건 삭제되었습니다.`);
setPage(1);
load();
} catch (e) {
console.error('Delete closed error:', e);
alert('삭제 실패');
}
};
const handleBookmark = async (id) => { const handleBookmark = async (id) => {
try { try {
const updated = await apiPatch(`/api/realestate/announcements/${id}/bookmark`); const updated = await apiPatch(`/api/realestate/announcements/${id}/bookmark`);
@@ -680,6 +694,14 @@ function AnnouncementsTab() {
onChange={(e) => { setRegionFilter(e.target.value); setPage(1); }} onChange={(e) => { setRegionFilter(e.target.value); setPage(1); }}
style={{ width: 160, padding: '6px 12px', fontSize: 12 }} style={{ width: 160, padding: '6px 12px', fontSize: 12 }}
/> />
<button
className="sub-filter-btn"
onClick={handleDeleteClosed}
style={{ fontSize: 12, color: '#f87171' }}
title="status='완료' 공고 일괄 삭제"
>
🗑 종료 청약 삭제
</button>
</div> </div>
</div> </div>
@@ -1276,8 +1298,18 @@ function ProfileTab() {
// ── Subscription (Main) ────────────────────────────────────────────────────── // ── Subscription (Main) ──────────────────────────────────────────────────────
function Subscription() { function Subscription() {
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [refreshKey, setRefreshKey] = useState(0);
const handleRefresh = useCallback(async () => {
setRefreshKey(k => k + 1);
}, []);
const handleFABClick = useCallback(() => {
setActiveTab(1); // 공고 목록 탭으로 이동
}, []);
return ( return (
<PullToRefresh onRefresh={handleRefresh}>
<div className="sub"> <div className="sub">
{/* Header */} {/* Header */}
<div className="sub-header"> <div className="sub-header">
@@ -1307,12 +1339,15 @@ function Subscription() {
{/* Body */} {/* Body */}
<div className="sub-body"> <div className="sub-body">
{activeTab === 0 && <DashboardTab />} {activeTab === 0 && <DashboardTab key={`dash-${refreshKey}`} />}
{activeTab === 1 && <AnnouncementsTab />} {activeTab === 1 && <AnnouncementsTab key={`ann-${refreshKey}`} />}
{activeTab === 2 && <MatchesTab />} {activeTab === 2 && <MatchesTab key={`match-${refreshKey}`} />}
{activeTab === 3 && <ProfileTab />} {activeTab === 3 && <ProfileTab key={`prof-${refreshKey}`} />}
</div> </div>
<FAB onClick={handleFABClick} label="공고 목록" />
</div> </div>
</PullToRefresh>
); );
} }

View File

@@ -222,8 +222,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 26px; width: 36px;
height: 26px; height: 36px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
@@ -370,11 +370,21 @@
text-decoration-color: rgba(244, 114, 182, 0.4); text-decoration-color: rgba(244, 114, 182, 0.4);
} }
/* ── 스와이프 보드 (모바일 전용) ──────────────────────────────────────── */
.todo-swipe-board {
display: none;
}
/* ── Responsive ──────────────────────────────────────────────────────── */ /* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.todo-board { .todo-board {
grid-template-columns: 1fr; display: none;
}
.todo-swipe-board {
display: block;
} }
.todo-col { .todo-col {

View File

@@ -1,5 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { getTodos, addTodo, updateTodo, deleteTodo, clearTodos } from '../../api'; import { getTodos, addTodo, updateTodo, deleteTodo, clearTodos } from '../../api';
import { useIsMobile } from '../../hooks/useIsMobile';
import SwipeableView from '../../components/SwipeableView';
import FAB from '../../components/FAB';
import MobileSheet from '../../components/MobileSheet';
import PullToRefresh from '../../components/PullToRefresh';
import './Todo.css'; import './Todo.css';
const ACTIVE_COLUMNS = [ const ACTIVE_COLUMNS = [
@@ -19,11 +24,13 @@ const toDateStr = (iso) => {
}; };
const Todo = () => { const Todo = () => {
const isMobile = useIsMobile();
const [todos, setTodos] = useState([]); const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [addSheetOpen, setAddSheetOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [dragging, setDragging] = useState(null); const [dragging, setDragging] = useState(null);
const [dragOver, setDragOver] = useState(null); const [dragOver, setDragOver] = useState(null);
@@ -185,7 +192,66 @@ const Todo = () => {
</div> </div>
); );
/* ── 칸반 컬럼 렌더러 (재사용) ── */
const renderColumn = (col) => {
const items = byStatus(col.id);
return (
<div
key={col.id}
className={`todo-col${dragOver === col.id ? ' is-drag-over' : ''}`}
onDragOver={(e) => onDragOver(e, col.id)}
onDrop={(e) => onDrop(e, col.id)}
>
<div className="todo-col__head">
<span className="todo-col__title">{col.label}</span>
<span className="todo-col__count">{items.length}</span>
</div>
<div className="todo-col__body">
{items.length === 0 && (
<p className="todo-col__empty">드래그하여 이동</p>
)}
{items.map((todo) => renderCard(todo, col.id))}
</div>
</div>
);
};
/* ── 추가 폼 (공통) ── */
const addForm = (
<form className="todo-form" onSubmit={async (e) => { await handleAdd(e); setAddSheetOpen(false); }}>
<label className="todo-form__field">
<span>제목 *</span>
<input
type="text"
placeholder="태스크 제목을 입력하세요"
value={form.title}
onChange={(e) => setForm((v) => ({ ...v, title: e.target.value }))}
required
/>
</label>
<label className="todo-form__field">
<span>설명</span>
<textarea
placeholder="설명 (선택)"
value={form.description}
rows={3}
onChange={(e) => setForm((v) => ({ ...v, description: e.target.value }))}
/>
</label>
<div className="todo-form__actions">
<button
type="submit"
className="button primary"
disabled={saving || !form.title.trim()}
>
{saving ? '저장 중...' : '추가'}
</button>
</div>
</form>
);
return ( return (
<PullToRefresh onRefresh={load}>
<div className="todo-page"> <div className="todo-page">
{/* 툴바 */} {/* 툴바 */}
<div className="todo-toolbar"> <div className="todo-toolbar">
@@ -194,7 +260,7 @@ const Todo = () => {
className="button primary" className="button primary"
onClick={() => setFormOpen((v) => !v)} onClick={() => setFormOpen((v) => !v)}
> >
{formOpen ? '취소' : '+ 태스크 추가'} {formOpen ? '취소' : '+ 할일 추가'}
</button> </button>
<button <button
type="button" type="button"
@@ -205,127 +271,126 @@ const Todo = () => {
</button> </button>
</div> </div>
{/* 추가 폼 */} {/* 추가 폼 (데스크탑) */}
{formOpen && ( {formOpen && !isMobile && addForm}
<form className="todo-form" onSubmit={handleAdd}>
<label className="todo-form__field">
<span>제목 *</span>
<input
type="text"
placeholder="태스크 제목을 입력하세요"
value={form.title}
onChange={(e) => setForm((v) => ({ ...v, title: e.target.value }))}
required
/>
</label>
<label className="todo-form__field">
<span>설명</span>
<textarea
placeholder="설명 (선택)"
value={form.description}
rows={3}
onChange={(e) => setForm((v) => ({ ...v, description: e.target.value }))}
/>
</label>
<div className="todo-form__actions">
<button
type="submit"
className="button primary"
disabled={saving || !form.title.trim()}
>
{saving ? '저장 중...' : '추가'}
</button>
</div>
</form>
)}
{error && <p className="todo-error">{error}</p>} {error && <p className="todo-error">{error}</p>}
{loading && todos.length === 0 && <p className="todo-loading">불러오는 ...</p>} {loading && todos.length === 0 && <p className="todo-loading">불러오는 ...</p>}
{/* 활성 보드 (할 일 + 진행 중) */} {/* 모바일: SwipeableView 칸반 */}
<div className="todo-board"> {isMobile ? (
{ACTIVE_COLUMNS.map((col) => { <div className="todo-swipe-board">
const items = byStatus(col.id); <SwipeableView
return ( tabs={[
<div { key: 'todo', label: '할 일', content: renderColumn({ id: 'todo', label: '할 일' }) },
key={col.id} { key: 'in_progress', label: '진행 중', content: renderColumn({ id: 'in_progress', label: '진행 중' }) },
className={`todo-col${dragOver === col.id ? ' is-drag-over' : ''}`} { key: 'done', label: '완료', content: (
onDragOver={(e) => onDragOver(e, col.id)} <div
onDrop={(e) => onDrop(e, col.id)} className={`todo-done-panel${dragOver === 'done' ? ' is-drag-over' : ''}`}
> onDragOver={(e) => onDragOver(e, 'done')}
<div className="todo-col__head"> onDrop={(e) => onDrop(e, 'done')}
<span className="todo-col__title">{col.label}</span> >
<span className="todo-col__count">{items.length}</span> <div className="todo-done-panel__head">
</div> <div className="todo-done-panel__title-row">
<div className="todo-col__body"> <span className="todo-col__title">완료</span>
{items.length === 0 && ( <span className="todo-col__count">{doneTodos.length}</span>
<p className="todo-col__empty">드래그하여 이동</p> </div>
<div className="todo-done-panel__filter">
<button type="button" className={`todo-date-btn${doneDate === '' ? ' is-active' : ''}`} onClick={() => setDoneDate('')}>전체</button>
{doneDates.map((d) => (
<button key={d} type="button" className={`todo-date-btn${doneDate === d ? ' is-active' : ''}`} onClick={() => setDoneDate(d)}>
{new Date(d + 'T00:00:00').toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
</button>
))}
</div>
</div>
<div className="todo-done-panel__body">
{doneTodos.length === 0 ? (
<p className="todo-col__empty">{doneDate ? '해당 날짜에 완료된 항목이 없습니다' : '드래그하여 이동'}</p>
) : (
doneTodos.map((todo) => renderCard(todo, 'done'))
)}
</div>
</div>
)},
]}
/>
</div>
) : (
<>
{/* 데스크탑: 활성 보드 (할 일 + 진행 중) */}
<div className="todo-board">
{ACTIVE_COLUMNS.map((col) => renderColumn(col))}
</div>
{/* 완료 패널 */}
<div
className={`todo-done-panel${dragOver === 'done' ? ' is-drag-over' : ''}`}
onDragOver={(e) => onDragOver(e, 'done')}
onDrop={(e) => onDrop(e, 'done')}
>
<div className="todo-done-panel__head">
<div className="todo-done-panel__title-row">
<span className="todo-col__title">완료</span>
<span className="todo-col__count">{doneTodos.length}</span>
{doneDates.length > 0 && doneDate === '' && (
<span className="todo-done-panel__total-hint">
전체 {todos.filter(t => t.status === 'done').length}
</span>
)} )}
{items.map((todo) => renderCard(todo, col.id))} </div>
<div className="todo-done-panel__filter">
<button
type="button"
className={`todo-date-btn${doneDate === '' ? ' is-active' : ''}`}
onClick={() => setDoneDate('')}
>
전체
</button>
{doneDates.map((d) => (
<button
key={d}
type="button"
className={`todo-date-btn${doneDate === d ? ' is-active' : ''}`}
onClick={() => setDoneDate(d)}
>
{new Date(d + 'T00:00:00').toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
</button>
))}
<input
type="date"
className="todo-date-input"
value={doneDate}
onChange={(e) => setDoneDate(e.target.value)}
title="날짜 직접 선택"
/>
</div> </div>
</div> </div>
); <div className="todo-done-panel__body">
})} {doneTodos.length === 0 ? (
</div> <p className="todo-col__empty">
{doneDate ? '해당 날짜에 완료된 항목이 없습니다' : '드래그하여 이동'}
</p>
) : (
doneTodos.map((todo) => renderCard(todo, 'done'))
)}
</div>
</div>
</>
)}
{/* 완료 패널 */} {/* 모바일: 추가 바텀시트 */}
<div <MobileSheet
className={`todo-done-panel${dragOver === 'done' ? ' is-drag-over' : ''}`} open={addSheetOpen}
onDragOver={(e) => onDragOver(e, 'done')} onClose={() => { setAddSheetOpen(false); setForm(emptyForm); }}
onDrop={(e) => onDrop(e, 'done')} title="할일 추가"
> >
{/* 완료 패널 헤더 */} {addForm}
<div className="todo-done-panel__head"> </MobileSheet>
<div className="todo-done-panel__title-row">
<span className="todo-col__title">완료</span>
<span className="todo-col__count">{doneTodos.length}</span>
{doneDates.length > 0 && doneDate === '' && (
<span className="todo-done-panel__total-hint">
전체 {todos.filter(t => t.status === 'done').length}
</span>
)}
</div>
{/* 날짜 필터 */}
<div className="todo-done-panel__filter">
<button
type="button"
className={`todo-date-btn${doneDate === '' ? ' is-active' : ''}`}
onClick={() => setDoneDate('')}
>
전체
</button>
{doneDates.map((d) => (
<button
key={d}
type="button"
className={`todo-date-btn${doneDate === d ? ' is-active' : ''}`}
onClick={() => setDoneDate(d)}
>
{new Date(d + 'T00:00:00').toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
</button>
))}
<input
type="date"
className="todo-date-input"
value={doneDate}
onChange={(e) => setDoneDate(e.target.value)}
title="날짜 직접 선택"
/>
</div>
</div>
{/* 완료 카드 그리드 */} <FAB onClick={() => setAddSheetOpen(true)} label="할일 추가" />
<div className="todo-done-panel__body">
{doneTodos.length === 0 ? (
<p className="todo-col__empty">
{doneDate ? '해당 날짜에 완료된 항목이 없습니다' : '드래그하여 이동'}
</p>
) : (
doneTodos.map((todo) => renderCard(todo, 'done'))
)}
</div>
</div>
</div> </div>
</PullToRefresh>
); );
}; };

View File

@@ -0,0 +1,116 @@
/* ── AlbumCard ── */
.album-card {
position: relative;
height: 240px;
border-radius: 12px;
border: 1px solid rgba(245, 230, 200, 0.08);
overflow: hidden;
cursor: pointer;
outline: none;
transition: transform 0.28s ease, box-shadow 0.28s ease;
}
.album-card:hover,
.album-card:focus-visible {
transform: scale(1.03);
box-shadow: 0 4px 24px color-mix(in srgb, var(--card-accent) 35%, transparent);
}
/* cover image */
.album-card__cover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.35s ease;
}
.album-card:hover .album-card__cover {
transform: scale(1.06);
}
/* gradient overlay */
.album-card__gradient {
position: absolute;
inset: 0;
background: linear-gradient(transparent 50%, rgba(15, 12, 9, 0.85));
pointer-events: none;
}
/* meta */
.album-card__meta {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
z-index: 2;
display: flex;
flex-direction: column;
gap: 4px;
}
.album-card__region-badge {
align-self: flex-start;
font: 10px var(--tv-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--card-accent);
background: rgba(15, 12, 9, 0.6);
padding: 2px 8px;
border-radius: 4px;
}
.album-card__name {
margin: 0;
font: 600 24px/1.15 var(--tv-serif);
color: var(--tv-text);
}
.album-card__count {
font: 11px var(--tv-mono);
letter-spacing: 0.06em;
color: var(--tv-muted);
background: rgba(15, 12, 9, 0.55);
padding: 2px 8px;
border-radius: 4px;
align-self: flex-start;
}
/* grid layout */
.album-card-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 1024px) {
.album-card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.album-card-grid {
grid-template-columns: 1fr;
}
.album-card {
height: 200px;
}
.album-card__name {
font-size: 18px;
}
}
/* reduced motion */
@media (prefers-reduced-motion: reduce) {
.album-card,
.album-card__cover {
transition: none;
transform: none !important;
}
}

View File

@@ -0,0 +1,55 @@
import React, { useRef, useCallback } from 'react';
import { getRegionAccent } from './MiniMap';
import './AlbumCard.css';
/* ─────────────────────────────────────────────
AlbumCard — cover image + gradient + meta
───────────────────────────────────────────── */
export default function AlbumCard({ album, onClick }) {
const cardRef = useRef(null);
const accent = getRegionAccent(album.region || '');
const handleClick = useCallback(() => {
if (!onClick) return;
const rect = cardRef.current?.getBoundingClientRect();
onClick(album, rect);
}, [album, onClick]);
const handleKeyDown = useCallback(
(e) => {
if (e.key === 'Enter') handleClick();
},
[handleClick],
);
return (
<div
ref={cardRef}
className="album-card"
style={{ '--card-accent': accent }}
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{/* cover */}
<img
className="album-card__cover"
src={album.coverThumb}
alt={album.name}
loading="lazy"
draggable={false}
/>
{/* gradient overlay */}
<div className="album-card__gradient" />
{/* meta */}
<div className="album-card__meta">
<span className="album-card__region-badge">{album.regionName}</span>
<h3 className="album-card__name">{album.name}</h3>
<span className="album-card__count">{album.photoCount} frames</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,183 @@
/* ─────────────────────────────────────────────
AlbumDetail — fixed overlay
───────────────────────────────────────────── */
.album-detail {
position: fixed;
inset: 0;
z-index: 2000;
background: var(--tv-bg, #0f0c09);
display: flex;
flex-direction: column;
opacity: 0;
transform: scale(0.95);
transition: opacity 400ms cubic-bezier(0.4, 0, 0.2, 1),
transform 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
.album-detail--open {
opacity: 1;
transform: scale(1);
}
.album-detail--exit {
opacity: 0;
transform: scale(0.95);
}
/* ── Header ── */
.album-detail__header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--tv-line, rgba(232, 221, 208, 0.1));
flex-shrink: 0;
}
.album-detail__back {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22));
background: transparent;
color: var(--tv-text, #e8ddd0);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: background 200ms, border-color 200ms;
}
.album-detail__back:hover {
background: var(--tv-line, rgba(232, 221, 208, 0.1));
border-color: var(--tv-muted, rgba(232, 221, 208, 0.45));
}
.album-detail__title-group {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.album-detail__name {
font-family: var(--tv-serif, Georgia, 'Times New Roman', serif);
font-size: 22px;
font-weight: 400;
color: var(--tv-text, #e8ddd0);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album-detail__region {
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
padding: 2px 6px;
border-radius: 3px;
background: var(--tv-line, rgba(232, 221, 208, 0.1));
width: fit-content;
}
.album-detail__count {
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
font-size: 11px;
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
white-space: nowrap;
flex-shrink: 0;
}
/* ── Body ── */
.album-detail__body {
flex: 1;
overflow-y: auto;
min-height: 0;
padding-bottom: env(safe-area-inset-bottom, 0);
}
/* ── Loading dots ── */
.album-detail__loading {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 80px 0;
}
.album-detail__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--tv-muted, rgba(232, 221, 208, 0.45));
animation: albumDetailPulse 1.2s ease-in-out infinite;
}
.album-detail__dot:nth-child(2) {
animation-delay: 0.2s;
}
.album-detail__dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes albumDetailPulse {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1); }
}
/* ── Error / Empty ── */
.album-detail__error,
.album-detail__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 24px;
text-align: center;
gap: 12px;
}
.album-detail__error-text {
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
font-size: 12px;
color: #c85a4a;
}
.album-detail__empty-text {
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
font-size: 12px;
color: var(--tv-dim, rgba(232, 221, 208, 0.25));
}
/* ── Mobile ── */
@media (max-width: 768px) {
.album-detail__header {
padding: 12px 16px;
}
.album-detail__name {
font-size: 18px;
}
.album-detail__body {
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0));
}
}
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.album-detail {
transition: none;
}
.album-detail__dot {
animation: none;
opacity: 0.6;
}
}

View File

@@ -0,0 +1,216 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import SwipeableView from '../../components/SwipeableView';
import PullToRefresh from '../../components/PullToRefresh';
import MasonryGrid from './MasonryGrid';
import HeroLightbox from './HeroLightbox';
import VideoTab from './VideoTab';
import { getRegionAccent } from './MiniMap';
import { useIsMobile } from '../../hooks/useIsMobile';
import './AlbumDetail.css';
/* ─────────────────────────────────────────────
AlbumDetail — full-screen album overlay
───────────────────────────────────────────── */
const ANIM_MS = 400;
const prefersReduced = () =>
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
export default function AlbumDetail({
album,
sourceRect,
photos,
photoSummary,
loading,
loadingMore,
hasNext,
error,
onClose,
onLoadMore,
onReload,
}) {
const isMobile = useIsMobile();
/* ── Animation phases: enter → open → exit ── */
const [phase, setPhase] = useState('enter');
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null);
const [lightboxRect, setLightboxRect] = useState(null);
const closingRef = useRef(false);
// Enter → open
useEffect(() => {
if (prefersReduced()) {
setPhase('open');
return;
}
const raf = requestAnimationFrame(() => {
requestAnimationFrame(() => setPhase('open'));
});
return () => cancelAnimationFrame(raf);
}, []);
/* ── Body scroll lock (only when lightbox NOT open) ── */
useEffect(() => {
if (selectedPhotoIndex != null) return; // lightbox handles its own
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [selectedPhotoIndex]);
/* ── ESC key (close album when lightbox not open) ── */
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape' && selectedPhotoIndex == null) {
handleClose();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [selectedPhotoIndex]); // eslint-disable-line react-hooks/exhaustive-deps
/* ── Close with exit animation ── */
const handleClose = useCallback(() => {
if (closingRef.current) return;
closingRef.current = true;
if (prefersReduced()) {
onClose();
return;
}
setPhase('exit');
setTimeout(() => onClose(), ANIM_MS);
}, [onClose]);
/* ── Photo selection → open lightbox ── */
const handleSelectPhoto = useCallback((e, index) => {
const el = e?.currentTarget || e?.target;
const rect = el ? el.getBoundingClientRect() : null;
setLightboxRect(rect);
setSelectedPhotoIndex(index);
}, []);
const handleLightboxClose = useCallback(() => {
setSelectedPhotoIndex(null);
setLightboxRect(null);
}, []);
const handleLightboxNavigate = useCallback((idx) => {
setSelectedPhotoIndex(idx);
}, []);
/* ── Derived ── */
const regionAccent = getRegionAccent(album?.region || album?.id || '');
const photoCountLabel = photoSummary?.total
? `${photoSummary.total} photos`
: photos?.length
? `${photos.length}${hasNext ? '+' : ''}`
: '';
/* ── Phase → class ── */
const cls = [
'album-detail',
phase === 'open' && 'album-detail--open',
phase === 'exit' && 'album-detail--exit',
].filter(Boolean).join(' ');
/* ── Tab content: Photos ── */
const photosContent = (
<div className="album-detail__body">
{loading ? (
<div className="album-detail__loading">
<span className="album-detail__dot" />
<span className="album-detail__dot" />
<span className="album-detail__dot" />
</div>
) : error ? (
<div className="album-detail__error">
<span className="album-detail__error-text">{error}</span>
</div>
) : !photos || photos.length === 0 ? (
<div className="album-detail__empty">
<span className="album-detail__empty-text">No photos</span>
</div>
) : (
<PullToRefresh onRefresh={onReload}>
<MasonryGrid
photos={photos}
onSelectPhoto={handleSelectPhoto}
onLoadMore={onLoadMore}
hasNext={hasNext}
isLoadingMore={loadingMore}
regionAccent={regionAccent}
/>
</PullToRefresh>
)}
</div>
);
/* ── Tab content: Video ── */
const videoContent = (
<div className="album-detail__body">
<VideoTab />
</div>
);
/* ── Tabs ── */
const tabLabel = `사진${photoCountLabel ? ` (${photoCountLabel})` : ''}`;
const tabs = [
{ key: 'photos', label: tabLabel, content: photosContent },
{ key: 'video', label: '영상', content: videoContent },
];
return (
<>
<div className={cls}>
{/* Header */}
<div className="album-detail__header">
<button
className="album-detail__back"
onClick={handleClose}
aria-label="Back"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path
d="M11 4L6 9l5 5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<div className="album-detail__title-group">
<span className="album-detail__name">{album?.name || ''}</span>
{album?.regionName && (
<span className="album-detail__region">{album.regionName}</span>
)}
</div>
{photoCountLabel && (
<span className="album-detail__count">{photoCountLabel}</span>
)}
</div>
{/* Tabs */}
<SwipeableView tabs={tabs} />
</div>
{/* Lightbox */}
{selectedPhotoIndex != null && photos?.length > 0 && (
<HeroLightbox
photos={photos}
selectedIndex={selectedPhotoIndex}
albumName={album?.name}
regionId={album?.region || album?.id}
sourceRect={lightboxRect}
hasNext={hasNext}
loadingMore={loadingMore}
onClose={handleLightboxClose}
onNavigate={handleLightboxNavigate}
onLoadMore={onLoadMore}
/>
)}
</>
);
}

View File

@@ -0,0 +1,279 @@
/* ═══════════════════════════════════════════
HeroLightbox — fullscreen photo viewer
═══════════════════════════════════════════ */
/* ── Root overlay ── */
.hero-lb {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.35s ease;
}
.hero-lb--enter { opacity: 0; }
.hero-lb--open { opacity: 1; }
.hero-lb--exit { opacity: 0; pointer-events: none; }
/* ── Backdrop ── */
.hero-lb__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.95);
transition: background 0.35s ease;
}
.hero-lb--enter .hero-lb__backdrop { background: rgba(0, 0, 0, 0); }
/* ── Inner container ── */
.hero-lb__inner {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
max-width: 1280px;
width: 100%;
height: 100%;
padding: 16px 24px;
box-sizing: border-box;
}
/* ── Top bar ── */
.hero-lb__topbar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 0 8px;
flex-shrink: 0;
}
.hero-lb__counter {
font-family: var(--tv-mono, 'Space Mono', 'Courier New', monospace);
font-size: 0.85rem;
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
letter-spacing: 0.04em;
}
.hero-lb__counter-cur {
font-weight: 600;
}
.hero-lb__close {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.08);
color: var(--tv-text, #e8ddd0);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
flex-shrink: 0;
}
.hero-lb__close:hover {
background: rgba(255, 255, 255, 0.18);
}
/* ── Stage (photo + arrows) ── */
.hero-lb__stage {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 0;
position: relative;
gap: 12px;
}
/* ── Photo ── */
.hero-lb__photo {
max-width: 100%;
max-height: calc(100vh - 200px);
object-fit: contain;
border-radius: 4px;
user-select: none;
-webkit-user-drag: none;
}
/* ── Slide animations ── */
.hero-lb__slide--next {
animation: hero-slide-right 280ms cubic-bezier(0.22, 0.68, 0.36, 1) both;
}
.hero-lb__slide--prev {
animation: hero-slide-left 280ms cubic-bezier(0.22, 0.68, 0.36, 1) both;
}
@keyframes hero-slide-right {
from { opacity: 0; transform: translateX(24px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes hero-slide-left {
from { opacity: 0; transform: translateX(-24px); }
to { opacity: 1; transform: translateX(0); }
}
/* ── Arrow buttons ── */
.hero-lb__arrow {
width: 44px;
height: 44px;
border-radius: 12px;
border: none;
background: rgba(255, 255, 255, 0.06);
color: var(--tv-text, #e8ddd0);
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.2s, transform 0.15s;
}
.hero-lb__arrow:hover {
background: rgba(255, 255, 255, 0.14);
transform: scale(1.06);
}
.hero-lb__arrow:active {
transform: scale(0.96);
}
.hero-lb__arrow--loading {
cursor: default;
opacity: 0.6;
}
.hero-lb__arrow--loading:hover {
background: rgba(255, 255, 255, 0.06);
transform: none;
}
/* ── Spinner ── */
.hero-lb__spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.15);
border-top-color: var(--tv-text, #e8ddd0);
border-radius: 50%;
animation: hero-spin 0.7s linear infinite;
}
@keyframes hero-spin {
to { transform: rotate(360deg); }
}
/* ── Meta ── */
.hero-lb__meta {
padding: 8px 0 4px;
font-size: 0.82rem;
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
text-align: center;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.hero-lb__meta-album {
font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif);
font-style: italic;
}
.hero-lb__meta-file {
font-family: var(--tv-mono, 'Space Mono', 'Courier New', monospace);
font-size: 0.78rem;
}
/* ── Thumbnail strip ── */
.hero-lb__strip {
display: flex;
gap: 4px;
overflow-x: auto;
justify-content: center;
padding: 8px 0 4px;
flex-shrink: 0;
max-width: 100%;
scrollbar-width: none;
-ms-overflow-style: none;
}
.hero-lb__strip::-webkit-scrollbar {
display: none;
}
.hero-lb__thumb {
width: 52px;
height: 52px;
flex-shrink: 0;
border-radius: 4px;
border: 2px solid transparent;
padding: 0;
background: none;
cursor: pointer;
overflow: hidden;
transition: border-color 0.2s, opacity 0.2s;
opacity: 0.55;
}
.hero-lb__thumb:hover {
opacity: 0.85;
}
.hero-lb__thumb--active {
border-color: #f5e6c8;
opacity: 1;
}
.hero-lb__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 2px;
}
/* ═══════════════════════════════════════════
Mobile (<=768px)
═══════════════════════════════════════════ */
@media (max-width: 768px) {
.hero-lb__inner {
max-width: 100vw;
padding: 12px 12px;
}
.hero-lb__arrow {
display: none;
}
.hero-lb__thumb {
width: 44px;
height: 44px;
}
.hero-lb__photo {
max-height: calc(100vh - 180px);
}
.hero-lb__meta {
font-size: 0.76rem;
}
}
/* ═══════════════════════════════════════════
Reduced motion
═══════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
.hero-lb,
.hero-lb__backdrop,
.hero-lb__close,
.hero-lb__arrow,
.hero-lb__thumb {
transition: none;
}
.hero-lb__slide--next,
.hero-lb__slide--prev {
animation: none;
}
.hero-lb__spinner {
animation: none;
}
}

View File

@@ -0,0 +1,268 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useSwipeable } from 'react-swipeable';
import { useIsMobile } from '../../hooks/useIsMobile';
import { getRegionAccent } from './MiniMap';
import './HeroLightbox.css';
/* ─────────────────────────────────────────────
Helpers
───────────────────────────────────────────── */
const STRIP_LIMIT = 36;
const THUMB_SIZE = 52;
const THUMB_SIZE_MOBILE = 44;
const ANIM_MS = 350;
function getStripRange(total, active) {
if (total <= STRIP_LIMIT) return [0, total];
const half = Math.floor(STRIP_LIMIT / 2);
let start = active - half;
if (start < 0) start = 0;
let end = start + STRIP_LIMIT;
if (end > total) {
end = total;
start = Math.max(0, end - STRIP_LIMIT);
}
return [start, end];
}
const prefersReduced = () =>
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* ─────────────────────────────────────────────
HeroLightbox
───────────────────────────────────────────── */
export default function HeroLightbox({
photos,
selectedIndex,
albumName,
regionId,
sourceRect,
hasNext,
loadingMore,
onClose,
onNavigate,
onLoadMore,
}) {
const isMobile = useIsMobile();
const [phase, setPhase] = useState('enter');
const [slideDir, setSlideDir] = useState(null);
const [slideToken, setSlideToken] = useState(0);
const pendingAdvanceRef = useRef(false);
const stripRef = useRef(null);
const prevOverflowRef = useRef('');
const accent = useMemo(() => getRegionAccent(regionId), [regionId]);
const reduced = useMemo(() => prefersReduced(), []);
const animMs = reduced ? 0 : ANIM_MS;
/* — Phase transitions — */
useEffect(() => {
// enter → open via double rAF
let raf1, raf2;
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setPhase('open'));
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}, []);
/* — Body scroll lock — */
useEffect(() => {
prevOverflowRef.current = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prevOverflowRef.current;
};
}, []);
/* — Pending advance after load more — */
useEffect(() => {
if (pendingAdvanceRef.current && !loadingMore) {
pendingAdvanceRef.current = false;
goNext();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadingMore, photos.length]);
/* — Auto-center active thumb — */
useEffect(() => {
if (!stripRef.current) return;
const thumbSize = isMobile ? THUMB_SIZE_MOBILE : THUMB_SIZE;
const gap = 4;
const stripW = stripRef.current.offsetWidth;
const scrollTarget =
selectedIndex * (thumbSize + gap) - stripW / 2 + thumbSize / 2;
stripRef.current.scrollTo({ left: scrollTarget, behavior: reduced ? 'auto' : 'smooth' });
}, [selectedIndex, isMobile, reduced]);
/* — Close handler — */
const handleClose = useCallback(() => {
setPhase('exit');
setTimeout(onClose, animMs);
}, [onClose, animMs]);
/* — Navigation — */
const goPrev = useCallback(() => {
if (selectedIndex <= 0) return;
setSlideDir('prev');
setSlideToken((t) => t + 1);
onNavigate(selectedIndex - 1);
}, [selectedIndex, onNavigate]);
const goNext = useCallback(() => {
if (selectedIndex >= photos.length - 1) {
if (hasNext) {
pendingAdvanceRef.current = true;
onLoadMore?.();
}
return;
}
setSlideDir('next');
setSlideToken((t) => t + 1);
onNavigate(selectedIndex + 1);
}, [selectedIndex, photos.length, hasNext, onNavigate, onLoadMore]);
/* — Keyboard — */
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape') handleClose();
else if (e.key === 'ArrowLeft') goPrev();
else if (e.key === 'ArrowRight') goNext();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [handleClose, goPrev, goNext]);
/* — Swipe — */
const swipeHandlers = useSwipeable({
onSwipedLeft: goNext,
onSwipedRight: goPrev,
onSwipedDown: (e) => {
if (e.absY > 100) handleClose();
},
trackMouse: false,
delta: 30,
});
/* — Current photo — */
const photo = photos[selectedIndex];
if (!photo) return null;
const thumbSz = isMobile ? THUMB_SIZE_MOBILE : THUMB_SIZE;
const [stripStart, stripEnd] = getStripRange(photos.length, selectedIndex);
const stripPhotos = photos.slice(stripStart, stripEnd);
const slideClass =
slideDir === 'next'
? 'hero-lb__slide--next'
: slideDir === 'prev'
? 'hero-lb__slide--prev'
: '';
return (
<div
className={`hero-lb hero-lb--${phase}`}
{...swipeHandlers}
role="dialog"
aria-modal="true"
aria-label="Photo viewer"
>
{/* Backdrop */}
<div className="hero-lb__backdrop" onClick={handleClose} />
{/* Inner */}
<div className="hero-lb__inner">
{/* Top bar */}
<div className="hero-lb__topbar">
<span className="hero-lb__counter">
<span className="hero-lb__counter-cur" style={{ color: accent }}>
{selectedIndex + 1}
</span>
{' / '}
{photos.length}
</span>
<button
className="hero-lb__close"
onClick={handleClose}
aria-label="Close"
>
×
</button>
</div>
{/* Main photo area */}
<div className="hero-lb__stage">
{/* Left arrow */}
{!isMobile && selectedIndex > 0 && (
<button className="hero-lb__arrow hero-lb__arrow--left" onClick={goPrev} aria-label="Previous">
</button>
)}
{/* Photo */}
<img
key={slideToken}
className={`hero-lb__photo ${slideClass}`}
src={photo.url || photo.src}
alt={photo.filename || photo.name || ''}
draggable={false}
/>
{/* Right arrow */}
{!isMobile && selectedIndex < photos.length - 1 && (
<button className="hero-lb__arrow hero-lb__arrow--right" onClick={goNext} aria-label="Next">
</button>
)}
{/* Loading spinner for load-more */}
{!isMobile && loadingMore && selectedIndex >= photos.length - 1 && (
<button className="hero-lb__arrow hero-lb__arrow--right hero-lb__arrow--loading" disabled aria-label="Loading">
<span className="hero-lb__spinner" />
</button>
)}
</div>
{/* Meta */}
<div className="hero-lb__meta">
<span className="hero-lb__meta-album">{albumName}</span>
{' · '}
<span className="hero-lb__meta-file">{photo.filename || photo.name || ''}</span>
</div>
{/* Thumbnail strip */}
<div className="hero-lb__strip" ref={stripRef}>
{stripPhotos.map((p, i) => {
const realIdx = stripStart + i;
const isActive = realIdx === selectedIndex;
return (
<button
key={p.id || realIdx}
className={`hero-lb__thumb${isActive ? ' hero-lb__thumb--active' : ''}`}
style={{
width: thumbSz,
height: thumbSz,
borderColor: isActive ? '#f5e6c8' : 'transparent',
}}
onClick={() => {
setSlideDir(realIdx > selectedIndex ? 'next' : 'prev');
setSlideToken((t) => t + 1);
onNavigate(realIdx);
}}
aria-label={`Photo ${realIdx + 1}`}
>
<img
src={p.thumbUrl || p.thumb || p.url || p.src}
alt=""
draggable={false}
/>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
/* ── MasonryGrid ── */
.masonry-grid {
column-count: 4;
column-gap: 8px;
}
/* item */
.masonry-item {
break-inside: avoid;
margin-bottom: 8px;
position: relative;
border-radius: 4px;
overflow: hidden;
cursor: zoom-in;
/* scroll-reveal initial state */
opacity: 0;
transform: translateY(20px);
transition: opacity 0.45s ease, transform 0.45s ease;
}
.masonry-item--revealed {
opacity: 1;
transform: translateY(0);
}
.masonry-item__img {
display: block;
width: 100%;
height: auto;
transition: filter 0.25s ease;
}
.masonry-item:hover .masonry-item__img {
filter: brightness(1.08);
}
/* hover overlay */
.masonry-item__overlay {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
padding: 8px 10px;
background: linear-gradient(transparent 60%, rgba(15, 12, 9, 0.7));
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
.masonry-item:hover .masonry-item__overlay {
opacity: 1;
}
.masonry-item__label {
font: 11px var(--tv-mono);
color: var(--tv-text);
letter-spacing: 0.04em;
}
/* sentinel */
.masonry-sentinel {
height: 1px;
column-span: all;
}
/* loading dots */
.masonry-loading {
column-span: all;
display: flex;
justify-content: center;
gap: 6px;
padding: 24px 0;
}
.masonry-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--tv-muted);
animation: masonry-pulse 1.2s ease-in-out infinite;
}
.masonry-dot:nth-child(2) {
animation-delay: 0.15s;
}
.masonry-dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes masonry-pulse {
0%, 80%, 100% { opacity: 0.25; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
/* end message */
.masonry-end {
column-span: all;
text-align: center;
font: 11px var(--tv-mono);
letter-spacing: 0.12em;
color: var(--tv-dim);
padding: 32px 0 16px;
margin: 0;
}
/* responsive */
@media (max-width: 1024px) {
.masonry-grid {
column-count: 3;
}
}
@media (max-width: 768px) {
.masonry-grid {
column-count: 2;
}
}
/* reduced motion */
@media (prefers-reduced-motion: reduce) {
.masonry-item {
opacity: 1;
transform: none;
transition: none;
}
.masonry-item__img,
.masonry-item__overlay {
transition: none;
}
.masonry-dot {
animation: none;
}
}

View File

@@ -0,0 +1,117 @@
import React, { useEffect, useRef, useCallback } from 'react';
import './MasonryGrid.css';
/* ─────────────────────────────────────────────
Utility
───────────────────────────────────────────── */
function getPhotoLabel(photo) {
if (photo.label) return photo.label;
if (photo.name) {
const base = photo.name.replace(/\.[^.]+$/, '');
return base.replace(/[_-]/g, ' ');
}
return '';
}
/* ─────────────────────────────────────────────
MasonryGrid — CSS columns + infinite scroll
───────────────────────────────────────────── */
export default function MasonryGrid({
photos,
onSelectPhoto,
onLoadMore,
hasNext,
isLoadingMore,
regionAccent,
}) {
const sentinelRef = useRef(null);
const itemRefs = useRef([]);
/* infinite scroll sentinel */
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasNext) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !isLoadingMore && onLoadMore) {
onLoadMore();
}
},
{ rootMargin: '300px' },
);
io.observe(sentinel);
return () => io.disconnect();
}, [hasNext, isLoadingMore, onLoadMore]);
/* scroll-reveal */
useEffect(() => {
const nodes = itemRefs.current.filter(Boolean);
if (!nodes.length) return;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('masonry-item--revealed');
io.unobserve(entry.target);
}
});
},
{ rootMargin: '120px', threshold: 0.05 },
);
nodes.forEach((n) => io.observe(n));
return () => io.disconnect();
}, [photos]);
const setItemRef = useCallback((el, idx) => {
itemRefs.current[idx] = el;
}, []);
return (
<div className="masonry-grid" style={{ '--region-accent': regionAccent }}>
{photos.map((photo, idx) => {
const label = getPhotoLabel(photo);
return (
<div
key={photo.id || photo.src || idx}
className="masonry-item"
ref={(el) => setItemRef(el, idx)}
onClick={() => onSelectPhoto && onSelectPhoto(photo, idx)}
>
<img
className="masonry-item__img"
src={photo.thumb || photo.src}
alt={label}
loading={idx < 8 ? 'eager' : 'lazy'}
draggable={false}
/>
{label && (
<div className="masonry-item__overlay">
<span className="masonry-item__label">{label}</span>
</div>
)}
</div>
);
})}
{/* sentinel for infinite scroll */}
{hasNext && <div ref={sentinelRef} className="masonry-sentinel" />}
{/* loading indicator */}
{isLoadingMore && (
<div className="masonry-loading">
<span className="masonry-dot" />
<span className="masonry-dot" />
<span className="masonry-dot" />
</div>
)}
{/* end message */}
{!hasNext && photos.length > 0 && (
<p className="masonry-end"> {photos.length} frames developed </p>
)}
</div>
);
}

View File

@@ -0,0 +1,106 @@
/* ── MiniMap ── */
.minimap-wrapper {
width: 100%;
}
/* toolbar */
.minimap-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.minimap-toggle-btn,
.minimap-clear-btn {
background: var(--tv-surface);
color: var(--tv-muted);
border: 1px solid var(--tv-line-bright);
border-radius: var(--tv-r-sm);
padding: 5px 14px;
font: 11px var(--tv-mono);
letter-spacing: 0.04em;
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
}
.minimap-toggle-btn:hover,
.minimap-clear-btn:hover {
color: var(--tv-text);
border-color: var(--tv-accent);
}
/* container */
.minimap-container {
position: relative;
height: var(--minimap-h, 200px);
border-radius: var(--tv-r-lg);
border: 1px solid var(--tv-line-bright);
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.35);
overflow: hidden;
transition: height 0.35s ease, opacity 0.35s ease;
opacity: 1;
}
.minimap-collapsed {
height: 0 !important;
opacity: 0;
pointer-events: none;
border: none;
box-shadow: none;
}
/* leaflet overrides */
.minimap-leaflet {
background: var(--tv-bg);
}
.minimap-leaflet .leaflet-tile-pane {
filter: brightness(0.7) saturate(0.4);
}
/* hint overlay */
.minimap-hint {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 800;
font: 10px var(--tv-mono);
letter-spacing: 0.18em;
color: var(--tv-dim);
background: rgba(15, 12, 9, 0.65);
padding: 4px 14px;
border-radius: var(--tv-r-sm);
pointer-events: none;
}
/* tooltip */
.minimap-tooltip {
background: var(--tv-surface) !important;
color: var(--tv-text) !important;
border: 1px solid var(--tv-line-bright) !important;
border-radius: var(--tv-r-sm) !important;
font: 11px var(--tv-mono) !important;
padding: 3px 10px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4) !important;
}
.minimap-tooltip::before {
border-top-color: var(--tv-surface) !important;
}
/* mobile */
@media (max-width: 768px) {
.minimap-container {
height: 150px;
}
}
/* reduced motion */
@media (prefers-reduced-motion: reduce) {
.minimap-container {
transition: none;
}
}

View File

@@ -0,0 +1,166 @@
import React, { useState, useCallback } from 'react';
import { MapContainer, TileLayer, GeoJSON, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { useIsMobile } from '../../hooks/useIsMobile';
import './MiniMap.css';
/* ─────────────────────────────────────────────
Region accent palette
───────────────────────────────────────────── */
export const REGION_PALETTE = {
japan: '#e05c4b',
korea: '#d64f6e',
china: '#c84b3a',
europe: '#5b8fc4',
france: '#6f8fc4',
italy: '#78a46e',
spain: '#c4844a',
sea: '#4aad8b',
thailand: '#4aad8b',
vietnam: '#5faa78',
bali: '#7aac5a',
indonesia: '#8aaa4a',
america: '#b4885c',
usa: '#b4885c',
canada: '#6a9890',
africa: '#c47c3c',
middle: '#c4a24a',
dubai: '#c4a24a',
default: '#c8905e',
};
export function getRegionAccent(regionId = '') {
const id = regionId.toLowerCase();
for (const [key, color] of Object.entries(REGION_PALETTE)) {
if (key !== 'default' && id.includes(key)) return color;
}
return REGION_PALETTE.default;
}
/* ─────────────────────────────────────────────
MapLayer — internal component
───────────────────────────────────────────── */
function MapLayer({ geojson, selectedRegionId, onSelectRegion }) {
const map = useMap();
const style = useCallback(
(feature) => {
const rid = feature.properties?.id || feature.properties?.name || '';
const isSelected =
selectedRegionId && rid.toLowerCase() === selectedRegionId.toLowerCase();
const accent = getRegionAccent(rid);
return {
fillColor: isSelected ? accent : 'rgba(232,221,208,0.12)',
fillOpacity: isSelected ? 0.45 : 0.18,
color: isSelected ? accent : 'rgba(232,221,208,0.25)',
weight: isSelected ? 2.5 : 1,
};
},
[selectedRegionId],
);
const onEachFeature = useCallback(
(feature, layer) => {
const name =
feature.properties?.name_ko ||
feature.properties?.name ||
feature.properties?.id ||
'';
if (name) {
layer.bindTooltip(name, {
className: 'minimap-tooltip',
sticky: true,
});
}
layer.on('click', () => {
const rid = feature.properties?.id || feature.properties?.name || '';
onSelectRegion(rid);
const bounds = layer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
}
});
},
[map, onSelectRegion],
);
if (!geojson) return null;
return (
<GeoJSON
key={selectedRegionId || '__all__'}
data={geojson}
style={style}
onEachFeature={onEachFeature}
/>
);
}
/* ─────────────────────────────────────────────
MiniMap
───────────────────────────────────────────── */
export default function MiniMap({
geojson,
selectedRegionId,
onSelectRegion,
onClearRegion,
}) {
const [expanded, setExpanded] = useState(true);
const isMobile = useIsMobile();
const toggleExpanded = () => setExpanded((v) => !v);
return (
<div className="minimap-wrapper">
{/* toolbar */}
<div className="minimap-toolbar">
<button
className="minimap-toggle-btn"
onClick={toggleExpanded}
aria-label={expanded ? '지도 접기' : '지도 펼치기'}
>
{expanded ? '▲ 지도 접기' : '▼ 지도 펼치기'}
</button>
{selectedRegionId && (
<button className="minimap-clear-btn" onClick={onClearRegion}>
전체 보기
</button>
)}
</div>
{/* map container */}
<div
className={`minimap-container${expanded ? '' : ' minimap-collapsed'}`}
style={{
'--minimap-h': isMobile ? '150px' : '200px',
}}
>
<MapContainer
center={[30, 125]}
zoom={2}
minZoom={2}
maxZoom={7}
zoomControl={false}
attributionControl={false}
className="minimap-leaflet"
style={{ width: '100%', height: '100%' }}
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution=""
/>
<MapLayer
geojson={geojson}
selectedRegionId={selectedRegionId}
onSelectRegion={onSelectRegion}
/>
</MapContainer>
{!selectedRegionId && expanded && (
<div className="minimap-hint">CLICK A REGION</div>
)}
</div>
</div>
);
}

View File

@@ -166,73 +166,16 @@
} }
/* ═══════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════
MAP SECTION ALBUMS SECTION — card grid
═══════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════ */
.tv-map-section { .tv-albums {
min-height: 120px;
}
.tv-albums__grid {
display: grid; display: grid;
gap: 28px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
transition: opacity 0.35s ease; gap: 20px;
}
.tv-map-section.is-dimmed {
opacity: 0.3;
pointer-events: none;
}
.tv-map-wrap {
position: relative;
border-radius: var(--tv-r-lg);
overflow: hidden;
border: 1px solid var(--tv-line-bright);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
}
.tv-map {
width: 100%;
height: 480px;
}
@media (max-width: 768px) {
.tv-map {
height: 300px;
}
}
/* Leaflet map tooltip override */
.map-tooltip {
font-family: var(--tv-mono) !important;
font-size: 10px !important;
letter-spacing: 0.12em !important;
text-transform: uppercase !important;
background: rgba(15, 12, 9, 0.92) !important;
border: 1px solid rgba(232, 221, 208, 0.2) !important;
border-radius: 6px !important;
color: #e8ddd0 !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5) !important;
}
.map-tooltip::before {
border-top-color: rgba(232, 221, 208, 0.15) !important;
}
/* Map overlay hint */
.tv-map__overlay-hint {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(15, 12, 9, 0.85);
border: 1px solid rgba(232, 221, 208, 0.2);
border-radius: 999px;
padding: 7px 18px;
pointer-events: none;
}
.tv-map__overlay-hint span {
font-family: var(--tv-mono);
font-size: 9px;
letter-spacing: 0.24em;
color: var(--tv-muted);
} }
/* ── Loading / Error states ──────────────────────── */ /* ── Loading / Error states ──────────────────────── */
@@ -286,693 +229,16 @@
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
/* ═══════════════════════════════════════════════════
ALBUM HEADER
═══════════════════════════════════════════════════ */
.tv-album-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--tv-line);
}
.tv-album-header__left {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 14px;
}
.tv-album-header__region {
font-family: var(--tv-serif);
font-size: 24px;
font-weight: 600;
letter-spacing: -0.01em;
}
.tv-album-header__albums {
font-family: var(--tv-mono);
font-size: 10px;
color: var(--tv-muted);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.tv-album-header__count {
font-family: var(--tv-mono);
font-size: 11px;
color: var(--tv-dim);
letter-spacing: 0.12em;
flex-shrink: 0;
}
/* ═══════════════════════════════════════════════════
PHOTO MOSAIC — 4-column editorial grid
═══════════════════════════════════════════════════ */
.photo-mosaic {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 240px;
grid-auto-flow: dense;
gap: 6px;
}
@media (max-width: 1024px) {
.photo-mosaic {
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 200px;
}
}
@media (max-width: 640px) {
.photo-mosaic {
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 180px;
gap: 4px;
}
}
/* ═══════════════════════════════════════════════════
PHOTO CARD
═══════════════════════════════════════════════════ */
.photo-card {
position: relative;
overflow: hidden;
border-radius: var(--tv-r-sm);
cursor: pointer;
background: var(--tv-surface);
/* Scroll-reveal */
opacity: 0;
transform: scale(0.97) translateY(10px);
transition:
opacity 0.5s ease,
transform 0.5s ease,
box-shadow 0.25s ease;
transition-delay: var(--reveal-delay, 0ms);
}
.photo-card[data-revealed='true'] {
opacity: 1;
transform: scale(1) translateY(0);
}
/* Layout variants */
.photo-card--hero {
grid-column: span 2;
grid-row: span 2;
}
.photo-card--tall {
grid-row: span 2;
}
.photo-card--wide {
grid-column: span 2;
}
/* Image */
.photo-card img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.6s cubic-bezier(0.25, 0, 0, 1), filter 0.4s ease;
filter: saturate(0.85) brightness(0.92);
}
.photo-card:hover img {
transform: scale(1.04);
filter: saturate(1) brightness(1);
}
/* Hover overlay */
.photo-card__overlay {
position: absolute;
inset: 0;
background: linear-gradient(
160deg,
rgba(15, 12, 9, 0) 40%,
rgba(15, 12, 9, 0.75) 100%
);
opacity: 0;
transition: opacity 0.3s ease;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 14px;
}
.photo-card:hover .photo-card__overlay {
opacity: 1;
}
.photo-card__overlay-inner {
display: flex;
flex-direction: column;
gap: 3px;
}
.photo-card__index {
font-family: var(--tv-mono);
font-size: 9px;
letter-spacing: 0.2em;
color: var(--accent, var(--tv-accent));
}
.photo-card__label {
font-family: var(--tv-mono);
font-size: 10px;
color: rgba(232, 221, 208, 0.85);
margin: 0;
letter-spacing: 0.06em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* Decorative print-border effect */
.photo-card__frame {
position: absolute;
inset: 0;
border-radius: var(--tv-r-sm);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
pointer-events: none;
transition: box-shadow 0.3s ease;
}
.photo-card:hover .photo-card__frame {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
}
.photo-card:focus-visible {
outline: 2px solid var(--tv-accent);
outline-offset: 2px;
}
/* ═══════════════════════════════════════════════════
MOSAIC FOOTER — sentinel + end message
═══════════════════════════════════════════════════ */
.mosaic-footer {
display: flex;
justify-content: center;
align-items: center;
padding: 24px 0 8px;
min-height: 48px;
grid-column: 1 / -1;
}
.mosaic-loading {
display: flex;
gap: 8px;
}
.mosaic-loading__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--tv-accent);
animation: tv-pulse 1.2s ease-in-out infinite;
}
.mosaic-loading__dot:nth-child(2) { animation-delay: 0.2s; }
.mosaic-loading__dot:nth-child(3) { animation-delay: 0.4s; }
.mosaic-end {
font-family: var(--tv-mono);
font-size: 10px;
letter-spacing: 0.22em;
color: var(--tv-dim);
text-transform: uppercase;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.mosaic-end span {
color: var(--tv-line-bright);
}
/* ═══════════════════════════════════════════════════
FILM STRIP — thumbnail rail
═══════════════════════════════════════════════════ */
.filmstrip {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: stretch;
gap: 0;
background: #0a0806;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--tv-line);
}
.filmstrip__nav {
width: 32px;
background: rgba(15, 12, 9, 0.9);
border: none;
color: var(--tv-muted);
font-size: 22px;
cursor: pointer;
transition: color 0.2s ease, background 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.filmstrip__nav:hover {
color: var(--tv-text);
background: rgba(15, 12, 9, 0.6);
}
.filmstrip__rail {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* Perforation strip */
.filmstrip__holes {
display: flex;
flex-direction: row;
gap: 0;
padding: 5px 8px;
background: #0a0806;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.filmstrip__hole {
width: 10px;
height: 8px;
flex-shrink: 0;
margin-right: 14px;
border-radius: 2px;
background: var(--tv-surface);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
}
/* Thumbnail frames */
.filmstrip__frames {
display: flex;
gap: 3px;
padding: 5px 8px;
overflow-x: auto;
scroll-behavior: smooth;
scrollbar-width: none;
}
.filmstrip__frames::-webkit-scrollbar {
display: none;
}
.filmstrip__frame {
position: relative;
width: 68px;
height: 52px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--tv-surface-2);
padding: 0;
cursor: pointer;
flex-shrink: 0;
overflow: hidden;
transition: border-color 0.2s ease, transform 0.2s ease;
}
.filmstrip__frame img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: saturate(0.7);
transition: filter 0.2s ease;
}
.filmstrip__frame:hover img,
.filmstrip__frame.is-active img {
filter: saturate(1);
}
.filmstrip__frame:hover {
transform: scale(1.06);
border-color: rgba(255, 255, 255, 0.4);
}
.filmstrip__frame.is-active {
border-color: var(--tv-accent);
box-shadow: 0 0 0 1px var(--tv-accent);
}
.filmstrip__frame-num {
position: absolute;
bottom: 2px;
right: 3px;
font-family: var(--tv-mono);
font-size: 7px;
color: rgba(232, 221, 208, 0.6);
letter-spacing: 0.06em;
pointer-events: none;
line-height: 1;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
/* ═══════════════════════════════════════════════════
LIGHTBOX — cinematic full-screen viewer
═══════════════════════════════════════════════════ */
.lightbox {
position: fixed;
inset: 0;
background: rgba(10, 8, 6, 0.9);
backdrop-filter: blur(var(--lb-blur, 6px));
-webkit-backdrop-filter: blur(var(--lb-blur, 6px));
z-index: 3000;
display: grid;
place-items: center;
}
.lightbox__inner {
width: min(1280px, 98vw);
max-height: 100dvh;
display: grid;
grid-template-rows: auto 1fr auto auto auto;
gap: 0;
overflow: hidden;
}
/* ── Top bar ──────────────────────────────────────── */
.lightbox__topbar {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 16px;
padding: 14px 20px;
border-bottom: 1px solid var(--tv-line);
background: rgba(10, 8, 6, 0.7);
}
.lightbox__counter {
display: flex;
align-items: baseline;
gap: 4px;
font-family: var(--tv-mono);
}
.lightbox__counter-current {
font-size: 22px;
font-weight: 400;
line-height: 1;
}
.lightbox__counter-sep {
font-size: 12px;
color: var(--tv-line-bright);
}
.lightbox__counter-total {
font-size: 12px;
color: var(--tv-muted);
}
.lightbox__region {
display: flex;
align-items: center;
gap: 8px;
}
.lightbox__region-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent, var(--tv-accent));
flex-shrink: 0;
}
.lightbox__region-name {
font-family: var(--tv-serif);
font-size: 15px;
font-weight: 600;
color: var(--tv-text);
letter-spacing: 0.02em;
}
.lightbox__album {
font-family: var(--tv-mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--tv-muted);
padding-left: 10px;
border-left: 1px solid var(--tv-line-bright);
margin-left: 2px;
}
.lightbox__controls {
display: flex;
align-items: center;
gap: 12px;
}
.lb-control {
display: flex;
align-items: center;
gap: 7px;
font-family: var(--tv-mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--tv-muted);
cursor: pointer;
}
.lb-control input[type='range'] {
appearance: none;
-webkit-appearance: none;
width: 100px;
height: 3px;
background: rgba(232, 221, 208, 0.15);
border-radius: 999px;
outline: none;
}
.lb-control input[type='range']::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--tv-text);
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.4);
}
.lb-control input[type='range']::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--tv-text);
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.4);
}
.lb-control__val {
font-size: 9px;
min-width: 16px;
text-align: right;
}
.lightbox__close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid rgba(232, 221, 208, 0.18);
background: rgba(15, 12, 9, 0.8);
color: var(--tv-text);
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
flex-shrink: 0;
}
.lightbox__close:hover {
border-color: rgba(232, 221, 208, 0.5);
background: rgba(232, 221, 208, 0.08);
}
/* ── Photo stage ──────────────────────────────────── */
.lightbox__stage {
display: grid;
grid-template-columns: 56px 1fr 56px;
align-items: center;
gap: 0;
min-height: 0;
padding: 12px 0;
}
.lightbox__frame {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: clamp(300px, 58vh, 700px);
overflow: hidden;
}
.lightbox__photo {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 4px;
display: block;
}
.lightbox__photo.slide-next {
animation: lb-slide-in-right 280ms cubic-bezier(0.25, 0, 0.25, 1) forwards;
}
.lightbox__photo.slide-prev {
animation: lb-slide-in-left 280ms cubic-bezier(0.25, 0, 0.25, 1) forwards;
}
@keyframes lb-slide-in-right {
from { opacity: 0; transform: translateX(24px) scale(0.98); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes lb-slide-in-left {
from { opacity: 0; transform: translateX(-24px) scale(0.98); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
/* Decorative film frame border */
.lightbox__photo-frame {
position: absolute;
inset: 0;
pointer-events: none;
border-radius: 4px;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
0 2px 24px rgba(0, 0, 0, 0.5);
}
/* Navigation arrows */
.lightbox__arrow {
width: 44px;
height: 44px;
border-radius: 12px;
border: 1px solid rgba(232, 221, 208, 0.18);
background: rgba(15, 12, 9, 0.85);
color: var(--tv-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
justify-self: center;
position: relative;
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
.lightbox__arrow:hover {
border-color: rgba(232, 221, 208, 0.45);
background: rgba(232, 221, 208, 0.06);
transform: scale(1.05);
}
.lightbox__arrow:disabled {
opacity: 0.25;
cursor: not-allowed;
transform: none;
}
.lightbox__arrow.is-loading {
pointer-events: none;
}
.lightbox__spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(232, 221, 208, 0.25);
border-top-color: var(--tv-accent);
animation: tv-spin 0.7s linear infinite;
}
@keyframes tv-spin {
to { transform: rotate(360deg); }
}
/* Photo meta */
.lightbox__meta {
padding: 6px 20px;
font-family: var(--tv-mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--tv-muted);
margin: 0;
border-top: 1px solid var(--tv-line);
}
.lightbox__meta span {
color: var(--tv-dim);
}
/* Toast */
.lightbox__toast {
position: absolute;
left: 20px;
bottom: 16px;
background: rgba(15, 12, 9, 0.92);
border: 1px solid rgba(232, 221, 208, 0.2);
border-radius: 999px;
padding: 7px 14px;
font-family: var(--tv-mono);
font-size: 10px;
letter-spacing: 0.12em;
color: var(--tv-text);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
pointer-events: none;
animation: lb-toast-in 0.22s ease;
}
@keyframes lb-toast-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* ═══════════════════════════════════════════════════
SCROLL REVEAL
═══════════════════════════════════════════════════ */
[data-reveal] {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
[data-reveal][data-revealed='true'] {
opacity: 1;
transform: translateY(0);
}
/* ═══════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════
RESPONSIVE RESPONSIVE
═══════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════ */
@media (max-width: 900px) { @media (max-width: 768px) {
.tv-header { .tv-header {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 640px) { @media (max-width: 480px) {
.travel { .travel {
gap: 28px; gap: 28px;
} }
@@ -986,41 +252,9 @@
font-size: clamp(40px, 12vw, 60px); font-size: clamp(40px, 12vw, 60px);
} }
.lightbox__topbar { .tv-albums__grid {
grid-template-columns: auto 1fr auto; grid-template-columns: 1fr;
gap: 8px; gap: 14px;
padding: 10px 12px;
}
.lightbox__controls {
display: none;
}
.lightbox__stage {
grid-template-columns: 44px 1fr 44px;
padding: 6px 0;
}
.lightbox__frame {
height: clamp(240px, 50vh, 480px);
}
.filmstrip__frame {
width: 56px;
height: 44px;
}
.photo-mosaic {
grid-template-columns: repeat(2, 1fr);
}
.photo-card--hero {
grid-column: span 2;
grid-row: span 1;
}
.photo-card--wide {
grid-column: span 2;
} }
} }
@@ -1028,19 +262,8 @@
REDUCED MOTION REDUCED MOTION
═══════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.photo-card, .tv-state__loader span {
[data-reveal] {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
.lightbox__photo.slide-next,
.lightbox__photo.slide-prev {
animation: none !important; animation: none !important;
} opacity: 1 !important;
.photo-card img {
transition: none !important;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
/* ── VideoTab placeholder ── */
.video-tab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 12px;
padding: 40px 20px;
}
.video-tab__icon {
color: var(--tv-dim);
}
.video-tab__title {
margin: 0;
font: 600 20px/1.3 var(--tv-serif);
color: var(--tv-text);
}
.video-tab__desc {
margin: 0;
font: 11px var(--tv-mono);
letter-spacing: 0.06em;
color: var(--tv-muted);
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import './VideoTab.css';
export default function VideoTab() {
return (
<div className="video-tab">
<svg
className="video-tab__icon"
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect
x="4"
y="10"
width="30"
height="28"
rx="4"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M34 18l10-6v24l-10-6V18z"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
<circle cx="19" cy="24" r="6" stroke="currentColor" strokeWidth="2" />
<path
d="M17 24l4-2.5v5L17 24z"
fill="currentColor"
/>
</svg>
<h2 className="video-tab__title">영상 기능 준비 </h2>
<p className="video-tab__desc">
여행 영상을 감상할 있는 기능이 추가됩니다.
</p>
</div>
);
}

View File

@@ -0,0 +1,368 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/* ─────────────────────────────────────────────
Constants
───────────────────────────────────────────── */
const PAGE_SIZE = 20;
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
/* ─────────────────────────────────────────────
Utility — normalise raw API items to a
consistent photo shape
───────────────────────────────────────────── */
export const normalizePhotos = (items = []) =>
items
.map((item) => {
if (typeof item === 'string') return { src: item, title: '', original: item, file: '', album: '' };
if (!item) return null;
return {
src: item.thumb || item.url || item.path || item.src || '',
title: item.title || item.name || item.file || '',
original: item.url || item.path || item.src || '',
file: item.file || '',
album: item.album || '',
};
})
.filter((item) => item && item.src);
/* ─────────────────────────────────────────────
Internal helper — parse fetch JSON to
normalised photo list + summary metadata
───────────────────────────────────────────── */
const parsePhotoResponse = (json) => {
const items = Array.isArray(json) ? json : json.items ?? [];
const meta = Array.isArray(json) ? {} : json ?? {};
const normalized = normalizePhotos(items);
const hasNext =
typeof meta.has_next === 'boolean'
? meta.has_next
: typeof meta.hasNext === 'boolean'
? meta.hasNext
: normalized.length >= PAGE_SIZE;
const summary =
meta && (Object.prototype.hasOwnProperty.call(meta, 'total') ||
Object.prototype.hasOwnProperty.call(meta, 'matched_albums'))
? { total: meta.total, albums: meta.matched_albums ?? [] }
: null;
return { normalized, hasNext, summary, matchedAlbums: meta.matched_albums ?? [] };
};
/* ─────────────────────────────────────────────
useTravelData — data layer hook for the
Travel gallery page
───────────────────────────────────────────── */
const useTravelData = () => {
// ── Region & GeoJSON ─────────────────────
const [regions, setRegions] = useState(null); // GeoJSON FeatureCollection
const [selectedRegion, setSelectedRegion] = useState(null); // { id, name }
// ── Album list ───────────────────────────
const [albums, setAlbums] = useState([]); // built from per-region page-1 fetch
const [loadingAlbums, setLoadingAlbums] = useState(false);
// ── Photo list for selected album ────────
const [photos, setPhotos] = useState([]);
const [photoSummary, setPhotoSummary] = useState(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasNext, setHasNext] = useState(false);
const [error, setError] = useState('');
// ── Internal refs ────────────────────────
const pageRef = useRef(1);
const currentAlbumRef = useRef(null); // { regionId, albumName }
const cacheRef = useRef(new Map()); // photo data cache key: `${regionId}::${albumName}`
const albumCacheRef = useRef(new Map()); // album metadata cache key: regionId
const loadAbortRef = useRef(null); // AbortController for loadAlbumPhotos
/* ── Load GeoJSON regions once ──────────── */
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const res = await fetch('/api/travel/regions', { signal: controller.signal });
if (!res.ok) throw new Error(`지역 정보 로딩 실패 (${res.status})`);
const geojson = await res.json();
setRegions(geojson);
} catch (err) {
if (err?.name !== 'AbortError') {
setError(err?.message ?? String(err));
}
}
})();
return () => controller.abort();
}, []);
/* ── Build album list when regions arrive ── */
useEffect(() => {
if (!regions?.features?.length) return;
const controller = new AbortController();
(async () => {
setLoadingAlbums(true);
const builtAlbums = [];
for (const feature of regions.features) {
if (controller.signal.aborted) break;
const regionId = feature?.properties?.id;
const regionName = feature?.properties?.name || regionId || '';
if (!regionId) continue;
// Use cached album metadata if fresh
const cached = albumCacheRef.current.get(regionId);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
builtAlbums.push(...cached.albums);
continue;
}
try {
const res = await fetch(
`/api/travel/photos?region=${encodeURIComponent(regionId)}&page=1&size=${PAGE_SIZE}`,
{ signal: controller.signal }
);
if (!res.ok) continue; // skip failed regions silently
const json = await res.json();
const { normalized, matchedAlbums } = parsePhotoResponse(json);
const regionAlbums = matchedAlbums.map((ma) => {
// Find first photo that belongs to this album for coverThumb
const cover = normalized.find((p) => p.album === ma.album);
return {
id: `${regionId}::${ma.album}`,
name: ma.album,
region: regionId,
regionName,
photoCount: ma.count ?? 0,
coverThumb: cover?.src || '',
};
});
// If API returned no matched_albums, create a single implicit album
if (regionAlbums.length === 0 && normalized.length > 0) {
regionAlbums.push({
id: `${regionId}::`,
name: regionName,
region: regionId,
regionName,
photoCount: normalized.length,
coverThumb: normalized[0]?.src || '',
});
}
albumCacheRef.current.set(regionId, {
timestamp: Date.now(),
albums: regionAlbums,
});
builtAlbums.push(...regionAlbums);
} catch (err) {
if (err?.name === 'AbortError') break;
// Non-fatal — continue with other regions
}
}
if (!controller.signal.aborted) {
setAlbums(builtAlbums);
setLoadingAlbums(false);
}
})();
return () => controller.abort();
}, [regions]);
/* ── loadAlbumPhotos — initial load ────── */
const loadAlbumPhotos = useCallback(async (regionId, albumName) => {
if (!regionId) return;
const cacheKey = `${regionId}::${albumName ?? ''}`;
currentAlbumRef.current = { regionId, albumName };
// Check photo cache
const cached = cacheRef.current.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
setPhotos(cached.items);
setPhotoSummary(cached.summary ?? null);
pageRef.current = cached.page ?? 2;
setHasNext(cached.hasNext ?? false);
setLoading(false);
setLoadingMore(false);
setError('');
return;
}
setLoading(true);
setLoadingMore(false);
setError('');
setPhotos([]);
setPhotoSummary(null);
setHasNext(false);
pageRef.current = 1;
// Abort any in-flight loadAlbumPhotos request
if (loadAbortRef.current) loadAbortRef.current.abort();
const controller = new AbortController();
loadAbortRef.current = controller;
try {
let url = `/api/travel/photos?region=${encodeURIComponent(regionId)}&page=1&size=${PAGE_SIZE}`;
if (albumName) url += `&album=${encodeURIComponent(albumName)}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`);
const json = await res.json();
const { normalized, hasNext: hn, summary } = parsePhotoResponse(json);
// Filter by album name client-side when API doesn't support album param
const filtered = albumName
? normalized.filter((p) => !p.album || p.album === albumName)
: normalized;
pageRef.current = 2;
setPhotos(filtered);
setPhotoSummary(summary);
setHasNext(hn);
cacheRef.current.set(cacheKey, {
timestamp: Date.now(),
items: filtered,
page: 2,
hasNext: hn,
summary,
});
} catch (err) {
if (err?.name === 'AbortError') return;
setError(err?.message ?? String(err));
setPhotos([]);
setPhotoSummary(null);
} finally {
setLoading(false);
}
}, []);
/* ── loadMorePhotos — infinite scroll ──── */
const loadMorePhotos = useCallback(async (regionId, albumName) => {
const activeRegion = regionId ?? currentAlbumRef.current?.regionId;
const activeAlbum = albumName ?? currentAlbumRef.current?.albumName;
if (!activeRegion || loading || loadingMore || !hasNext) return;
setLoadingMore(true);
setError('');
const moreController = new AbortController();
try {
let url = `/api/travel/photos?region=${encodeURIComponent(activeRegion)}&page=${pageRef.current}&size=${PAGE_SIZE}`;
if (activeAlbum) url += `&album=${encodeURIComponent(activeAlbum)}`;
const res = await fetch(url, { signal: moreController.signal });
if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`);
const json = await res.json();
const { normalized, hasNext: hn, summary } = parsePhotoResponse(json);
// Filter by album name client-side
const filtered = activeAlbum
? normalized.filter((p) => !p.album || p.album === activeAlbum)
: normalized;
const cacheKey = `${activeRegion}::${activeAlbum ?? ''}`;
setPhotos((prev) => {
const merged = [...prev, ...filtered];
cacheRef.current.set(cacheKey, {
timestamp: Date.now(),
items: merged,
page: pageRef.current + 1,
hasNext: hn,
summary: photoSummary ?? summary,
});
return merged;
});
if (!photoSummary && summary) setPhotoSummary(summary);
setHasNext(hn);
pageRef.current += 1;
} catch (err) {
setError(err?.message ?? String(err));
} finally {
setLoadingMore(false);
}
}, [hasNext, loading, loadingMore, photoSummary]);
/* ── reloadAlbumPhotos — pull-to-refresh ─ */
const reloadAlbumPhotos = useCallback(async (regionId, albumName) => {
const activeRegion = regionId ?? currentAlbumRef.current?.regionId;
const activeAlbum = albumName ?? currentAlbumRef.current?.albumName;
if (!activeRegion) return;
const cacheKey = `${activeRegion}::${activeAlbum ?? ''}`;
cacheRef.current.delete(cacheKey);
let url = `/api/travel/photos?region=${encodeURIComponent(activeRegion)}&page=1&size=${PAGE_SIZE}`;
if (activeAlbum) url += `&album=${encodeURIComponent(activeAlbum)}`;
const reloadController = new AbortController();
try {
const res = await fetch(url, { signal: reloadController.signal });
if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`);
const json = await res.json();
const { normalized, hasNext: hn, summary } = parsePhotoResponse(json);
const filtered = activeAlbum
? normalized.filter((p) => !p.album || p.album === activeAlbum)
: normalized;
pageRef.current = 2;
setPhotos(filtered);
setHasNext(hn);
cacheRef.current.set(cacheKey, {
timestamp: Date.now(),
items: filtered,
page: 2,
hasNext: hn,
summary,
});
if (summary) setPhotoSummary(summary);
} catch (err) {
if (err?.name === 'AbortError') return;
setError(err?.message ?? String(err));
}
}, []);
/* ── getFilteredAlbums — filter by region ─ */
const getFilteredAlbums = useCallback(
(regionId) => {
if (!regionId) return albums;
return albums.filter((a) => a.region === regionId);
},
[albums]
);
return {
// GeoJSON data
regions,
// Album list
albums,
loadingAlbums,
// Region filter
selectedRegion,
setSelectedRegion,
// Photo data
photos,
photoSummary,
// Loading states
loading,
loadingMore,
// Error
error,
// Pagination
hasNext,
// Actions
loadAlbumPhotos,
loadMorePhotos,
reloadAlbumPhotos,
getFilteredAlbums,
};
};
export default useTravelData;