Compare commits
82 Commits
feat/agent
...
bebd55874c
| Author | SHA1 | Date | |
|---|---|---|---|
| bebd55874c | |||
| 6cbdf95596 | |||
| 3e4f2e0934 | |||
| 31fc2dfb0d | |||
| 403046c4d0 | |||
| b03f438935 | |||
| 22a37cf6d9 | |||
| 6bd6cbd635 | |||
| 4c930c2cf8 | |||
| efeecadbef | |||
| a712a2f43b | |||
| ce245609f9 | |||
| 43904d033a | |||
| 379ad41e32 | |||
| f3de315272 | |||
| 71fe91cc85 | |||
| 7dd2cc9793 | |||
| f01a432329 | |||
| d4279f2e3b | |||
| 8207205418 | |||
| 95b3f2b37c | |||
| eab8ef295b | |||
| f11f9c529e | |||
| d24c04f9fa | |||
| b7ee9fe3fd | |||
| b8eb290e4d | |||
| fba101500e | |||
| 9b8daeffa4 | |||
| 59bb05ba22 | |||
| 093ca6635a | |||
| 047e15cad3 | |||
| d6ace70bff | |||
| 27dca3df69 | |||
| 439844cd14 | |||
| 085481e104 | |||
| f9495f0c30 | |||
| 4655e9ab3b | |||
| 5efb9525d5 | |||
| 201601dc95 | |||
| 1072a5eb21 | |||
| c9df3e0e88 | |||
| 6ef687378d | |||
| ca9929faac | |||
| 0198fec43c | |||
| 901cfd7e1b | |||
| c7cad9da61 | |||
| 28a80b5bd7 | |||
| 00f8e00436 | |||
| 326d54c73f | |||
| 5c10952e39 | |||
| 2b826ed700 | |||
| d5ef77ad17 | |||
| 033b89f87d | |||
| e7427ff1d5 | |||
| fd13f65faa | |||
| 2c2011659a | |||
| 0922261c74 | |||
| d53108f1c9 | |||
| 80921563be | |||
| 6875a28e92 | |||
| 2db0c1b3eb | |||
| bce5ae9fac | |||
| a053cf2d71 | |||
| 08efaa722a | |||
| 2cdecd918e | |||
| 1e60524cfc | |||
| 75d1558508 | |||
| 188a714372 | |||
| 064c983ca1 | |||
| bf1c23e66a | |||
| a922dd12c0 | |||
| 1344967118 | |||
| 2840ad7df6 | |||
| ad0a123d0f | |||
| 18d2cd5a51 | |||
| 104a34912f | |||
| be46da0a1f | |||
| 6728b2269e | |||
| cfc45fc43f | |||
| a165d6271f | |||
| deb285695a | |||
| 25715a2198 |
85
CLAUDE.md
85
CLAUDE.md
@@ -25,6 +25,8 @@
|
||||
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
|
||||
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
||||
| `/todo` | `Todo` | 태스크 보드 |
|
||||
| `/blog-lab` | `BlogMarketing` | 블로그 마케팅 수익화 대시보드 |
|
||||
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
|
||||
|
||||
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
||||
|
||||
@@ -99,6 +101,19 @@ proxy: {
|
||||
| AI 음악 | GET | `/api/music/status/:task_id` → `{ status, progress, message, audio_url?, error?, track? }` |
|
||||
| AI 음악 라이브러리 | GET/POST | `/api/music/library` — response: `{ tracks: [...] }` |
|
||||
| AI 음악 라이브러리 | DELETE | `/api/music/library/:id` |
|
||||
| 여행 | GET | `/api/travel/regions`, `/api/travel/albums`, `/api/travel/photos` |
|
||||
| 여행 | POST | `/api/travel/sync` |
|
||||
| 여행 | PUT | `/api/travel/albums/:album/cover`, `/api/travel/albums/:album/region` |
|
||||
| 여행 | PUT | `/api/travel/regions/:id` |
|
||||
| 블로그마케팅 | POST | `/api/blog-marketing/research`, `/api/blog-marketing/generate` |
|
||||
| 블로그마케팅 | GET | `/api/blog-marketing/posts`, `/api/blog-marketing/dashboard` |
|
||||
| 블로그마케팅 | POST | `/api/blog-marketing/market/:id`, `/api/blog-marketing/review/:id` |
|
||||
| 에이전트 | GET | `/api/agent-office/agents`, `/api/agent-office/states` |
|
||||
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
|
||||
| 에이전트 | WS | `/api/agent-office/ws` |
|
||||
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
||||
| 부동산 | PUT | `/api/realestate/profile` |
|
||||
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
|
||||
|
||||
---
|
||||
|
||||
@@ -222,7 +237,32 @@ handleGenerate()
|
||||
|
||||
## 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 | 설명 |
|
||||
|------|-----|------|
|
||||
@@ -235,9 +275,46 @@ handleGenerate()
|
||||
|
||||
## Travel 갤러리 (`/travel`)
|
||||
|
||||
- 테마: "Dark Room" (배경 `#0f0c09`, 서체 Cormorant Garamond + Space Mono)
|
||||
- 사진 URL: `/media/travel/...` 형식 → `vite.config.js` `/media` 프록시로 처리
|
||||
- 프로덕션 nginx에도 `location /media/` 프록시 블록 필요
|
||||
테마: "Dark Room" (배경 `#0f0c09`, 서체 Cormorant Garamond + Space Mono)
|
||||
|
||||
### 파일 구조
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `src/pages/travel/Travel.jsx` | 메인 페이지 — 앨범 카드 목록 + MiniMap |
|
||||
| `src/pages/travel/AlbumCard.jsx` | 앨범 썸네일 카드 (커버 이미지 + 사진 수) |
|
||||
| `src/pages/travel/AlbumDetail.jsx` | 앨범 상세 오버레이 — 사진/영상 탭 + 지역 편집 |
|
||||
| `src/pages/travel/MasonryGrid.jsx` | CSS columns 기반 Masonry 레이아웃 + 무한 스크롤 |
|
||||
| `src/pages/travel/HeroLightbox.jsx` | 전체화면 사진 뷰어 — 스와이프/키보드 네비게이션 |
|
||||
| `src/pages/travel/MiniMap.jsx` | 접이식 Leaflet 지도 — GeoJSON 지역 + 핀 마커 |
|
||||
| `src/pages/travel/RegionPinPicker.jsx` | 지도 핀 위치 지정 모달 (Leaflet 클릭 → 좌표 저장) |
|
||||
| `src/pages/travel/VideoTab.jsx` | 영상 탭 (준비 중) |
|
||||
|
||||
### 핵심 기능
|
||||
|
||||
- **지역 관리**: GeoJSON 기반 지역 선택 → 앨범 필터링 + 지역 변경 + 핀 좌표 지정
|
||||
- **앨범 카드**: 커버 사진, 지역 라벨, 사진 수 표시, 접근성 accent 색상
|
||||
- **Masonry 그리드**: 40장 단위 청크 로딩, IntersectionObserver 기반 무한 스크롤
|
||||
- **Lightbox**: 앨범 커버 지정, 스와이프/키보드 네비게이션, 추가 로딩 지원
|
||||
- **MiniMap**: Polygon(기존 지역) + CircleMarker(커스텀 핀) 이중 렌더링
|
||||
- **지역 편집**: AlbumDetail에서 인라인 편집 + 자동완성 + "위치 지정" 버튼
|
||||
|
||||
### API 연동
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/travel/regions` | GeoJSON (커스텀 지역 포함) |
|
||||
| GET | `/api/travel/photos?region=X&page=N&size=40` | 사진 페이지네이션 |
|
||||
| GET | `/api/travel/albums` | 앨범 목록 + cover + region |
|
||||
| POST | `/api/travel/sync` | 폴더 동기화 |
|
||||
| PUT | `/api/travel/albums/{album}/cover` | 커버 지정 |
|
||||
| PUT | `/api/travel/albums/{album}/region` | 지역 변경 |
|
||||
| PUT | `/api/travel/regions/{id}` | 핀 좌표 저장 |
|
||||
|
||||
### 미디어 URL
|
||||
- 사진: `/media/travel/{album}/{filename}`
|
||||
- 썸네일: `/media/travel/.thumb/{album}/{filename}`
|
||||
- `vite.config.js` `/media` 프록시로 처리, 프로덕션 nginx에서 직접 서빙
|
||||
|
||||
---
|
||||
|
||||
|
||||
138
README.md
138
README.md
@@ -1,6 +1,6 @@
|
||||
# Web UI
|
||||
|
||||
개인 대시보드 — 블로그, 로또, 주식, 부동산, 여행, 할 일 등을 한 곳에서 관리하는 개인 웹 UI입니다.
|
||||
개인 대시보드 — 블로그, 로또, 주식, 부동산, 여행, AI 음악, AI 에이전트, 할 일 등을 한 곳에서 관리하는 개인 웹 UI입니다.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
| 지도 | react-leaflet + Leaflet |
|
||||
| 차트 | Recharts |
|
||||
| 3D | Three.js |
|
||||
| 제스처 | react-swipeable |
|
||||
| 스타일 | 커스텀 CSS (CSS 변수 기반 사이버펑크 다크 테마) |
|
||||
| 배포 | Synology NAS (Docker + nginx 리버스 프록시) |
|
||||
|
||||
---
|
||||
|
||||
## 페이지 구성
|
||||
## 페이지 구성 (13개 라우트)
|
||||
|
||||
### Home (`/`)
|
||||
|
||||
@@ -39,15 +40,18 @@
|
||||
|
||||
---
|
||||
|
||||
### Lotto (`/lotto`)
|
||||
### Lotto (`/lotto`) — 14 컴포넌트
|
||||
|
||||
로또 번호 추천 및 통계 실험실.
|
||||
|
||||
- 최신 로또 당첨 결과 조회
|
||||
- **3탭 구조**: 이번 주 브리핑 / 분석·통계 / 구매·성과
|
||||
- AI 큐레이터 브리핑 (5세트 + 내러티브 + 토큰·비용 집계)
|
||||
- 가중치·최근 회차·회피 수 파라미터 기반 번호 추천
|
||||
- 프리셋으로 빠른 추천 생성
|
||||
- 추천 히스토리 목록 확인 및 삭제
|
||||
- 번호 원클릭 복사
|
||||
- 몬테카를로 시뮬레이션 최적 번호 표시
|
||||
- 전략 진화 (EMA+Softmax) 기반 메타 추천
|
||||
- 주간 리포트 + ConfidenceRing 시각화
|
||||
- 구매 이력 CRUD + 성과 통계 (수익률·당첨 현황)
|
||||
- 프리셋으로 빠른 추천 생성, 번호 원클릭 복사
|
||||
|
||||
---
|
||||
|
||||
@@ -60,65 +64,93 @@
|
||||
- 미국 10년물 금리, WTI/Brent 유가 등 매크로 지표
|
||||
- 국내 / 해외 주식 뉴스 탭 분류 및 필터링
|
||||
|
||||
### Stock Trade (`/stock/trade`)
|
||||
### Stock Trade (`/stock/trade`) — 7 컴포넌트
|
||||
|
||||
포트폴리오 관리 및 트레이딩 데스크.
|
||||
|
||||
- **포트폴리오 탭**: 보유 종목 수익률, 자산 구성 차트 (파이/바 차트)
|
||||
- **AI 탭**: AI 시장 분석 요약 및 투자 인사이트
|
||||
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이
|
||||
- 종목 추가/편집/삭제 CRUD
|
||||
- 현금 잔고(예수금) 관리, 브로커별 분리
|
||||
- **어드바이저 탭**: 투자 조언 및 리밸런싱 제안
|
||||
- 종목 추가/편집/삭제 CRUD, 현금 잔고(예수금) 관리
|
||||
- 매도 히스토리 드로어 (실현손익 추적)
|
||||
|
||||
---
|
||||
|
||||
### Realestate (`/realestate`)
|
||||
### Realestate (`/realestate`) — 2 섹션
|
||||
|
||||
부동산 청약 통합 관리 — 청약 대시보드와 관심 단지 정보 두 화면으로 구성.
|
||||
부동산 청약 통합 관리.
|
||||
|
||||
#### 청약 대시보드 (`/realestate`)
|
||||
|
||||
- **청약 목록 탭**: 관심 청약 카드 목록 + 상세 패널 (요건/일정/자금 섹션)
|
||||
- **일정 탭**: 전체 청약 일정 타임라인 (청약 → 계약 → 중도금 → 잔금)
|
||||
- **자금 탭**: 단지별 자금 계획 및 총합 분석
|
||||
- 가점 계산 엔진 (무주택기간 최대 32점, 부양가족 최대 35점, 통장기간 최대 17점 = 84점 만점)
|
||||
- 내 청약 조건 프로필 입력 및 단지별 요건 충족 여부 자동 비교
|
||||
- 관심 청약 카드 목록 + 상세 패널 (요건/일정/자금 섹션)
|
||||
- 전체 청약 일정 타임라인 (청약 → 계약 → 중도금 → 잔금)
|
||||
- 가점 계산 엔진 (무주택 32점 + 부양가족 35점 + 통장 17점 = 84점 만점)
|
||||
- 청약 유형 분류: 줍줍 / 특공 / 일반
|
||||
- API 미구현 시 localStorage fallback으로 데이터 유지
|
||||
|
||||
#### 부동산 정보 (`/realestate/property`)
|
||||
|
||||
- 관심 아파트 단지 카드 그리드 + 지도 통합 뷰 (react-leaflet)
|
||||
- 단지별 상태 마커: 청약예정 / 청약중 / 결과발표 / 완료
|
||||
- D-day 카운트다운 및 우선순위 배지
|
||||
- 평당가 비교 바 차트 (Recharts)
|
||||
- 일정 탭: 전체 단지 청약 일정 타임라인
|
||||
- 분석 탭: 단지별 평당가 비교표
|
||||
- 모달 기반 단지 추가/편집 (단지명, 주소, 좌표, 평형, 분양가, 네이버 부동산 URL)
|
||||
- 네이버 부동산 바로가기 링크 연동
|
||||
- 관심 아파트 단지 카드 그리드 + Leaflet 지도 통합 뷰
|
||||
- D-day 카운트다운, 평당가 비교 바 차트 (Recharts)
|
||||
- 모달 기반 단지 추가/편집, 네이버 부동산 바로가기 연동
|
||||
|
||||
---
|
||||
|
||||
### Travel (`/travel`)
|
||||
### Travel (`/travel`) — 8 컴포넌트
|
||||
|
||||
여행 사진 갤러리.
|
||||
여행 사진 갤러리 (Dark Room 테마).
|
||||
|
||||
- 지도 기반 지역 선택 (GeoJSON)
|
||||
- 선택 지역의 사진 목록 로딩 및 캐시
|
||||
- 스크롤 기반 이미지 추가 로딩 (chunked lazy load)
|
||||
- 썸네일 / 모달 뷰, 키보드 및 스와이프 네비게이션
|
||||
- 앨범 및 파일 메타 정보 표시
|
||||
- **MiniMap**: GeoJSON 기반 접이식 세계 지도 — Polygon(기존 지역) + CircleMarker(핀)
|
||||
- **AlbumCard**: 앨범 썸네일 카드 그리드 (커버 이미지 + 지역 라벨 + 사진 수)
|
||||
- **AlbumDetail**: 앨범 상세 오버레이 — 사진/영상 탭 + 지역 인라인 편집
|
||||
- **MasonryGrid**: CSS columns Masonry 레이아웃 + IntersectionObserver 무한 스크롤
|
||||
- **HeroLightbox**: 전체화면 사진 뷰어 — 스와이프/키보드 네비 + 앨범 커버 지정
|
||||
- **RegionPinPicker**: 커스텀 지역 좌표 지정 모달 (Leaflet 클릭 → 핀 저장)
|
||||
- 40장 단위 청크 로딩, PullToRefresh 지원
|
||||
|
||||
---
|
||||
|
||||
### Lab (`/lab`)
|
||||
### Music — Sonic Forge (`/lab/music`) — 8 컴포넌트
|
||||
|
||||
AI 음악 생성 스튜디오.
|
||||
|
||||
- 듀얼 프로바이더: Suno (보컬/가사) + 로컬 MusicGen (인스트루멘탈)
|
||||
- 장르/무드/악기/BPM/키/스케일 설정, 스타일 부스트
|
||||
- 생성 진행 폴링 (3초 간격), 라이브러리 자동 등록
|
||||
- 가사 관리 + 타임스탬프 동기 재생 (가라오케)
|
||||
- 커버 이미지 생성, WAV 변환, 12스템 분리
|
||||
- SonicRadar 시각 효과 + WaveformCanvas 오실로스코프
|
||||
|
||||
---
|
||||
|
||||
### Blog Marketing (`/blog-lab`)
|
||||
|
||||
AI 블로그 마케팅 자동화 대시보드.
|
||||
|
||||
- 키워드 리서치 (네이버 검색 + 상위 블로그 크롤링)
|
||||
- AI 글 생성 → 마케팅 강화 → 품질 리뷰 (6기준 x 10점)
|
||||
- 발행 관리 + 브랜드커넥트 링크 + 수익 추적
|
||||
- 비동기 작업 상태 폴링
|
||||
|
||||
---
|
||||
|
||||
### Agent Office (`/agent-office`) — 5 컴포넌트
|
||||
|
||||
AI 에이전트 가상 오피스.
|
||||
|
||||
- 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업 수행
|
||||
- WebSocket 실시간 상태 동기화 (에이전트 FSM: idle → working → reporting)
|
||||
- 에이전트별 명령 전송 + 작업 승인/거부
|
||||
- 채팅 패널 + 문서 패널
|
||||
|
||||
---
|
||||
|
||||
### Lab (`/lab`) — 3 컴포넌트
|
||||
|
||||
실험적 UI/UX 효과 테스트 공간.
|
||||
|
||||
- Three.js 기반 실시간 3D 파티클 애니메이션 (1,500개 오브젝트)
|
||||
- 호버 모드: 마우스 추적 및 자연스러운 흐름
|
||||
- 오빗 모드: 클릭 시 나선형 궤도 회전
|
||||
- 동적 스케일, 조명 효과
|
||||
- **SwordStream**: Three.js 1,500개 파티클 3D 애니메이션 (호버/오빗 모드)
|
||||
- **DayCalc**: 날짜 계산 유틸리티
|
||||
|
||||
---
|
||||
|
||||
@@ -129,7 +161,23 @@
|
||||
- 칸반 레이아웃: 할 일 → 진행 중 → 완료
|
||||
- 드래그 앤 드롭으로 상태 변경
|
||||
- 태스크 추가/삭제, 완료 항목 일괄 정리
|
||||
- 상태별 카운트 및 타임스탬프 표시
|
||||
|
||||
---
|
||||
|
||||
## 공통 컴포넌트 (`src/components/`)
|
||||
|
||||
| 컴포넌트 | 설명 |
|
||||
|----------|------|
|
||||
| `Navbar` | 상단 네비게이션 바 |
|
||||
| `BottomNav` | 모바일 하단 네비게이션 |
|
||||
| `PageHeader` | 페이지 헤더 + 브레드크럼 |
|
||||
| `SwipeableView` | 스와이프 탭 컨테이너 |
|
||||
| `PullToRefresh` | 풀투리프레시 제스처 |
|
||||
| `MobileSheet` | 모바일 바텀시트 모달 |
|
||||
| `FAB` | 플로팅 액션 버튼 |
|
||||
| `FearGreedGauge` | 공포·탐욕 게이지 |
|
||||
| `Loading` | 로딩 스피너 |
|
||||
| `Icons` | SVG 아이콘 라이브러리 |
|
||||
|
||||
---
|
||||
|
||||
@@ -161,5 +209,15 @@ NAS_SSH_TARGET=user@gahusb.synology.me NAS_SSH_PORT=22 npm run release:nas
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from './api';
|
||||
|
||||
apiGet('/api/stock/indices');
|
||||
apiPost('/api/subscription/items', { ... });
|
||||
apiPost('/api/travel/sync');
|
||||
```
|
||||
|
||||
## 프로젝트 통계
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 페이지 라우트 | 13개 |
|
||||
| JSX 컴포넌트 | 62+ |
|
||||
| 공통 컴포넌트 | 10개 |
|
||||
| API 헬퍼 함수 | 65+ |
|
||||
| 외부 라이브러리 | React, Router, Leaflet, Recharts, Three.js, react-swipeable |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"recharts": "^3.7.0",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
@@ -3088,6 +3089,15 @@
|
||||
"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": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"recharts": "^3.7.0",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
|
||||
13
src/App.css
13
src/App.css
@@ -62,6 +62,7 @@
|
||||
@media (max-width: 768px) {
|
||||
.site-main {
|
||||
padding: 16px;
|
||||
padding-bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,3 +492,15 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Navbar from './components/Navbar';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import PageHeader from './components/PageHeader';
|
||||
import Loading from './components/Loading';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Navbar />
|
||||
@@ -17,6 +21,7 @@ function App() {
|
||||
</React.Suspense>
|
||||
</main>
|
||||
</div>
|
||||
{isMobile && <BottomNav />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
38
src/api.js
38
src/api.js
@@ -588,3 +588,41 @@ export function deleteBrandLink(id) {
|
||||
return apiDelete(`/api/blog-marketing/links/${id}`);
|
||||
}
|
||||
|
||||
// ── Agent Office ──────────────────────────────────
|
||||
export const getAgents = () => apiGet('/api/agent-office/agents');
|
||||
export const getAgentDetail = (id) => apiGet(`/api/agent-office/agents/${id}`);
|
||||
export const updateAgentConfig = (id, body) => apiPut(`/api/agent-office/agents/${id}`, body);
|
||||
export const getAgentTasks = (id, limit=20) => apiGet(`/api/agent-office/agents/${id}/tasks?limit=${limit}`);
|
||||
export const getAgentLogs = (id, limit=50) => apiGet(`/api/agent-office/agents/${id}/logs?limit=${limit}`);
|
||||
export const getPendingTasks = () => apiGet('/api/agent-office/tasks/pending');
|
||||
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 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();
|
||||
}
|
||||
|
||||
|
||||
167
src/components/BottomNav.css
Normal file
167
src/components/BottomNav.css
Normal 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;
|
||||
}
|
||||
}
|
||||
114
src/components/BottomNav.jsx
Normal file
114
src/components/BottomNav.jsx
Normal 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
50
src/components/FAB.css
Normal 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
37
src/components/FAB.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
src/components/MobileSheet.css
Normal file
125
src/components/MobileSheet.css
Normal 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(20px + 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;
|
||||
}
|
||||
}
|
||||
113
src/components/MobileSheet.jsx
Normal file
113
src/components/MobileSheet.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -334,26 +334,6 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +1,58 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { navLinks } from '../routes.jsx';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import mainLogo from '../assets/main_logo.png';
|
||||
import './Navbar.css';
|
||||
|
||||
const Navbar = () => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const closeMenu = () => setMenuOpen(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = menuOpen ? 'hidden' : '';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [menuOpen]);
|
||||
// 모바일에서는 BottomNav가 대체하므로 사이드바 미렌더링
|
||||
if (isMobile) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 모바일 오버레이 */}
|
||||
<div
|
||||
className={`sidebar__overlay${menuOpen ? ' is-visible' : ''}`}
|
||||
onClick={closeMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* 모바일 토글 버튼 */}
|
||||
<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>
|
||||
<aside className="sidebar">
|
||||
<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 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" />
|
||||
|
||||
{/* 네비게이션 */}
|
||||
<nav className="sidebar__nav">
|
||||
<p className="sidebar__section-label">NAVIGATION</p>
|
||||
{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 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>
|
||||
</aside>
|
||||
</>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
86
src/components/PullToRefresh.css
Normal file
86
src/components/PullToRefresh.css
Normal 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;
|
||||
}
|
||||
}
|
||||
99
src/components/PullToRefresh.jsx
Normal file
99
src/components/PullToRefresh.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/components/SwipeableView.css
Normal file
100
src/components/SwipeableView.css
Normal file
@@ -0,0 +1,100 @@
|
||||
/* 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);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.swipeable-view__tab.is-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 2px;
|
||||
background: var(--neon-cyan);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Mobile touch targets */
|
||||
@media (max-width: 768px) {
|
||||
.swipeable-view__tab {
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.swipeable-view__track {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.swipeable-view__tab {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
92
src/components/SwipeableView.jsx
Normal file
92
src/components/SwipeableView.jsx
Normal 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
18
src/hooks/useIsMobile.js
Normal 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;
|
||||
}
|
||||
@@ -72,6 +72,8 @@
|
||||
/* ── Layout ──────────────────────────────────────────────────────── */
|
||||
--sidebar-w: 240px;
|
||||
--topbar-h: 56px;
|
||||
--bottom-nav-h: 64px;
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
/* ── Typography ──────────────────────────────────────────────────── */
|
||||
--font-display: 'Space Grotesk', 'Noto Sans KR', system-ui, serif;
|
||||
@@ -113,6 +115,10 @@ html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
@@ -240,5 +246,6 @@ select option {
|
||||
body {
|
||||
overflow: auto;
|
||||
background-attachment: scroll;
|
||||
padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
477
src/pages/agent-office/AgentOffice.css
Normal file
477
src/pages/agent-office/AgentOffice.css
Normal file
@@ -0,0 +1,477 @@
|
||||
/* src/pages/agent-office/AgentOffice.css */
|
||||
|
||||
/* ===== Root Layout ===== */
|
||||
.ao-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #0d0d1a;
|
||||
color: #ffffff;
|
||||
font-family: 'Courier New', monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ===== Top Bar ===== */
|
||||
.ao-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
background: #1a1a2e;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ao-topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.ao-topbar-title {
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
.ao-topbar-status {
|
||||
font-size: 11px;
|
||||
}
|
||||
.ao-topbar-status.connected { color: #22c55e; }
|
||||
.ao-topbar-status.disconnected { color: #ef4444; }
|
||||
.ao-topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.ao-topbar-select {
|
||||
background: #2a2a3e;
|
||||
color: #aaa;
|
||||
border: 1px solid #444;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ao-topbar-zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.ao-topbar-zoom button {
|
||||
background: #2a2a3e;
|
||||
color: #aaa;
|
||||
border: 1px solid #444;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.ao-topbar-zoom button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
.ao-topbar-zoom span {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== Main Area ===== */
|
||||
.ao-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ao-canvas {
|
||||
flex: 1;
|
||||
cursor: grab;
|
||||
display: block;
|
||||
}
|
||||
.ao-canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ===== Side Panel ===== */
|
||||
.ao-sidepanel {
|
||||
width: 320px;
|
||||
background: #111;
|
||||
border-left: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
.ao-sidepanel-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.ao-sidepanel-agent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.ao-sidepanel-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
.ao-sidepanel-name {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
.ao-sidepanel-state {
|
||||
font-size: 11px;
|
||||
color: #22c55e;
|
||||
}
|
||||
.ao-sidepanel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.ao-sidepanel-close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.ao-sidepanel-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.ao-sidepanel-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ao-sidepanel-tab.active {
|
||||
color: #8b5cf6;
|
||||
border-bottom-color: #8b5cf6;
|
||||
font-weight: bold;
|
||||
}
|
||||
.ao-sidepanel-tab:hover {
|
||||
color: #aaa;
|
||||
}
|
||||
.ao-sidepanel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* ===== Command Tab ===== */
|
||||
.ao-command-tab { display: flex; flex-direction: column; gap: 12px; }
|
||||
.ao-section { margin-bottom: 4px; }
|
||||
.ao-section-label {
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ao-quick-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.ao-btn-quick {
|
||||
background: #2a2a4e;
|
||||
color: #8b5cf6;
|
||||
border: 1px solid #4c1d95;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ao-btn-quick:hover { background: #3a3a5e; }
|
||||
.ao-btn-quick:disabled { opacity: 0.4; }
|
||||
|
||||
.ao-param-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.ao-input {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #333;
|
||||
color: #fff;
|
||||
padding: 7px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ao-input::placeholder { color: #555; }
|
||||
.ao-btn-send {
|
||||
background: #4c1d95;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 7px 14px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ao-btn-send:hover { background: #5b21b6; }
|
||||
.ao-btn-send:disabled { opacity: 0.4; }
|
||||
|
||||
/* Approval */
|
||||
.ao-approval-card {
|
||||
background: rgba(146,64,14,0.15);
|
||||
border: 1px solid #92400e;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.ao-approval-title {
|
||||
color: #fbbf24;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ao-approval-desc {
|
||||
color: #ddd;
|
||||
font-size: 11px;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ao-approval-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.ao-btn-approve {
|
||||
flex: 1;
|
||||
background: #065f46;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ao-btn-reject {
|
||||
flex: 1;
|
||||
background: #7f1d1d;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ===== Task Tab ===== */
|
||||
.ao-task-tab { display: flex; flex-direction: column; gap: 4px; }
|
||||
.ao-task-item {
|
||||
background: #1a1a2e;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ao-task-item:hover { background: #222240; }
|
||||
.ao-task-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ao-task-type { color: #ccc; font-weight: bold; flex: 1; }
|
||||
.ao-task-badge {
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.ao-task-time { color: #666; font-size: 10px; }
|
||||
.ao-task-result {
|
||||
margin-top: 6px;
|
||||
background: #0d0d1a;
|
||||
padding: 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
color: #aaa;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ===== Token Tab ===== */
|
||||
.ao-token-tab { display: flex; flex-direction: column; gap: 12px; }
|
||||
.ao-token-period {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.ao-btn-period {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
color: #888;
|
||||
border: 1px solid #333;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ao-btn-period.active {
|
||||
background: #4c1d95;
|
||||
color: #fff;
|
||||
border-color: #4c1d95;
|
||||
}
|
||||
.ao-token-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.ao-token-card {
|
||||
background: #1a1a2e;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.ao-token-label {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ao-token-value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
.ao-token-bar { margin-top: 4px; }
|
||||
.ao-token-bar-label { font-size: 10px; color: #888; margin-bottom: 4px; }
|
||||
.ao-token-bar-track {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
.ao-token-bar-fill.input { background: #3b82f6; }
|
||||
.ao-token-bar-fill.output { background: #8b5cf6; }
|
||||
.ao-token-bar-legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ao-token-bar-legend .dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.ao-token-bar-legend .dot.input { background: #3b82f6; }
|
||||
.ao-token-bar-legend .dot.output { background: #8b5cf6; }
|
||||
.ao-token-detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ===== Log Tab ===== */
|
||||
.ao-log-tab {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.ao-log-item {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid #1a1a2e;
|
||||
}
|
||||
.ao-log-time { color: #555; min-width: 60px; }
|
||||
.ao-log-level { min-width: 48px; font-weight: bold; }
|
||||
.ao-log-msg { color: #ccc; word-break: break-all; }
|
||||
|
||||
/* ===== Common ===== */
|
||||
.ao-empty {
|
||||
color: #555;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ===== Mobile (< 768px) ===== */
|
||||
@media (max-width: 768px) {
|
||||
.ao-topbar-right { gap: 6px; }
|
||||
.ao-topbar-select { font-size: 11px; padding: 2px 6px; }
|
||||
|
||||
.ao-main {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ao-canvas {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Side panel → bottom sheet */
|
||||
.ao-sidepanel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-height: 55vh;
|
||||
border-left: none;
|
||||
border-top: 1px solid #333;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.25s ease-out;
|
||||
z-index: 100;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.ao-sidepanel-header {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.ao-sidepanel-header::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.ao-sidepanel-tab {
|
||||
font-size: 11px;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.ao-sidepanel-content {
|
||||
padding: 8px 12px;
|
||||
padding-bottom: env(safe-area-inset-bottom, 16px);
|
||||
}
|
||||
}
|
||||
101
src/pages/agent-office/AgentOffice.jsx
Normal file
101
src/pages/agent-office/AgentOffice.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
// src/pages/agent-office/AgentOffice.jsx
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAgentManager } from './hooks/useAgentManager.js';
|
||||
import { useOfficeCanvas } from './hooks/useOfficeCanvas.js';
|
||||
import TopBar from './components/TopBar.jsx';
|
||||
import SidePanel from './components/SidePanel.jsx';
|
||||
import './AgentOffice.css';
|
||||
|
||||
export default function AgentOffice() {
|
||||
const {
|
||||
agents, pendingTasks, notifications, connected,
|
||||
refreshTrigger, clearNotifications
|
||||
} = useAgentManager();
|
||||
|
||||
const {
|
||||
canvasRef, updateAgentState, setAgentNotification,
|
||||
setTheme, setZoom, hitTest, getZoom, wasDragging
|
||||
} = useOfficeCanvas();
|
||||
|
||||
const [selectedAgent, setSelectedAgent] = useState(null);
|
||||
const [theme, setThemeState] = useState(localStorage.getItem('agent-office-theme') || 'modern');
|
||||
const [zoom, setZoomState] = useState(2);
|
||||
|
||||
// WebSocket 상태 → 캔버스 동기화
|
||||
useEffect(() => {
|
||||
for (const [id, agentState] of Object.entries(agents)) {
|
||||
updateAgentState(id, agentState.state, agentState.detail);
|
||||
}
|
||||
}, [agents, updateAgentState]);
|
||||
|
||||
// 알림 → 캔버스 동기화
|
||||
useEffect(() => {
|
||||
for (const [id, count] of Object.entries(notifications)) {
|
||||
setAgentNotification(id, count);
|
||||
}
|
||||
}, [notifications, setAgentNotification]);
|
||||
|
||||
// 캔버스 클릭 핸들러
|
||||
const handleCanvasClick = useCallback((e) => {
|
||||
if (wasDragging()) return; // 드래그 후 발생하는 클릭 무시
|
||||
const result = hitTest(e.clientX, e.clientY);
|
||||
if (result.type === 'agent') {
|
||||
setSelectedAgent(result.id);
|
||||
clearNotifications(result.id);
|
||||
setAgentNotification(result.id, 0);
|
||||
} else {
|
||||
setSelectedAgent(null);
|
||||
}
|
||||
}, [hitTest, clearNotifications, setAgentNotification, wasDragging]);
|
||||
|
||||
// 테마 변경
|
||||
const handleThemeChange = useCallback((name) => {
|
||||
setThemeState(name);
|
||||
setTheme(name);
|
||||
}, [setTheme]);
|
||||
|
||||
// 줌 변경
|
||||
const handleZoomChange = useCallback((level) => {
|
||||
setZoomState(level);
|
||||
setZoom(level);
|
||||
}, [setZoom]);
|
||||
|
||||
// 선택된 에이전트의 pending task
|
||||
const pendingTask = selectedAgent
|
||||
? pendingTasks.find(t => t.agent_id === selectedAgent)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="ao-root">
|
||||
<TopBar
|
||||
connected={connected}
|
||||
theme={theme}
|
||||
onThemeChange={handleThemeChange}
|
||||
zoom={zoom}
|
||||
onZoomChange={handleZoomChange}
|
||||
/>
|
||||
|
||||
<div className="ao-main">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="ao-canvas"
|
||||
onClick={handleCanvasClick}
|
||||
/>
|
||||
|
||||
{selectedAgent && (
|
||||
<SidePanel
|
||||
agentId={selectedAgent}
|
||||
agentState={agents[selectedAgent]}
|
||||
pendingTask={pendingTask}
|
||||
onClose={() => setSelectedAgent(null)}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Component() {
|
||||
return <AgentOffice />;
|
||||
}
|
||||
72
src/pages/agent-office/assets/office-map.json
Normal file
72
src/pages/agent-office/assets/office-map.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"cols": 32,
|
||||
"rows": 20,
|
||||
"tileSize": 32,
|
||||
"floor": [
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
|
||||
],
|
||||
"furniture": [
|
||||
{"type": "desk_monitor", "col": 3, "row": 3, "agent": "stock", "monitors": 3},
|
||||
{"type": "desk_monitor", "col": 10, "row": 3, "agent": "music", "monitors": 1, "accent": "instrument"},
|
||||
{"type": "desk_monitor", "col": 17, "row": 3, "agent": "blog", "monitors": 2, "accent": "papers"},
|
||||
{"type": "desk_monitor", "col": 24, "row": 3, "agent": "realestate", "monitors": 2, "accent": "briefcase"},
|
||||
{"type": "desk_monitor", "col": 14, "row": 7, "agent": "lotto", "monitors": 1, "accent": "dice"},
|
||||
{"type": "meeting_table","col": 13, "row": 11,"width": 6, "height": 2},
|
||||
{"type": "sofa", "col": 2, "row": 17},
|
||||
{"type": "coffee_machine","col": 5, "row": 16},
|
||||
{"type": "bookshelf", "col": 27, "row": 16, "height": 3},
|
||||
{"type": "plant", "col": 1, "row": 1},
|
||||
{"type": "plant", "col": 30, "row": 1},
|
||||
{"type": "plant", "col": 1, "row": 14},
|
||||
{"type": "plant", "col": 30, "row": 14},
|
||||
{"type": "water_cooler", "col": 8, "row": 17}
|
||||
],
|
||||
"waypoints": {
|
||||
"desk_stock": {"col": 3, "row": 4},
|
||||
"desk_music": {"col": 10, "row": 4},
|
||||
"desk_blog": {"col": 17, "row": 4},
|
||||
"desk_realestate": {"col": 24, "row": 4},
|
||||
"desk_lotto": {"col": 14, "row": 8},
|
||||
"meeting": {"col": 16, "row": 13},
|
||||
"break_room": {"col": 4, "row": 17},
|
||||
"coffee": {"col": 6, "row": 17},
|
||||
"water_cooler": {"col": 8, "row": 18}
|
||||
},
|
||||
"blocked": [
|
||||
[3,3],[4,3],[5,3],
|
||||
[10,3],[11,3],
|
||||
[17,3],[18,3],[19,3],
|
||||
[24,3],[25,3],[26,3],
|
||||
[14,7],[15,7],
|
||||
[13,11],[14,11],[15,11],[16,11],[17,11],[18,11],
|
||||
[13,12],[14,12],[15,12],[16,12],[17,12],[18,12],
|
||||
[2,17],[3,17],
|
||||
[5,16],[6,16],
|
||||
[27,16],[27,17],[27,18],
|
||||
[8,17]
|
||||
],
|
||||
"tileTypes": {
|
||||
"0": "wall",
|
||||
"1": "floor",
|
||||
"2": "floor_break"
|
||||
}
|
||||
}
|
||||
261
src/pages/agent-office/canvas/AgentSprite.js
Normal file
261
src/pages/agent-office/canvas/AgentSprite.js
Normal file
@@ -0,0 +1,261 @@
|
||||
// src/pages/agent-office/canvas/AgentSprite.js
|
||||
|
||||
import { ProceduralSprite } from './ProceduralSprite.js';
|
||||
|
||||
const WALK_SPEED = 3; // tiles per second
|
||||
const WANDER_DELAY_MIN = 3;
|
||||
const WANDER_DELAY_MAX = 8;
|
||||
const WANDER_LIMIT_MIN = 3;
|
||||
const WANDER_LIMIT_MAX = 6;
|
||||
const REST_DELAY_MIN = 2;
|
||||
const REST_DELAY_MAX = 20;
|
||||
|
||||
export class AgentSprite {
|
||||
constructor(id, meta, col, row, pathfinder) {
|
||||
this.id = id;
|
||||
this.meta = meta;
|
||||
this.pathfinder = pathfinder;
|
||||
|
||||
// 위치 (타일 좌표, 실수)
|
||||
this.x = col;
|
||||
this.y = row;
|
||||
this.deskCol = col;
|
||||
this.deskRow = row;
|
||||
|
||||
// 상태
|
||||
this.state = 'idle'; // FSM 상태 (from backend)
|
||||
this.detail = '';
|
||||
this.notificationCount = 0;
|
||||
|
||||
// 애니메이션
|
||||
this.animState = 'idle'; // 렌더링용 상태
|
||||
this.direction = 'down';
|
||||
this.animFrame = 0;
|
||||
this.animTimer = 0;
|
||||
|
||||
// 이동
|
||||
this.path = []; // BFS 경로 [{col, row}, ...]
|
||||
this.moveProgress = 0; // 0~1 현재 타일 → 다음 타일
|
||||
this.moveFrom = { col, row };
|
||||
this.moveTo_target = null;
|
||||
|
||||
// 배회
|
||||
this._wandering = false;
|
||||
this._wanderTimer = 0;
|
||||
this._wanderCount = 0;
|
||||
this._wanderLimit = 0;
|
||||
this._restTimer = 0;
|
||||
this._isResting = false;
|
||||
this._isAtDesk = true;
|
||||
}
|
||||
|
||||
/** 매 프레임 호출 */
|
||||
update(dt) {
|
||||
// 이동 처리
|
||||
if (this.path.length > 0) {
|
||||
this._updateMovement(dt);
|
||||
} else if (this._wandering) {
|
||||
this._updateWander(dt);
|
||||
}
|
||||
|
||||
// 애니메이션 프레임 업데이트
|
||||
this._updateAnimation(dt);
|
||||
}
|
||||
|
||||
_updateMovement(dt) {
|
||||
this.animState = 'walk';
|
||||
this.moveProgress += WALK_SPEED * dt;
|
||||
|
||||
if (this.moveProgress >= 1) {
|
||||
// 현재 구간 완료
|
||||
const arrived = this.path.shift();
|
||||
this.x = arrived.col;
|
||||
this.y = arrived.row;
|
||||
this.moveFrom = { col: arrived.col, row: arrived.row };
|
||||
this.moveProgress = 0;
|
||||
|
||||
if (this.path.length === 0) {
|
||||
// 최종 목적지 도착
|
||||
this._onArrival();
|
||||
} else {
|
||||
// 다음 구간의 방향 설정
|
||||
this._updateDirection(this.path[0]);
|
||||
}
|
||||
} else {
|
||||
// 보간
|
||||
const next = this.path[0];
|
||||
this.x = this.moveFrom.col + (next.col - this.moveFrom.col) * this.moveProgress;
|
||||
this.y = this.moveFrom.row + (next.row - this.moveFrom.row) * this.moveProgress;
|
||||
}
|
||||
}
|
||||
|
||||
_onArrival() {
|
||||
const atDesk = Math.abs(this.x - this.deskCol) < 0.5 && Math.abs(this.y - this.deskRow) < 0.5;
|
||||
this._isAtDesk = atDesk;
|
||||
|
||||
if (this.state === 'working' || this.state === 'reporting') {
|
||||
this.animState = 'type';
|
||||
this.direction = 'up'; // 모니터를 바라봄
|
||||
} else if (this.state === 'waiting') {
|
||||
this.animState = 'wait';
|
||||
} else if (this.state === 'break') {
|
||||
this.animState = 'break_anim';
|
||||
} else {
|
||||
// idle 도착 — 배회 계속 또는 자리에서 쉬기
|
||||
if (this._wandering && this._wanderCount < this._wanderLimit) {
|
||||
// 다음 배회 타이머 설정
|
||||
this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN);
|
||||
} else if (this._wandering) {
|
||||
// 배회 끝, 휴식
|
||||
this._wandering = false;
|
||||
this._isResting = true;
|
||||
this._restTimer = REST_DELAY_MIN + Math.random() * (REST_DELAY_MAX - REST_DELAY_MIN);
|
||||
}
|
||||
this.animState = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
_updateWander(dt) {
|
||||
if (this._isResting) {
|
||||
this._restTimer -= dt;
|
||||
if (this._restTimer <= 0) {
|
||||
this._isResting = false;
|
||||
this._startWandering();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._wanderTimer -= dt;
|
||||
if (this._wanderTimer <= 0) {
|
||||
// 랜덤 인접 타일로 이동
|
||||
const target = this.pathfinder.getRandomNearbyFloor(
|
||||
Math.round(this.x), Math.round(this.y), 4
|
||||
);
|
||||
if (target) {
|
||||
const path = this.pathfinder.findPath(
|
||||
Math.round(this.x), Math.round(this.y), target.col, target.row
|
||||
);
|
||||
if (path.length > 0 && path.length <= 6) {
|
||||
this.path = path;
|
||||
this.moveFrom = { col: Math.round(this.x), row: Math.round(this.y) };
|
||||
this.moveProgress = 0;
|
||||
this._updateDirection(path[0]);
|
||||
this._wanderCount++;
|
||||
}
|
||||
}
|
||||
// 실패해도 타이머 리셋
|
||||
this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN);
|
||||
}
|
||||
}
|
||||
|
||||
_updateDirection(nextTile) {
|
||||
const dx = nextTile.col - Math.round(this.x);
|
||||
const dy = nextTile.row - Math.round(this.y);
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
this.direction = dx > 0 ? 'right' : 'left';
|
||||
} else {
|
||||
this.direction = dy > 0 ? 'down' : 'up';
|
||||
}
|
||||
}
|
||||
|
||||
_updateAnimation(dt) {
|
||||
const config = ProceduralSprite.getAnimConfig(
|
||||
this.animState === 'walk' ? 'walk' : this.state
|
||||
);
|
||||
this.animTimer += dt;
|
||||
if (this.animTimer >= config.speed) {
|
||||
this.animTimer = 0;
|
||||
this.animFrame = (this.animFrame + 1) % config.frames;
|
||||
}
|
||||
}
|
||||
|
||||
/** 백엔드 상태 변경 시 호출 */
|
||||
onStateChange(newState, detail, waypoints) {
|
||||
const prevState = this.state;
|
||||
this.state = newState;
|
||||
this.detail = detail || '';
|
||||
|
||||
// 배회 중단
|
||||
this._wandering = false;
|
||||
this._isResting = false;
|
||||
|
||||
switch (newState) {
|
||||
case 'working':
|
||||
case 'reporting':
|
||||
case 'waiting':
|
||||
// 자리에 없으면 자리로 이동
|
||||
if (!this._isAtDesk) {
|
||||
this._moveToDesk();
|
||||
} else {
|
||||
this.animState = newState === 'waiting' ? 'wait' : 'type';
|
||||
this.direction = 'up';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'break': {
|
||||
// 휴게실로 이동
|
||||
const breakWp = waypoints.break_room || waypoints.coffee;
|
||||
if (breakWp) {
|
||||
this._navigateTo(breakWp.col, breakWp.row);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'idle':
|
||||
if (prevState === 'break') {
|
||||
// 휴게실에서 자리로 복귀
|
||||
this._moveToDesk();
|
||||
}
|
||||
// 복귀 후 배회 시작 (도착 콜백에서 처리)
|
||||
this._startWanderingAfterDelay(3);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_moveToDesk() {
|
||||
this._navigateTo(this.deskCol, this.deskRow);
|
||||
}
|
||||
|
||||
_navigateTo(goalCol, goalRow) {
|
||||
const startCol = Math.round(this.x);
|
||||
const startRow = Math.round(this.y);
|
||||
const path = this.pathfinder.findPath(startCol, startRow, goalCol, goalRow);
|
||||
if (path.length > 0) {
|
||||
this.path = path;
|
||||
this.moveFrom = { col: startCol, row: startRow };
|
||||
this.moveProgress = 0;
|
||||
this._updateDirection(path[0]);
|
||||
}
|
||||
}
|
||||
|
||||
_startWanderingAfterDelay(delay) {
|
||||
this._wandering = true;
|
||||
this._wanderCount = 0;
|
||||
this._wanderLimit = WANDER_LIMIT_MIN + Math.floor(Math.random() * (WANDER_LIMIT_MAX - WANDER_LIMIT_MIN));
|
||||
this._wanderTimer = delay;
|
||||
this._isResting = false;
|
||||
}
|
||||
|
||||
_startWandering() {
|
||||
this._startWanderingAfterDelay(WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN));
|
||||
}
|
||||
|
||||
isAtDesk() {
|
||||
return this._isAtDesk;
|
||||
}
|
||||
|
||||
/** 렌더링 */
|
||||
draw(ctx, zoom, panX, panY, tileSize) {
|
||||
const ts = tileSize * zoom;
|
||||
const screenX = this.x * ts + panX + ts / 2;
|
||||
const screenY = this.y * ts + panY + ts;
|
||||
const spriteScale = zoom * 1.5; // 캐릭터 약간 크게
|
||||
|
||||
ProceduralSprite.draw(
|
||||
ctx, this.id,
|
||||
this.animState === 'walk' ? 'walk' : this.state,
|
||||
this.direction, this.animFrame,
|
||||
screenX, screenY, spriteScale
|
||||
);
|
||||
}
|
||||
}
|
||||
209
src/pages/agent-office/canvas/FurnitureRenderer.js
Normal file
209
src/pages/agent-office/canvas/FurnitureRenderer.js
Normal file
@@ -0,0 +1,209 @@
|
||||
// src/pages/agent-office/canvas/FurnitureRenderer.js
|
||||
|
||||
/**
|
||||
* 가구 프로시저럴 렌더러 — 테마 팔레트 기반
|
||||
* 각 가구 타입별 draw 함수, Y-sort를 위한 zY 반환
|
||||
*/
|
||||
export class FurnitureRenderer {
|
||||
constructor(furnitureList, tileSize) {
|
||||
this.furnitureList = furnitureList;
|
||||
this.tileSize = tileSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 가구를 Y-sort 순서로 반환 (에이전트와 함께 정렬하기 위함)
|
||||
* @returns {Array<{type, col, row, zY, draw: Function}>}
|
||||
*/
|
||||
getRenderables(theme, scale, offsetX, offsetY) {
|
||||
const ts = this.tileSize * scale;
|
||||
return this.furnitureList.map(f => ({
|
||||
...f,
|
||||
zY: f.row,
|
||||
draw: (ctx) => this._drawFurniture(ctx, f, theme, ts, offsetX, offsetY)
|
||||
}));
|
||||
}
|
||||
|
||||
_drawFurniture(ctx, f, theme, ts, ox, oy) {
|
||||
const x = f.col * ts + ox;
|
||||
const y = f.row * ts + oy;
|
||||
|
||||
switch (f.type) {
|
||||
case 'desk_monitor': this._drawDesk(ctx, f, theme, ts, x, y); break;
|
||||
case 'meeting_table': this._drawMeetingTable(ctx, f, theme, ts, x, y); break;
|
||||
case 'sofa': this._drawSofa(ctx, theme, ts, x, y); break;
|
||||
case 'coffee_machine':this._drawCoffeeMachine(ctx, theme, ts, x, y); break;
|
||||
case 'bookshelf': this._drawBookshelf(ctx, f, theme, ts, x, y); break;
|
||||
case 'plant': this._drawPlant(ctx, theme, ts, x, y); break;
|
||||
case 'water_cooler': this._drawWaterCooler(ctx, theme, ts, x, y); break;
|
||||
}
|
||||
}
|
||||
|
||||
_drawDesk(ctx, f, theme, ts, x, y) {
|
||||
// 책상 상판
|
||||
const dw = ts * 2;
|
||||
const dh = ts * 0.6;
|
||||
ctx.fillStyle = theme.furniture.desk;
|
||||
ctx.fillRect(x, y + ts * 0.2, dw, dh);
|
||||
// 책상 다리
|
||||
ctx.fillStyle = theme.wall.border;
|
||||
ctx.fillRect(x + ts * 0.1, y + dh + ts * 0.2, ts * 0.15, ts * 0.3);
|
||||
ctx.fillRect(x + dw - ts * 0.25, y + dh + ts * 0.2, ts * 0.15, ts * 0.3);
|
||||
|
||||
// 모니터들
|
||||
const monCount = f.monitors || 1;
|
||||
const monW = ts * 0.5;
|
||||
const monH = ts * 0.4;
|
||||
const totalW = monCount * monW + (monCount - 1) * ts * 0.1;
|
||||
let monX = x + (dw - totalW) / 2;
|
||||
|
||||
for (let i = 0; i < monCount; i++) {
|
||||
// 모니터 프레임
|
||||
ctx.fillStyle = theme.furniture.monitor;
|
||||
ctx.fillRect(monX, y - monH + ts * 0.2, monW, monH);
|
||||
// 화면
|
||||
ctx.fillStyle = theme.furniture.monitorScreen;
|
||||
ctx.fillRect(monX + ts * 0.05, y - monH + ts * 0.25, monW - ts * 0.1, monH - ts * 0.1);
|
||||
// 모니터 받침대
|
||||
ctx.fillStyle = theme.furniture.monitor;
|
||||
ctx.fillRect(monX + monW * 0.35, y + ts * 0.2 - ts * 0.05, monW * 0.3, ts * 0.08);
|
||||
monX += monW + ts * 0.1;
|
||||
}
|
||||
|
||||
// 의자 (책상 아래)
|
||||
ctx.fillStyle = theme.furniture.chair;
|
||||
ctx.fillRect(x + dw * 0.35, y + ts, dw * 0.3, ts * 0.5);
|
||||
ctx.fillRect(x + dw * 0.3, y + ts * 0.8, dw * 0.4, ts * 0.25);
|
||||
|
||||
// 에이전트별 악센트 소품
|
||||
if (f.accent === 'instrument') {
|
||||
// 음표 모양
|
||||
ctx.fillStyle = theme.ui.accent;
|
||||
ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.3, ts * 0.1, ts * 0.5);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + dw + ts * 0.2, y + ts * 0.8, ts * 0.15, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (f.accent === 'papers') {
|
||||
// 서류 더미
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.3, ts * 0.35, ts * 0.45);
|
||||
ctx.fillStyle = theme.text.label;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.38 + i * ts * 0.1, ts * 0.25, ts * 0.02);
|
||||
}
|
||||
} else if (f.accent === 'briefcase') {
|
||||
ctx.fillStyle = '#8B4513';
|
||||
ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.5, ts * 0.4, ts * 0.3);
|
||||
ctx.fillStyle = '#D4A06A';
|
||||
ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.45, ts * 0.2, ts * 0.08);
|
||||
} else if (f.accent === 'dice') {
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.4, ts * 0.3, ts * 0.3);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + dw + ts * 0.3, y + ts * 0.55, ts * 0.05, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
_drawMeetingTable(ctx, f, theme, ts, x, y) {
|
||||
const w = (f.width || 4) * ts;
|
||||
const h = (f.height || 2) * ts;
|
||||
// 테이블 상판
|
||||
ctx.fillStyle = theme.furniture.table;
|
||||
ctx.fillRect(x + ts * 0.1, y + ts * 0.1, w - ts * 0.2, h - ts * 0.2);
|
||||
// 테이블 그림자
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.15)';
|
||||
ctx.fillRect(x + ts * 0.15, y + h - ts * 0.1, w - ts * 0.25, ts * 0.1);
|
||||
// 의자들 (상하 4개씩)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const cx = x + ts * 0.5 + i * (w - ts) / 3;
|
||||
ctx.fillStyle = theme.furniture.chair;
|
||||
ctx.fillRect(cx, y - ts * 0.3, ts * 0.4, ts * 0.35);
|
||||
ctx.fillRect(cx, y + h - ts * 0.05, ts * 0.4, ts * 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
_drawSofa(ctx, theme, ts, x, y) {
|
||||
ctx.fillStyle = theme.furniture.sofa;
|
||||
ctx.fillRect(x, y, ts * 2, ts * 0.8);
|
||||
// 등받이
|
||||
ctx.fillStyle = theme.furniture.sofa;
|
||||
ctx.fillRect(x, y - ts * 0.3, ts * 2, ts * 0.35);
|
||||
// 쿠션 구분선
|
||||
ctx.strokeStyle = theme.wall.border;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + ts, y);
|
||||
ctx.lineTo(x + ts, y + ts * 0.8);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
_drawCoffeeMachine(ctx, theme, ts, x, y) {
|
||||
ctx.fillStyle = theme.furniture.coffee;
|
||||
ctx.fillRect(x + ts * 0.15, y, ts * 0.7, ts * 0.8);
|
||||
// 디스펜서
|
||||
ctx.fillStyle = theme.furniture.monitor;
|
||||
ctx.fillRect(x + ts * 0.25, y + ts * 0.15, ts * 0.5, ts * 0.3);
|
||||
// 커피 잔
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(x + ts * 0.3, y + ts * 0.55, ts * 0.2, ts * 0.15);
|
||||
// 스팀
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + ts * 0.4, y + ts * 0.5);
|
||||
ctx.quadraticCurveTo(x + ts * 0.45, y + ts * 0.35, x + ts * 0.4, y + ts * 0.2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
_drawBookshelf(ctx, f, theme, ts, x, y) {
|
||||
const h = (f.height || 3) * ts;
|
||||
ctx.fillStyle = theme.furniture.shelf;
|
||||
ctx.fillRect(x, y, ts * 0.9, h);
|
||||
// 선반 및 책
|
||||
const bookColors = ['#aa4444', '#4444aa', '#44aa44', '#aaaa44', '#aa44aa', '#44aaaa'];
|
||||
const shelfCount = f.height || 3;
|
||||
for (let i = 0; i < shelfCount; i++) {
|
||||
const sy = y + i * ts + ts * 0.1;
|
||||
// 선반 판
|
||||
ctx.fillStyle = theme.furniture.shelf;
|
||||
ctx.fillRect(x, sy + ts * 0.7, ts * 0.9, ts * 0.05);
|
||||
// 책들
|
||||
for (let b = 0; b < 4; b++) {
|
||||
ctx.fillStyle = bookColors[(i * 4 + b) % bookColors.length];
|
||||
ctx.fillRect(x + ts * 0.05 + b * ts * 0.2, sy + ts * 0.1, ts * 0.15, ts * 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_drawPlant(ctx, theme, ts, x, y) {
|
||||
// 화분
|
||||
ctx.fillStyle = theme.decor.pot;
|
||||
ctx.fillRect(x + ts * 0.25, y + ts * 0.6, ts * 0.5, ts * 0.35);
|
||||
ctx.fillRect(x + ts * 0.2, y + ts * 0.55, ts * 0.6, ts * 0.1);
|
||||
// 잎
|
||||
ctx.fillStyle = theme.decor.plant;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + ts * 0.5, y + ts * 0.35, ts * 0.3, ts * 0.25, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + ts * 0.35, y + ts * 0.25, ts * 0.15, ts * 0.2, -0.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + ts * 0.65, y + ts * 0.25, ts * 0.15, ts * 0.2, 0.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
_drawWaterCooler(ctx, theme, ts, x, y) {
|
||||
// 본체
|
||||
ctx.fillStyle = theme.furniture.shelf;
|
||||
ctx.fillRect(x + ts * 0.2, y + ts * 0.3, ts * 0.6, ts * 0.6);
|
||||
// 물통
|
||||
ctx.fillStyle = 'rgba(100,180,255,0.5)';
|
||||
ctx.fillRect(x + ts * 0.3, y, ts * 0.4, ts * 0.35);
|
||||
ctx.fillStyle = 'rgba(100,180,255,0.3)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + ts * 0.5, y, ts * 0.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
316
src/pages/agent-office/canvas/OfficeRenderer.js
Normal file
316
src/pages/agent-office/canvas/OfficeRenderer.js
Normal file
@@ -0,0 +1,316 @@
|
||||
// src/pages/agent-office/canvas/OfficeRenderer.js
|
||||
|
||||
import mapData from '../assets/office-map.json';
|
||||
import { TileMap } from './TileMap.js';
|
||||
import { FurnitureRenderer } from './FurnitureRenderer.js';
|
||||
import { Pathfinder } from './Pathfinder.js';
|
||||
import { AgentSprite } from './AgentSprite.js';
|
||||
import { OverlayRenderer } from './OverlayRenderer.js';
|
||||
import { getTheme } from './themes.js';
|
||||
|
||||
const AGENT_META = {
|
||||
stock: { displayName: '주식 트레이더', emoji: '📈', color: '#4488cc' },
|
||||
music: { displayName: '음악 프로듀서', emoji: '🎵', color: '#44aa88' },
|
||||
blog: { displayName: '블로그 마케터', emoji: '✍️', color: '#d97706' },
|
||||
realestate: { displayName: '청약 애널리스트', emoji: '🏢', color: '#c026d3' },
|
||||
lotto: { displayName: '로또 큐레이터', emoji: '🎱', color: '#ef4444' }
|
||||
};
|
||||
|
||||
export class OfficeRenderer {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
|
||||
// 맵 & 렌더러
|
||||
this.tileMap = new TileMap(mapData);
|
||||
this.furnitureRenderer = new FurnitureRenderer(mapData.furniture, mapData.tileSize);
|
||||
this.pathfinder = new Pathfinder(mapData.cols, mapData.rows);
|
||||
this.overlayRenderer = new OverlayRenderer();
|
||||
|
||||
// blocked 타일 설정
|
||||
this.pathfinder.setWalls(mapData.floor);
|
||||
this.pathfinder.setBlocked(mapData.blocked);
|
||||
|
||||
// 테마 & 뷰포트
|
||||
this.theme = getTheme(
|
||||
(typeof localStorage !== 'undefined' && localStorage.getItem('agent-office-theme')) || 'modern'
|
||||
);
|
||||
this.zoom = 2;
|
||||
this.panX = 0;
|
||||
this.panY = 0;
|
||||
this._isPanning = false;
|
||||
this._panStart = { x: 0, y: 0 };
|
||||
|
||||
// 에이전트
|
||||
this.agents = new Map();
|
||||
this._initAgents();
|
||||
|
||||
// 게임 루프
|
||||
this._lastTime = 0;
|
||||
this._animId = null;
|
||||
this._lastDpr = window.devicePixelRatio || 1;
|
||||
|
||||
// 드래그 감지
|
||||
this._mouseDownPos = { x: 0, y: 0 };
|
||||
this._wasDragging = false;
|
||||
|
||||
// 이벤트
|
||||
this._setupInputHandlers();
|
||||
}
|
||||
|
||||
_initAgents() {
|
||||
for (const [id, meta] of Object.entries(AGENT_META)) {
|
||||
const waypoint = mapData.waypoints[`desk_${id}`];
|
||||
if (!waypoint) continue;
|
||||
const sprite = new AgentSprite(id, meta, waypoint.col, waypoint.row, this.pathfinder);
|
||||
sprite.deskCol = waypoint.col;
|
||||
sprite.deskRow = waypoint.row;
|
||||
this.agents.set(id, sprite);
|
||||
}
|
||||
}
|
||||
|
||||
/** 줌/팬/클릭 이벤트 핸들러 */
|
||||
_setupInputHandlers() {
|
||||
// 마우스 휠 줌
|
||||
this.canvas.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const oldZoom = this.zoom;
|
||||
if (e.deltaY < 0) {
|
||||
this.zoom = Math.min(this.zoom + 0.5, 4);
|
||||
} else {
|
||||
this.zoom = Math.max(this.zoom - 0.5, 1);
|
||||
}
|
||||
// 마우스 위치 기준 줌
|
||||
if (this.zoom !== oldZoom) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const ratio = this.zoom / oldZoom;
|
||||
this.panX = mx - (mx - this.panX) * ratio;
|
||||
this.panY = my - (my - this.panY) * ratio;
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// 마우스 드래그 패닝
|
||||
this.canvas.addEventListener('mousedown', (e) => {
|
||||
if (e.button === 0) {
|
||||
this._isPanning = true;
|
||||
this._panStart = { x: e.clientX - this.panX, y: e.clientY - this.panY };
|
||||
this._mouseDownPos = { x: e.clientX, y: e.clientY };
|
||||
this._wasDragging = false;
|
||||
}
|
||||
});
|
||||
this._onMouseMove = (e) => {
|
||||
if (this._isPanning) {
|
||||
this.panX = e.clientX - this._panStart.x;
|
||||
this.panY = e.clientY - this._panStart.y;
|
||||
const dx = e.clientX - this._mouseDownPos.x;
|
||||
const dy = e.clientY - this._mouseDownPos.y;
|
||||
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) this._wasDragging = true;
|
||||
}
|
||||
};
|
||||
this._onMouseUp = () => {
|
||||
this._isPanning = false;
|
||||
};
|
||||
window.addEventListener('mousemove', this._onMouseMove);
|
||||
window.addEventListener('mouseup', this._onMouseUp);
|
||||
|
||||
// 터치 (모바일)
|
||||
let lastTouchDist = 0;
|
||||
let lastTouchCenter = { x: 0, y: 0 };
|
||||
this.canvas.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 1) {
|
||||
this._isPanning = true;
|
||||
this._panStart = { x: e.touches[0].clientX - this.panX, y: e.touches[0].clientY - this.panY };
|
||||
} else if (e.touches.length === 2) {
|
||||
this._isPanning = false;
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
lastTouchDist = Math.hypot(dx, dy);
|
||||
lastTouchCenter = {
|
||||
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||
y: (e.touches[0].clientY + e.touches[1].clientY) / 2
|
||||
};
|
||||
}
|
||||
}, { passive: false });
|
||||
this.canvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.touches.length === 1 && this._isPanning) {
|
||||
this.panX = e.touches[0].clientX - this._panStart.x;
|
||||
this.panY = e.touches[0].clientY - this._panStart.y;
|
||||
} else if (e.touches.length === 2) {
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const oldZoom = this.zoom;
|
||||
this.zoom = Math.min(4, Math.max(1, this.zoom * (dist / lastTouchDist)));
|
||||
lastTouchDist = dist;
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const cx = lastTouchCenter.x - rect.left;
|
||||
const cy = lastTouchCenter.y - rect.top;
|
||||
const ratio = this.zoom / oldZoom;
|
||||
this.panX = cx - (cx - this.panX) * ratio;
|
||||
this.panY = cy - (cy - this.panY) * ratio;
|
||||
}
|
||||
}, { passive: false });
|
||||
this.canvas.addEventListener('touchend', () => {
|
||||
this._isPanning = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** 클릭 히트 테스트 — AgentOffice에서 호출 */
|
||||
hitTest(clientX, clientY) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const screenX = clientX - rect.left;
|
||||
const screenY = clientY - rect.top;
|
||||
const { col, row } = this.tileMap.screenToTile(screenX, screenY, this.zoom, this.panX, this.panY);
|
||||
|
||||
// 에이전트 히트 (역순, 최상위 우선)
|
||||
for (const [id, sprite] of [...this.agents.entries()].reverse()) {
|
||||
const dx = Math.abs(sprite.x - col);
|
||||
const dy = Math.abs(sprite.y - row);
|
||||
if (dx < 1.2 && dy < 1.5) {
|
||||
return { type: 'agent', id };
|
||||
}
|
||||
}
|
||||
return { type: 'empty' };
|
||||
}
|
||||
|
||||
/** 에이전트 상태 업데이트 (WebSocket에서 호출) */
|
||||
updateAgentState(agentId, state, detail) {
|
||||
const sprite = this.agents.get(agentId);
|
||||
if (!sprite) return;
|
||||
sprite.onStateChange(state, detail, mapData.waypoints);
|
||||
}
|
||||
|
||||
/** 에이전트 알림 배지 설정 */
|
||||
setAgentNotification(agentId, count) {
|
||||
const sprite = this.agents.get(agentId);
|
||||
if (sprite) sprite.notificationCount = count;
|
||||
}
|
||||
|
||||
/** 테마 변경 */
|
||||
setTheme(themeName) {
|
||||
this.theme = getTheme(themeName);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('agent-office-theme', themeName);
|
||||
}
|
||||
}
|
||||
|
||||
/** 줌 레벨 설정 */
|
||||
setZoom(level) {
|
||||
const cx = this.canvas.width / 2;
|
||||
const cy = this.canvas.height / 2;
|
||||
const oldZoom = this.zoom;
|
||||
this.zoom = Math.min(4, Math.max(1, level));
|
||||
const ratio = this.zoom / oldZoom;
|
||||
this.panX = cx - (cx - this.panX) * ratio;
|
||||
this.panY = cy - (cy - this.panY) * ratio;
|
||||
}
|
||||
|
||||
/** 카메라를 맵 중앙에 맞추기 */
|
||||
centerCamera() {
|
||||
const mapW = mapData.cols * mapData.tileSize * this.zoom;
|
||||
const mapH = mapData.rows * mapData.tileSize * this.zoom;
|
||||
this.panX = (this.canvas.clientWidth - mapW) / 2;
|
||||
this.panY = (this.canvas.clientHeight - mapH) / 2;
|
||||
}
|
||||
|
||||
/** 게임 루프 시작 */
|
||||
start() {
|
||||
this.centerCamera();
|
||||
this._lastTime = performance.now();
|
||||
this._loop(this._lastTime);
|
||||
}
|
||||
|
||||
/** 게임 루프 중지 */
|
||||
stop() {
|
||||
if (this._animId) {
|
||||
cancelAnimationFrame(this._animId);
|
||||
this._animId = null;
|
||||
}
|
||||
}
|
||||
|
||||
_loop(timestamp) {
|
||||
const dt = Math.min((timestamp - this._lastTime) / 1000, 0.1); // cap dt to avoid spiral
|
||||
this._lastTime = timestamp;
|
||||
|
||||
this._update(dt);
|
||||
this._render();
|
||||
|
||||
this._animId = requestAnimationFrame((t) => this._loop(t));
|
||||
}
|
||||
|
||||
_update(dt) {
|
||||
for (const sprite of this.agents.values()) {
|
||||
sprite.update(dt);
|
||||
}
|
||||
}
|
||||
|
||||
_render() {
|
||||
const ctx = this.ctx;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// 캔버스 크기 조정
|
||||
const displayW = this.canvas.clientWidth;
|
||||
const displayH = this.canvas.clientHeight;
|
||||
if (this.canvas.width !== displayW * dpr || this.canvas.height !== displayH * dpr || this._lastDpr !== dpr) {
|
||||
this.canvas.width = displayW * dpr;
|
||||
this.canvas.height = displayH * dpr;
|
||||
this._lastDpr = dpr;
|
||||
}
|
||||
// setTransform 방식으로 누적 없이 항상 올바른 변환 적용
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, displayW, displayH);
|
||||
|
||||
// 배경
|
||||
ctx.fillStyle = this.theme.wall.color;
|
||||
ctx.fillRect(0, 0, displayW, displayH);
|
||||
|
||||
// 1. 타일맵 (바닥 + 벽)
|
||||
this.tileMap.render(ctx, this.theme, this.zoom, this.panX, this.panY);
|
||||
|
||||
// 2. Y-sorted: 가구 + 에이전트
|
||||
const renderables = [];
|
||||
|
||||
// 가구
|
||||
const furnitureItems = this.furnitureRenderer.getRenderables(this.theme, this.zoom, this.panX, this.panY);
|
||||
renderables.push(...furnitureItems);
|
||||
|
||||
// 에이전트
|
||||
for (const sprite of this.agents.values()) {
|
||||
renderables.push({
|
||||
zY: sprite.y,
|
||||
draw: (ctx2) => sprite.draw(ctx2, this.zoom, this.panX, this.panY, mapData.tileSize)
|
||||
});
|
||||
}
|
||||
|
||||
// Y좌표 정렬
|
||||
renderables.sort((a, b) => a.zY - b.zY);
|
||||
for (const item of renderables) {
|
||||
item.draw(ctx);
|
||||
}
|
||||
|
||||
// 3. 오버레이 (항상 최상위)
|
||||
for (const sprite of this.agents.values()) {
|
||||
this.overlayRenderer.draw(ctx, sprite, this.theme, this.zoom, this.panX, this.panY, mapData.tileSize);
|
||||
}
|
||||
}
|
||||
|
||||
/** 드래그 여부 반환 (클릭 이벤트 필터링용) */
|
||||
wasDragging() { return this._wasDragging; }
|
||||
|
||||
/** 리사이즈 처리 */
|
||||
resize() {
|
||||
// 다음 프레임에서 자동 조정됨 (_render에서 크기 체크)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stop();
|
||||
// window 이벤트 리스너 정리
|
||||
if (this._onMouseMove) window.removeEventListener('mousemove', this._onMouseMove);
|
||||
if (this._onMouseUp) window.removeEventListener('mouseup', this._onMouseUp);
|
||||
}
|
||||
}
|
||||
122
src/pages/agent-office/canvas/OverlayRenderer.js
Normal file
122
src/pages/agent-office/canvas/OverlayRenderer.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// src/pages/agent-office/canvas/OverlayRenderer.js
|
||||
|
||||
/**
|
||||
* 캔버스 위 오버레이 렌더링:
|
||||
* - 이름 라벨 (항상)
|
||||
* - 상태 배지 (항상)
|
||||
* - 말풍선 (waiting 상태에서만)
|
||||
* - 알림 배지 (notification > 0 일 때)
|
||||
*/
|
||||
|
||||
const STATE_BADGE = {
|
||||
idle: { text: 'idle', bg: '#374151', fg: '#9ca3af' },
|
||||
working: { text: 'working', bg: '#1e3a5f', fg: '#60a5fa' },
|
||||
waiting: { text: 'waiting', bg: '#92400e', fg: '#fbbf24' },
|
||||
reporting: { text: 'reporting', bg: '#1e3a5f', fg: '#60a5fa' },
|
||||
break: { text: 'break', bg: '#065f46', fg: '#34d399' }
|
||||
};
|
||||
|
||||
export class OverlayRenderer {
|
||||
constructor() {
|
||||
this._bubbleAlpha = new Map(); // agentId → alpha (fade in/out)
|
||||
}
|
||||
|
||||
draw(ctx, sprite, theme, zoom, panX, panY, tileSize) {
|
||||
const ts = tileSize * zoom;
|
||||
const centerX = sprite.x * ts + panX + ts / 2;
|
||||
const topY = sprite.y * ts + panY - ts * 0.3;
|
||||
|
||||
const fontSize = Math.max(10, 11 * zoom / 2);
|
||||
const smallFontSize = Math.max(8, 9 * zoom / 2);
|
||||
|
||||
// 1. 이름 라벨
|
||||
ctx.font = `bold ${fontSize}px 'Courier New', monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = sprite.meta.color;
|
||||
ctx.fillText(sprite.meta.displayName, centerX, topY + ts * 1.85);
|
||||
|
||||
// 2. 상태 배지
|
||||
const badge = STATE_BADGE[sprite.state] || STATE_BADGE.idle;
|
||||
const badgeText = badge.text;
|
||||
ctx.font = `${smallFontSize}px 'Courier New', monospace`;
|
||||
const badgeW = ctx.measureText(badgeText).width + 8;
|
||||
const badgeH = smallFontSize + 4;
|
||||
const badgeX = centerX - badgeW / 2;
|
||||
const badgeY = topY + ts * 1.9;
|
||||
|
||||
ctx.fillStyle = badge.bg;
|
||||
this._roundRect(ctx, badgeX, badgeY, badgeW, badgeH, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = badge.fg;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(badgeText, centerX, badgeY + badgeH - 3);
|
||||
|
||||
// 3. 말풍선 (waiting 상태에서만)
|
||||
if (sprite.state === 'waiting') {
|
||||
this._drawBubble(ctx, sprite, centerX, topY - ts * 0.2, zoom);
|
||||
}
|
||||
|
||||
// 4. 알림 배지
|
||||
if (sprite.notificationCount > 0) {
|
||||
this._drawNotificationBadge(ctx, centerX + ts * 0.5, topY + ts * 0.2, sprite.notificationCount, zoom);
|
||||
}
|
||||
}
|
||||
|
||||
_drawBubble(ctx, sprite, x, y, zoom) {
|
||||
const text = '승인 대기!';
|
||||
const fontSize = Math.max(10, 11 * zoom / 2);
|
||||
ctx.font = `bold ${fontSize}px 'Courier New', monospace`;
|
||||
const tw = ctx.measureText(text).width;
|
||||
const pw = tw + 16;
|
||||
const ph = fontSize + 12;
|
||||
const px = x - pw / 2;
|
||||
const py = y - ph;
|
||||
|
||||
// 말풍선 배경
|
||||
ctx.fillStyle = '#fbbf24';
|
||||
this._roundRect(ctx, px, py, pw, ph, 6);
|
||||
ctx.fill();
|
||||
|
||||
// 꼬리 삼각형
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 5, py + ph);
|
||||
ctx.lineTo(x + 5, py + ph);
|
||||
ctx.lineTo(x, py + ph + 6);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 텍스트
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(text, x, py + ph - 5);
|
||||
}
|
||||
|
||||
_drawNotificationBadge(ctx, x, y, count, zoom) {
|
||||
const r = Math.max(7, 8 * zoom / 2);
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = `bold ${r}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(count > 9 ? '9+' : String(count), x, y);
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
_roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
}
|
||||
112
src/pages/agent-office/canvas/Pathfinder.js
Normal file
112
src/pages/agent-office/canvas/Pathfinder.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// src/pages/agent-office/canvas/Pathfinder.js
|
||||
|
||||
/**
|
||||
* BFS 4방향 경로 탐색 (대각선 없음)
|
||||
* blocked 타일과 벽 타일을 회피하여 최단 경로 반환
|
||||
*/
|
||||
export class Pathfinder {
|
||||
constructor(cols, rows) {
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
this.blocked = new Set();
|
||||
}
|
||||
|
||||
/** blocked 타일 세팅 (wall + furniture footprint) */
|
||||
setBlocked(blockedList) {
|
||||
// Do NOT clear — setWalls already added wall tiles
|
||||
for (const [col, row] of blockedList) {
|
||||
this.blocked.add(`${col},${row}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** wall 타일도 blocked로 추가 (floor 배열에서 0인 셀) */
|
||||
setWalls(floorGrid) {
|
||||
for (let r = 0; r < this.rows; r++) {
|
||||
for (let c = 0; c < this.cols; c++) {
|
||||
if (floorGrid[r][c] === 0) {
|
||||
this.blocked.add(`${c},${r}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isBlocked(col, row) {
|
||||
if (col < 0 || col >= this.cols || row < 0 || row >= this.rows) return true;
|
||||
return this.blocked.has(`${col},${row}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS 최단 경로
|
||||
* @returns {Array<{col, row}>} 시작점 제외, 도착점 포함 경로. 경로 없으면 빈 배열.
|
||||
*/
|
||||
findPath(startCol, startRow, goalCol, goalRow) {
|
||||
if (startCol === goalCol && startRow === goalRow) return [];
|
||||
|
||||
const key = (c, r) => `${c},${r}`;
|
||||
const startKey = key(startCol, startRow);
|
||||
const goalKey = key(goalCol, goalRow);
|
||||
|
||||
const queue = [{ col: startCol, row: startRow }];
|
||||
const visited = new Set([startKey]);
|
||||
const parent = new Map();
|
||||
|
||||
const dirs = [
|
||||
{ dc: 0, dr: -1 }, // up
|
||||
{ dc: 0, dr: 1 }, // down
|
||||
{ dc: -1, dr: 0 }, // left
|
||||
{ dc: 1, dr: 0 } // right
|
||||
];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
|
||||
for (const { dc, dr } of dirs) {
|
||||
const nc = current.col + dc;
|
||||
const nr = current.row + dr;
|
||||
const nk = key(nc, nr);
|
||||
|
||||
if (visited.has(nk)) continue;
|
||||
// 골 지점은 blocked여도 이동 가능 (에이전트가 자기 자리에 앉으려면)
|
||||
if (nk !== goalKey && this.isBlocked(nc, nr)) continue;
|
||||
|
||||
visited.add(nk);
|
||||
parent.set(nk, key(current.col, current.row));
|
||||
queue.push({ col: nc, row: nr });
|
||||
|
||||
if (nc === goalCol && nr === goalRow) {
|
||||
return this._reconstructPath(parent, startKey, goalKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return []; // 경로 없음
|
||||
}
|
||||
|
||||
_reconstructPath(parent, startKey, goalKey) {
|
||||
const path = [];
|
||||
let current = goalKey;
|
||||
while (current !== startKey) {
|
||||
const [c, r] = current.split(',').map(Number);
|
||||
path.unshift({ col: c, row: r });
|
||||
current = parent.get(current);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** idle 배회용: start 주변 반경 내 랜덤 walkable 타일 */
|
||||
getRandomNearbyFloor(col, row, radius = 4) {
|
||||
const candidates = [];
|
||||
for (let dr = -radius; dr <= radius; dr++) {
|
||||
for (let dc = -radius; dc <= radius; dc++) {
|
||||
const nc = col + dc;
|
||||
const nr = row + dr;
|
||||
if (nc === col && nr === row) continue;
|
||||
if (!this.isBlocked(nc, nr)) {
|
||||
candidates.push({ col: nc, row: nr });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||
}
|
||||
}
|
||||
164
src/pages/agent-office/canvas/ProceduralSprite.js
Normal file
164
src/pages/agent-office/canvas/ProceduralSprite.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// src/pages/agent-office/canvas/ProceduralSprite.js
|
||||
|
||||
/**
|
||||
* 프로시저럴 픽셀 캐릭터 렌더러 (16×32px 기본 해상도)
|
||||
* Phase 1: 코드로 캐릭터를 그림
|
||||
* Phase 2: SpriteLoader가 PNG 스프라이트로 대체
|
||||
*/
|
||||
|
||||
const AGENT_COLORS = {
|
||||
stock: { body: '#4488cc', hair: '#2255aa', accent: '#66aaee' },
|
||||
music: { body: '#44aa88', hair: '#228866', accent: '#66ccaa' },
|
||||
blog: { body: '#d97706', hair: '#b45e04', accent: '#f59e0b' },
|
||||
realestate: { body: '#c026d3', hair: '#9b1dab', accent: '#e044f0' },
|
||||
lotto: { body: '#ef4444', hair: '#cc2222', accent: '#ff6666' }
|
||||
};
|
||||
|
||||
/** 애니메이션 프레임 설정 */
|
||||
const ANIM_CONFIG = {
|
||||
idle: { frames: 2, speed: 0.8 },
|
||||
walk: { frames: 4, speed: 0.15, cycle: [0, 1, 2, 1] },
|
||||
type: { frames: 2, speed: 0.3 },
|
||||
wait: { frames: 2, speed: 0.5 },
|
||||
break_anim:{ frames: 2, speed: 1.0 }
|
||||
};
|
||||
|
||||
export class ProceduralSprite {
|
||||
/**
|
||||
* 캐릭터 1프레임 렌더링
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {string} agentId
|
||||
* @param {string} state - idle|walk|type|wait|break_anim
|
||||
* @param {string} direction - down|up|right|left
|
||||
* @param {number} frame - 현재 애니메이션 프레임 인덱스
|
||||
* @param {number} x - 캔버스 X 좌표 (캐릭터 중앙 하단)
|
||||
* @param {number} y - 캔버스 Y 좌표 (캐릭터 중앙 하단)
|
||||
* @param {number} scale - 렌더링 스케일
|
||||
*/
|
||||
static draw(ctx, agentId, state, direction, frame, x, y, scale) {
|
||||
const colors = AGENT_COLORS[agentId] || AGENT_COLORS.stock;
|
||||
const px = scale; // 1 pixel = scale 크기
|
||||
const w = 16 * px;
|
||||
const h = 32 * px;
|
||||
const bx = x - w / 2; // 좌상단 기준
|
||||
const by = y - h;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// 좌우 반전 (left = right 플립)
|
||||
if (direction === 'left') {
|
||||
ctx.translate(x, 0);
|
||||
ctx.scale(-1, 1);
|
||||
ctx.translate(-x, 0);
|
||||
}
|
||||
|
||||
// 그림자
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x, y, w * 0.35, px * 2, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 상태별 오프셋
|
||||
let bodyOffsetY = 0;
|
||||
let legSpread = 0;
|
||||
let armAngle = 0;
|
||||
|
||||
if (state === 'walk') {
|
||||
const walkFrame = ANIM_CONFIG.walk.cycle[frame % 4];
|
||||
legSpread = (walkFrame - 1) * px * 2;
|
||||
bodyOffsetY = walkFrame === 1 ? -px : 0;
|
||||
} else if (state === 'type') {
|
||||
armAngle = frame % 2 === 0 ? 1 : -1;
|
||||
bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5;
|
||||
} else if (state === 'wait') {
|
||||
bodyOffsetY = Math.sin(frame * Math.PI) * px;
|
||||
} else if (state === 'idle') {
|
||||
bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5;
|
||||
} else if (state === 'break_anim') {
|
||||
bodyOffsetY = frame % 2 === 0 ? 0 : px * 0.5; // 졸기
|
||||
}
|
||||
|
||||
const by2 = by + bodyOffsetY;
|
||||
|
||||
// 다리
|
||||
ctx.fillStyle = '#2a2a3e';
|
||||
// 왼쪽 다리
|
||||
ctx.fillRect(bx + px * 4 - legSpread, by2 + px * 24, px * 3, px * 8);
|
||||
// 오른쪽 다리
|
||||
ctx.fillRect(bx + px * 9 + legSpread, by2 + px * 24, px * 3, px * 8);
|
||||
// 신발
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.fillRect(bx + px * 3 - legSpread, by2 + px * 30, px * 5, px * 2);
|
||||
ctx.fillRect(bx + px * 8 + legSpread, by2 + px * 30, px * 5, px * 2);
|
||||
|
||||
// 몸통
|
||||
ctx.fillStyle = colors.body;
|
||||
ctx.fillRect(bx + px * 3, by2 + px * 12, px * 10, px * 13);
|
||||
|
||||
// 팔
|
||||
if (state === 'type') {
|
||||
// 타이핑: 팔 앞으로 뻗음
|
||||
ctx.fillStyle = colors.body;
|
||||
ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 8 + armAngle * px);
|
||||
ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 8 - armAngle * px);
|
||||
// 손
|
||||
ctx.fillStyle = '#ffcc99';
|
||||
ctx.fillRect(bx + px * 1, by2 + px * 20 + armAngle * px, px * 3, px * 3);
|
||||
ctx.fillRect(bx + px * 12, by2 + px * 20 - armAngle * px, px * 3, px * 3);
|
||||
} else {
|
||||
// 기본 팔
|
||||
ctx.fillStyle = colors.body;
|
||||
ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 10);
|
||||
ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 10);
|
||||
// 손
|
||||
ctx.fillStyle = '#ffcc99';
|
||||
ctx.fillRect(bx + px * 1, by2 + px * 22, px * 3, px * 3);
|
||||
ctx.fillRect(bx + px * 12, by2 + px * 22, px * 3, px * 3);
|
||||
}
|
||||
|
||||
// 머리
|
||||
ctx.fillStyle = '#ffcc99'; // 피부색
|
||||
ctx.fillRect(bx + px * 4, by2 + px * 2, px * 8, px * 10);
|
||||
|
||||
// 머리카락
|
||||
ctx.fillStyle = colors.hair;
|
||||
ctx.fillRect(bx + px * 3, by2 + px * 1, px * 10, px * 4);
|
||||
if (direction === 'down' || direction === 'left' || direction === 'right') {
|
||||
// 앞머리
|
||||
ctx.fillRect(bx + px * 4, by2 + px * 3, px * 2, px * 2);
|
||||
}
|
||||
|
||||
// 눈
|
||||
if (direction !== 'up') {
|
||||
ctx.fillStyle = '#222';
|
||||
if (state === 'break_anim' && frame % 2 === 1) {
|
||||
// 졸기: 눈 감음
|
||||
ctx.fillRect(bx + px * 5, by2 + px * 6, px * 2, px);
|
||||
ctx.fillRect(bx + px * 9, by2 + px * 6, px * 2, px);
|
||||
} else {
|
||||
ctx.fillRect(bx + px * 5, by2 + px * 5, px * 2, px * 2);
|
||||
ctx.fillRect(bx + px * 9, by2 + px * 5, px * 2, px * 2);
|
||||
}
|
||||
}
|
||||
|
||||
// break 소품: 커피잔
|
||||
if (state === 'break_anim') {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(bx + px * 14, by2 + px * 16, px * 3, px * 4);
|
||||
ctx.fillStyle = '#8B4513';
|
||||
ctx.fillRect(bx + px * 14.5, by2 + px * 16.5, px * 2, px * 2);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
static getAnimConfig(state) {
|
||||
const mapped = state === 'working' ? 'type'
|
||||
: state === 'waiting' ? 'wait'
|
||||
: state === 'reporting' ? 'type'
|
||||
: state === 'break' ? 'break_anim'
|
||||
: state === 'walk' ? 'walk'
|
||||
: 'idle';
|
||||
return { ...(ANIM_CONFIG[mapped] || ANIM_CONFIG.idle), mapped };
|
||||
}
|
||||
}
|
||||
77
src/pages/agent-office/canvas/SpriteLoader.js
Normal file
77
src/pages/agent-office/canvas/SpriteLoader.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// src/pages/agent-office/canvas/SpriteLoader.js
|
||||
|
||||
import { ProceduralSprite } from './ProceduralSprite.js';
|
||||
|
||||
/**
|
||||
* 스프라이트 로더 — PNG 스프라이트시트가 있으면 사용, 없으면 프로시저럴 폴백
|
||||
*
|
||||
* 스프라이트시트 규격 (Phase 2):
|
||||
* - 프레임 크기: 16×32px
|
||||
* - 행: 방향 (0=down, 1=up, 2=right)
|
||||
* - 열: 상태별 프레임 (idle 2 | walk 4 | type 2 | wait 2 | break 2 = 12열)
|
||||
*/
|
||||
export class SpriteLoader {
|
||||
constructor() {
|
||||
this.sprites = new Map(); // agentId → { image: Image, loaded: boolean }
|
||||
}
|
||||
|
||||
/** PNG 스프라이트시트 로드 시도 */
|
||||
async tryLoad(agentId, url) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
this.sprites.set(agentId, { image: img, loaded: true });
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve(false); // 폴백 사용
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
hasSprite(agentId) {
|
||||
return this.sprites.has(agentId) && this.sprites.get(agentId).loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 에이전트 1프레임 그리기 (스프라이트 또는 프로시저럴)
|
||||
*/
|
||||
draw(ctx, agentId, state, direction, frame, x, y, scale) {
|
||||
if (this.hasSprite(agentId)) {
|
||||
this._drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale);
|
||||
} else {
|
||||
ProceduralSprite.draw(ctx, agentId, state, direction, frame, x, y, scale);
|
||||
}
|
||||
}
|
||||
|
||||
_drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale) {
|
||||
const { image } = this.sprites.get(agentId);
|
||||
const frameW = 16;
|
||||
const frameH = 32;
|
||||
|
||||
// 방향 → 행
|
||||
const dirRow = direction === 'up' ? 1 : direction === 'right' || direction === 'left' ? 2 : 0;
|
||||
|
||||
// 상태 → 열 오프셋
|
||||
const stateOffsets = { idle: 0, walk: 2, type: 6, wait: 8, break_anim: 10 };
|
||||
const mappedState = state === 'working' ? 'type' : state === 'waiting' ? 'wait'
|
||||
: state === 'reporting' ? 'type' : state === 'break' ? 'break_anim'
|
||||
: state === 'walk' ? 'walk' : 'idle';
|
||||
const colOffset = stateOffsets[mappedState] || 0;
|
||||
|
||||
const srcX = (colOffset + frame) * frameW;
|
||||
const srcY = dirRow * frameH;
|
||||
const destW = frameW * scale;
|
||||
const destH = frameH * scale;
|
||||
|
||||
ctx.save();
|
||||
if (direction === 'left') {
|
||||
ctx.translate(x, 0);
|
||||
ctx.scale(-1, 1);
|
||||
ctx.translate(-x, 0);
|
||||
}
|
||||
ctx.drawImage(image, srcX, srcY, frameW, frameH, x - destW / 2, y - destH, destW, destH);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
80
src/pages/agent-office/canvas/TileMap.js
Normal file
80
src/pages/agent-office/canvas/TileMap.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// src/pages/agent-office/canvas/TileMap.js
|
||||
|
||||
/**
|
||||
* 타일맵 렌더러 — 바닥, 벽, 그리드를 테마 팔레트로 렌더링
|
||||
* 가구는 FurnitureRenderer가 별도 처리
|
||||
*/
|
||||
export class TileMap {
|
||||
constructor(mapData) {
|
||||
this.cols = mapData.cols;
|
||||
this.rows = mapData.rows;
|
||||
this.tileSize = mapData.tileSize;
|
||||
this.floor = mapData.floor;
|
||||
this.tileTypes = mapData.tileTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 바닥 + 벽 렌더링
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {object} theme - themes.js 에서 가져온 테마 객체
|
||||
* @param {number} scale - 줌 레벨
|
||||
* @param {number} offsetX - 패닝 X 오프셋
|
||||
* @param {number} offsetY - 패닝 Y 오프셋
|
||||
*/
|
||||
render(ctx, theme, scale, offsetX, offsetY) {
|
||||
const ts = this.tileSize * scale;
|
||||
|
||||
for (let r = 0; r < this.rows; r++) {
|
||||
for (let c = 0; c < this.cols; c++) {
|
||||
const tileType = this.floor[r][c];
|
||||
const x = c * ts + offsetX;
|
||||
const y = r * ts + offsetY;
|
||||
|
||||
// 화면 밖이면 스킵 (CSS 공간 기준 — DPR 변환 적용된 좌표계)
|
||||
if (x + ts < 0 || y + ts < 0 || x > ctx.canvas.clientWidth || y > ctx.canvas.clientHeight) continue;
|
||||
|
||||
if (tileType === 0) {
|
||||
// 벽
|
||||
ctx.fillStyle = theme.wall.color;
|
||||
ctx.fillRect(x, y, ts, ts);
|
||||
// 벽 하단 경계선
|
||||
ctx.fillStyle = theme.wall.border;
|
||||
ctx.fillRect(x, y + ts - scale, ts, scale);
|
||||
} else {
|
||||
// 바닥
|
||||
const isBreak = this.tileTypes[String(tileType)] === 'floor_break';
|
||||
ctx.fillStyle = isBreak ? theme.floor.color2 : theme.floor.color1;
|
||||
ctx.fillRect(x, y, ts, ts);
|
||||
|
||||
// 체커보드 패턴
|
||||
if ((r + c) % 2 === 0) {
|
||||
ctx.fillStyle = theme.floor.grid;
|
||||
ctx.fillRect(x, y, ts, ts);
|
||||
}
|
||||
|
||||
// 그리드 선
|
||||
ctx.strokeStyle = theme.floor.grid;
|
||||
ctx.lineWidth = scale * 0.5;
|
||||
ctx.strokeRect(x, y, ts, ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 화면 좌표 → 타일 좌표 변환 */
|
||||
screenToTile(screenX, screenY, scale, offsetX, offsetY) {
|
||||
const ts = this.tileSize * scale;
|
||||
const col = Math.floor((screenX - offsetX) / ts);
|
||||
const row = Math.floor((screenY - offsetY) / ts);
|
||||
return { col, row };
|
||||
}
|
||||
|
||||
/** 타일 좌표 → 화면 좌표 (타일 중앙) */
|
||||
tileToScreen(col, row, scale, offsetX, offsetY) {
|
||||
const ts = this.tileSize * scale;
|
||||
return {
|
||||
x: col * ts + offsetX + ts / 2,
|
||||
y: row * ts + offsetY + ts / 2
|
||||
};
|
||||
}
|
||||
}
|
||||
42
src/pages/agent-office/canvas/themes.js
Normal file
42
src/pages/agent-office/canvas/themes.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// src/pages/agent-office/canvas/themes.js
|
||||
|
||||
export const THEMES = {
|
||||
modern: {
|
||||
name: 'Modern',
|
||||
wall: { color: '#1a1a2e', border: '#333', accent: '#8b5cf6' },
|
||||
floor: { color1: '#2a2a3e', color2: '#323248', grid: 'rgba(255,255,255,0.03)' },
|
||||
furniture: { desk: '#3a3a5a', chair: '#4c1d95', monitor: '#5555aa', monitorScreen: '#1a3a5a', shelf: '#2a2a4e', table: '#3a3a5a', sofa: '#2a2a4e', coffee: '#3a3a2a' },
|
||||
decor: { plant: '#22c55e', pot: '#4a3a2a', lamp: '#fbbf24', ledStrip: '#8b5cf6' },
|
||||
lighting: { ambient: 'rgba(139,92,246,0.05)', glow: 'rgba(139,92,246,0.15)' },
|
||||
text: { primary: '#ffffff', secondary: '#aaaaaa', label: '#888888' },
|
||||
ui: { panelBg: '#111111', headerBg: '#1a1a2e', border: '#333333', accent: '#8b5cf6' }
|
||||
},
|
||||
retro: {
|
||||
name: 'Retro',
|
||||
wall: { color: '#2a1a0a', border: '#6a4a2a', accent: '#cc8844' },
|
||||
floor: { color1: '#4a3a1a', color2: '#3a2a10', grid: 'rgba(255,255,255,0.02)' },
|
||||
furniture: { desk: '#6a4a1a', chair: '#8a5a2a', monitor: '#555555', monitorScreen: '#1a3a1a', shelf: '#5a3a1a', table: '#5a4a2a', sofa: '#5a3a2a', coffee: '#4a3a1a' },
|
||||
decor: { plant: '#44aa44', pot: '#6a4a2a', lamp: '#ffcc66', brick: '#8a5a2a' },
|
||||
lighting: { ambient: 'rgba(255,200,100,0.05)', glow: 'rgba(255,200,100,0.2)' },
|
||||
text: { primary: '#ffe0b0', secondary: '#aa8866', label: '#887766' },
|
||||
ui: { panelBg: '#1a1008', headerBg: '#2a1a0a', border: '#4a3a2a', accent: '#cc8844' }
|
||||
},
|
||||
minimal: {
|
||||
name: 'Minimal',
|
||||
wall: { color: '#fafafa', border: '#dddddd', accent: '#3b82f6' },
|
||||
floor: { color1: '#e8e8e8', color2: '#f0f0f0', grid: 'rgba(0,0,0,0.04)' },
|
||||
furniture: { desk: '#ffffff', chair: '#e0e0e0', monitor: '#333333', monitorScreen: '#e0e8f0', shelf: '#f5f5f5', table: '#ffffff', sofa: '#e8e8e8', coffee: '#f0f0f0' },
|
||||
decor: { plant: '#86efac', pot: '#ffffff', lamp: '#fbbf24', window: '#e0f0ff' },
|
||||
lighting: { ambient: 'rgba(59,130,246,0.03)', glow: 'rgba(255,255,255,0.1)' },
|
||||
text: { primary: '#1a1a1a', secondary: '#666666', label: '#999999' },
|
||||
ui: { panelBg: '#ffffff', headerBg: '#fafafa', border: '#e0e0e0', accent: '#3b82f6' }
|
||||
}
|
||||
};
|
||||
|
||||
export function getTheme(name) {
|
||||
return THEMES[name] || THEMES.modern;
|
||||
}
|
||||
|
||||
export function getThemeNames() {
|
||||
return Object.entries(THEMES).map(([key, val]) => ({ key, name: val.name }));
|
||||
}
|
||||
164
src/pages/agent-office/components/CommandTab.jsx
Normal file
164
src/pages/agent-office/components/CommandTab.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
// src/pages/agent-office/components/CommandTab.jsx
|
||||
import { useState } from 'react';
|
||||
import { sendAgentCommand, approveAgentTask } from '../../../api';
|
||||
|
||||
const QUICK_ACTIONS = {
|
||||
stock: [{ action: 'fetch_news', label: 'Fetch News' }, { action: 'test_telegram', label: 'Test Telegram' }],
|
||||
music: [{ action: 'credits', label: 'Check Credits' }],
|
||||
blog: [{ action: 'list_trend_keywords', label: 'List Keywords' }],
|
||||
realestate: [{ action: 'dashboard', label: 'Dashboard' }],
|
||||
lotto: [{ action: 'status', label: 'Status' }, { action: 'curate_now', label: 'Curate Now' }]
|
||||
};
|
||||
|
||||
const PARAM_ACTIONS = {
|
||||
stock: { action: 'add_alert', label: 'Add Alert', placeholder: '{"symbol":"005930","target_price":70000,"direction":"above"}' },
|
||||
music: { action: 'compose', label: 'Compose', placeholder: 'jazzy lo-fi piano beat' },
|
||||
blog: { action: 'research', label: 'Research', placeholder: 'keyword to research' },
|
||||
realestate: { action: 'fetch_matches', label: 'Fetch Matches', placeholder: '' },
|
||||
lotto: null
|
||||
};
|
||||
|
||||
export default function CommandTab({ agentId, agentState, pendingTask, onCommandResult }) {
|
||||
const [customAction, setCustomAction] = useState('');
|
||||
const [customParams, setCustomParams] = useState('');
|
||||
const [paramInput, setParamInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const quickActions = QUICK_ACTIONS[agentId] || [];
|
||||
const paramAction = PARAM_ACTIONS[agentId];
|
||||
|
||||
const handleQuickAction = async (action) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await sendAgentCommand(agentId, action, {});
|
||||
onCommandResult?.(result);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleParamAction = async () => {
|
||||
if (!paramAction || !paramInput.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
let params = {};
|
||||
if (paramAction.action === 'compose') {
|
||||
params = { prompt: paramInput };
|
||||
} else if (paramAction.action === 'research') {
|
||||
params = { keyword: paramInput };
|
||||
} else {
|
||||
try { params = JSON.parse(paramInput); } catch { params = { value: paramInput }; }
|
||||
}
|
||||
const result = await sendAgentCommand(agentId, paramAction.action, params);
|
||||
onCommandResult?.(result);
|
||||
setParamInput('');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomCommand = async () => {
|
||||
if (!customAction.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
let params = {};
|
||||
if (customParams.trim()) {
|
||||
try { params = JSON.parse(customParams); } catch { params = { value: customParams }; }
|
||||
}
|
||||
const result = await sendAgentCommand(agentId, customAction, params);
|
||||
onCommandResult?.(result);
|
||||
setCustomAction('');
|
||||
setCustomParams('');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproval = async (approved) => {
|
||||
if (!pendingTask) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await approveAgentTask(agentId, pendingTask.id, approved);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ao-command-tab">
|
||||
{/* 승인 대기 UI */}
|
||||
{agentState === 'waiting' && pendingTask && (
|
||||
<div className="ao-approval-card">
|
||||
<div className="ao-approval-title">Awaiting Approval</div>
|
||||
<div className="ao-approval-desc">{pendingTask.task_type}: {pendingTask.detail || JSON.stringify(pendingTask.input_data)}</div>
|
||||
<div className="ao-approval-actions">
|
||||
<button className="ao-btn-approve" onClick={() => handleApproval(true)} disabled={loading}>Approve</button>
|
||||
<button className="ao-btn-reject" onClick={() => handleApproval(false)} disabled={loading}>Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="ao-section">
|
||||
<div className="ao-section-label">Quick Actions</div>
|
||||
<div className="ao-quick-actions">
|
||||
{quickActions.map(qa => (
|
||||
<button
|
||||
key={qa.action}
|
||||
className="ao-btn-quick"
|
||||
onClick={() => handleQuickAction(qa.action)}
|
||||
disabled={loading}
|
||||
>
|
||||
{qa.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameterized Action */}
|
||||
{paramAction && (
|
||||
<div className="ao-section">
|
||||
<div className="ao-section-label">{paramAction.label}</div>
|
||||
<div className="ao-param-row">
|
||||
<input
|
||||
className="ao-input"
|
||||
value={paramInput}
|
||||
onChange={e => setParamInput(e.target.value)}
|
||||
placeholder={paramAction.placeholder}
|
||||
onKeyDown={e => e.key === 'Enter' && handleParamAction()}
|
||||
/>
|
||||
<button className="ao-btn-send" onClick={handleParamAction} disabled={loading || !paramInput.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Command */}
|
||||
<div className="ao-section">
|
||||
<div className="ao-section-label">Custom Command</div>
|
||||
<input
|
||||
className="ao-input"
|
||||
value={customAction}
|
||||
onChange={e => setCustomAction(e.target.value)}
|
||||
placeholder="Action name"
|
||||
/>
|
||||
<input
|
||||
className="ao-input"
|
||||
value={customParams}
|
||||
onChange={e => setCustomParams(e.target.value)}
|
||||
placeholder='Parameters (JSON)'
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
<button
|
||||
className="ao-btn-send"
|
||||
onClick={handleCustomCommand}
|
||||
disabled={loading || !customAction.trim()}
|
||||
style={{ marginTop: 4, width: '100%' }}
|
||||
>
|
||||
Send Command
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/pages/agent-office/components/LogTab.jsx
Normal file
45
src/pages/agent-office/components/LogTab.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
// src/pages/agent-office/components/LogTab.jsx
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { getAgentLogs } from '../../../api';
|
||||
|
||||
const LEVEL_STYLE = {
|
||||
info: { color: '#60a5fa' },
|
||||
warning: { color: '#fbbf24' },
|
||||
error: { color: '#ef4444' }
|
||||
};
|
||||
|
||||
export default function LogTab({ agentId, refreshTrigger }) {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAgentLogs(agentId, 50).then(data => {
|
||||
if (!cancelled) setLogs(data || []);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [agentId, refreshTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
return (
|
||||
<div className="ao-log-tab" ref={scrollRef}>
|
||||
{logs.length === 0 && <div className="ao-empty">No logs yet</div>}
|
||||
{logs.map((log, i) => {
|
||||
const style = LEVEL_STYLE[log.level] || LEVEL_STYLE.info;
|
||||
const time = new Date(log.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
return (
|
||||
<div key={log.id || i} className="ao-log-item">
|
||||
<span className="ao-log-time">{time}</span>
|
||||
<span className="ao-log-level" style={style}>[{log.level}]</span>
|
||||
<span className="ao-log-msg">{log.message}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/pages/agent-office/components/SidePanel.jsx
Normal file
73
src/pages/agent-office/components/SidePanel.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// src/pages/agent-office/components/SidePanel.jsx
|
||||
import { useState } from 'react';
|
||||
import CommandTab from './CommandTab.jsx';
|
||||
import TaskTab from './TaskTab.jsx';
|
||||
import TokenTab from './TokenTab.jsx';
|
||||
import LogTab from './LogTab.jsx';
|
||||
|
||||
const AGENT_META = {
|
||||
stock: { displayName: '주식 트레이더', emoji: '📈', color: '#4488cc' },
|
||||
music: { displayName: '음악 프로듀서', emoji: '🎵', color: '#44aa88' },
|
||||
blog: { displayName: '블로그 마케터', emoji: '✍️', color: '#d97706' },
|
||||
realestate: { displayName: '청약 애널리스트', emoji: '🏢', color: '#c026d3' },
|
||||
lotto: { displayName: '로또 큐레이터', emoji: '🎱', color: '#ef4444' }
|
||||
};
|
||||
|
||||
const TABS = ['Commands', 'Tasks', 'Tokens', 'Logs'];
|
||||
|
||||
export default function SidePanel({ agentId, agentState, pendingTask, onClose, refreshTrigger }) {
|
||||
const [activeTab, setActiveTab] = useState('Commands');
|
||||
const meta = AGENT_META[agentId];
|
||||
if (!meta) return null;
|
||||
|
||||
const stateText = agentState?.detail
|
||||
? `${agentState.state} - ${agentState.detail}`
|
||||
: agentState?.state || 'unknown';
|
||||
|
||||
return (
|
||||
<div className="ao-sidepanel">
|
||||
{/* Header */}
|
||||
<div className="ao-sidepanel-header">
|
||||
<div className="ao-sidepanel-agent">
|
||||
<div className="ao-sidepanel-icon" style={{ background: meta.color }}>
|
||||
{meta.emoji}
|
||||
</div>
|
||||
<div className="ao-sidepanel-info">
|
||||
<div className="ao-sidepanel-name">{meta.displayName}</div>
|
||||
<div className="ao-sidepanel-state">● {stateText}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="ao-sidepanel-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="ao-sidepanel-tabs">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`ao-sidepanel-tab ${activeTab === tab ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="ao-sidepanel-content">
|
||||
{activeTab === 'Commands' && (
|
||||
<CommandTab agentId={agentId} agentState={agentState?.state} pendingTask={pendingTask} />
|
||||
)}
|
||||
{activeTab === 'Tasks' && (
|
||||
<TaskTab agentId={agentId} refreshTrigger={refreshTrigger} />
|
||||
)}
|
||||
{activeTab === 'Tokens' && (
|
||||
<TokenTab agentId={agentId} />
|
||||
)}
|
||||
{activeTab === 'Logs' && (
|
||||
<LogTab agentId={agentId} refreshTrigger={refreshTrigger} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/pages/agent-office/components/TaskTab.jsx
Normal file
60
src/pages/agent-office/components/TaskTab.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
// src/pages/agent-office/components/TaskTab.jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getAgentTasks } from '../../../api';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
succeeded: { bg: '#065f46', fg: '#34d399' },
|
||||
failed: { bg: '#7f1d1d', fg: '#fca5a5' },
|
||||
working: { bg: '#1e3a5f', fg: '#60a5fa' },
|
||||
pending: { bg: '#92400e', fg: '#fbbf24' },
|
||||
approved: { bg: '#065f46', fg: '#34d399' },
|
||||
rejected: { bg: '#7f1d1d', fg: '#fca5a5' }
|
||||
};
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
||||
return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
|
||||
}
|
||||
|
||||
export default function TaskTab({ agentId, refreshTrigger }) {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAgentTasks(agentId, 20).then(data => {
|
||||
if (!cancelled) setTasks(data || []);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [agentId, refreshTrigger]);
|
||||
|
||||
return (
|
||||
<div className="ao-task-tab">
|
||||
{tasks.length === 0 && <div className="ao-empty">No tasks yet</div>}
|
||||
{tasks.map(task => {
|
||||
const style = STATUS_STYLE[task.status] || STATUS_STYLE.pending;
|
||||
return (
|
||||
<div key={task.id} className="ao-task-item" onClick={() => setExpanded(expanded === task.id ? null : task.id)}>
|
||||
<div className="ao-task-header">
|
||||
<span className="ao-task-type">{task.task_type}</span>
|
||||
<span className="ao-task-badge" style={{ background: style.bg, color: style.fg }}>{task.status}</span>
|
||||
<span className="ao-task-time">{formatTime(task.created_at)}</span>
|
||||
</div>
|
||||
{expanded === task.id && task.result_data && (
|
||||
<pre className="ao-task-result">
|
||||
{(() => {
|
||||
try { return JSON.stringify(JSON.parse(task.result_data), null, 2); }
|
||||
catch { return task.result_data; }
|
||||
})()}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/pages/agent-office/components/TokenTab.jsx
Normal file
86
src/pages/agent-office/components/TokenTab.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
// src/pages/agent-office/components/TokenTab.jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getAgentTokenUsage } from '../../../api';
|
||||
|
||||
export default function TokenTab({ agentId }) {
|
||||
const [usage, setUsage] = useState(null);
|
||||
const [days, setDays] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAgentTokenUsage(agentId, days).then(data => {
|
||||
if (!cancelled) setUsage(data);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [agentId, days]);
|
||||
|
||||
if (!usage) return <div className="ao-empty">Loading...</div>;
|
||||
|
||||
const inputTokens = usage.input_tokens || 0;
|
||||
const outputTokens = usage.output_tokens || 0;
|
||||
const cacheRead = usage.cache_read || 0;
|
||||
const cacheWrite = usage.cache_write || 0;
|
||||
const total = inputTokens + outputTokens;
|
||||
const cacheHitRate = inputTokens > 0 ? Math.round((cacheRead / inputTokens) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="ao-token-tab">
|
||||
<div className="ao-token-period">
|
||||
{[1, 7, 30].map(d => (
|
||||
<button
|
||||
key={d}
|
||||
className={`ao-btn-period ${days === d ? 'active' : ''}`}
|
||||
onClick={() => setDays(d)}
|
||||
>
|
||||
{d === 1 ? 'Today' : d === 7 ? '7 Days' : '30 Days'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ao-token-grid">
|
||||
<div className="ao-token-card">
|
||||
<div className="ao-token-label">Input Tokens</div>
|
||||
<div className="ao-token-value">{inputTokens.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="ao-token-card">
|
||||
<div className="ao-token-label">Output Tokens</div>
|
||||
<div className="ao-token-value">{outputTokens.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="ao-token-card">
|
||||
<div className="ao-token-label">Total</div>
|
||||
<div className="ao-token-value">{total.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="ao-token-card">
|
||||
<div className="ao-token-label">Cache Hit Rate</div>
|
||||
<div className="ao-token-value">{cacheHitRate}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simple bar chart */}
|
||||
<div className="ao-token-bar">
|
||||
<div className="ao-token-bar-label">Input vs Output</div>
|
||||
<div className="ao-token-bar-track">
|
||||
<div
|
||||
className="ao-token-bar-fill input"
|
||||
style={{ width: total > 0 ? `${(inputTokens / total) * 100}%` : '0%' }}
|
||||
/>
|
||||
<div
|
||||
className="ao-token-bar-fill output"
|
||||
style={{ width: total > 0 ? `${(outputTokens / total) * 100}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="ao-token-bar-legend">
|
||||
<span><span className="dot input" />Input</span>
|
||||
<span><span className="dot output" />Output</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cacheRead > 0 && (
|
||||
<div className="ao-token-detail">
|
||||
<span>Cache Read: {cacheRead.toLocaleString()}</span>
|
||||
<span>Cache Write: {cacheWrite.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/pages/agent-office/components/TopBar.jsx
Normal file
33
src/pages/agent-office/components/TopBar.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
// src/pages/agent-office/components/TopBar.jsx
|
||||
import { getThemeNames } from '../canvas/themes.js';
|
||||
|
||||
export default function TopBar({ connected, theme, onThemeChange, zoom, onZoomChange }) {
|
||||
const themes = getThemeNames();
|
||||
|
||||
return (
|
||||
<div className="ao-topbar">
|
||||
<div className="ao-topbar-left">
|
||||
<span className="ao-topbar-title">Agent Office</span>
|
||||
<span className={`ao-topbar-status ${connected ? 'connected' : 'disconnected'}`}>
|
||||
● {connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ao-topbar-right">
|
||||
<select
|
||||
className="ao-topbar-select"
|
||||
value={theme}
|
||||
onChange={(e) => onThemeChange(e.target.value)}
|
||||
>
|
||||
{themes.map(t => (
|
||||
<option key={t.key} value={t.key}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="ao-topbar-zoom">
|
||||
<button onClick={() => onZoomChange(Math.max(1, zoom - 0.5))} disabled={zoom <= 1}>-</button>
|
||||
<span>{zoom}x</span>
|
||||
<button onClick={() => onZoomChange(Math.min(4, zoom + 0.5))} disabled={zoom >= 4}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/pages/agent-office/hooks/useAgentManager.js
Normal file
111
src/pages/agent-office/hooks/useAgentManager.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// src/pages/agent-office/hooks/useAgentManager.js
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
const WS_RECONNECT_DELAY = 3000;
|
||||
|
||||
export function useAgentManager() {
|
||||
const [agents, setAgents] = useState({}); // { agentId: { state, detail, task_id } }
|
||||
const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}]
|
||||
const [notifications, setNotifications] = useState({}); // { agentId: count }
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용
|
||||
|
||||
const wsRef = useRef(null);
|
||||
const reconnectRef = useRef(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => setConnected(true);
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
// 에이전트 초기 상태 세팅
|
||||
const agentMap = {};
|
||||
for (const a of msg.agents) {
|
||||
agentMap[a.agent_id] = { state: a.state, detail: a.detail || '', task_id: a.task_id };
|
||||
}
|
||||
setAgents(agentMap);
|
||||
setPendingTasks(msg.pending || []);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agent_state':
|
||||
setAgents(prev => ({
|
||||
...prev,
|
||||
[msg.agent]: { state: msg.state, detail: msg.detail || '', task_id: msg.task_id }
|
||||
}));
|
||||
// idle 전환 시 데이터 리프레시
|
||||
if (msg.state === 'idle') {
|
||||
setRefreshTrigger(n => n + 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task_complete':
|
||||
setRefreshTrigger(n => n + 1);
|
||||
break;
|
||||
|
||||
case 'notification':
|
||||
setNotifications(prev => ({
|
||||
...prev,
|
||||
[msg.agent]: (prev[msg.agent] || 0) + 1
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'command_result':
|
||||
// 사이드 패널에서 처리
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
reconnectRef.current = setTimeout(connect, WS_RECONNECT_DELAY);
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
if (reconnectRef.current) clearTimeout(reconnectRef.current);
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const sendCommand = useCallback((agent, action, params = {}) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'command', agent, action, params }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendApproval = useCallback((agent, taskId, approved) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'approval', agent, task_id: taskId, approved }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearNotifications = useCallback((agentId) => {
|
||||
setNotifications(prev => ({ ...prev, [agentId]: 0 }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
agents,
|
||||
pendingTasks,
|
||||
notifications,
|
||||
connected,
|
||||
refreshTrigger,
|
||||
sendCommand,
|
||||
sendApproval,
|
||||
clearNotifications
|
||||
};
|
||||
}
|
||||
64
src/pages/agent-office/hooks/useOfficeCanvas.js
Normal file
64
src/pages/agent-office/hooks/useOfficeCanvas.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// src/pages/agent-office/hooks/useOfficeCanvas.js
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { OfficeRenderer } from '../canvas/OfficeRenderer.js';
|
||||
|
||||
export function useOfficeCanvas() {
|
||||
const canvasRef = useRef(null);
|
||||
const rendererRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const renderer = new OfficeRenderer(canvasRef.current);
|
||||
rendererRef.current = renderer;
|
||||
renderer.start();
|
||||
|
||||
const handleResize = () => renderer.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
renderer.destroy();
|
||||
rendererRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateAgentState = useCallback((agentId, state, detail) => {
|
||||
rendererRef.current?.updateAgentState(agentId, state, detail);
|
||||
}, []);
|
||||
|
||||
const setAgentNotification = useCallback((agentId, count) => {
|
||||
rendererRef.current?.setAgentNotification(agentId, count);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((themeName) => {
|
||||
rendererRef.current?.setTheme(themeName);
|
||||
}, []);
|
||||
|
||||
const setZoom = useCallback((level) => {
|
||||
rendererRef.current?.setZoom(level);
|
||||
}, []);
|
||||
|
||||
const hitTest = useCallback((clientX, clientY) => {
|
||||
return rendererRef.current?.hitTest(clientX, clientY) || { type: 'empty' };
|
||||
}, []);
|
||||
|
||||
const getZoom = useCallback(() => {
|
||||
return rendererRef.current?.zoom || 2;
|
||||
}, []);
|
||||
|
||||
const wasDragging = useCallback(() => {
|
||||
return rendererRef.current?.wasDragging?.() || false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
canvasRef,
|
||||
updateAgentState,
|
||||
setAgentNotification,
|
||||
setTheme,
|
||||
setZoom,
|
||||
hitTest,
|
||||
getZoom,
|
||||
wasDragging
|
||||
};
|
||||
}
|
||||
@@ -125,14 +125,30 @@
|
||||
.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-header h1 { font-size: 1.2rem; }
|
||||
.bm-status { display: none; }
|
||||
.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-analysis-card__scores { gap: 10px; }
|
||||
.bm-write-actions { flex-direction: column; }
|
||||
.bm-post-card__actions { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bm-spinner { animation: none; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import {
|
||||
getBlogMarketingStatus,
|
||||
startResearch,
|
||||
@@ -84,10 +86,14 @@ export default function BlogMarketing() {
|
||||
const [tab, setTab] = useState('dashboard');
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
getBlogMarketingStatus().then(setStatus).catch(() => {});
|
||||
const loadStatus = useCallback(() => {
|
||||
return getBlogMarketingStatus().then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'research', label: 'Research' },
|
||||
@@ -96,6 +102,7 @@ export default function BlogMarketing() {
|
||||
];
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadStatus}>
|
||||
<div className="bm">
|
||||
<header className="bm-header">
|
||||
<h1>Blog Lab</h1>
|
||||
@@ -124,10 +131,13 @@ export default function BlogMarketing() {
|
||||
</nav>
|
||||
|
||||
{tab === 'dashboard' && <DashboardTab />}
|
||||
{tab === 'research' && <ResearchTab onGenerate={(id) => { setTab('write'); }} />}
|
||||
{tab === 'research' && <ResearchTab />}
|
||||
{tab === 'write' && <WriteTab />}
|
||||
{tab === 'posts' && <PostsTab />}
|
||||
|
||||
<FAB onClick={() => setTab('research')} label="키워드 분석" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
display: none;
|
||||
position: fixed;
|
||||
/* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */
|
||||
bottom: 24px;
|
||||
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
|
||||
right: 24px;
|
||||
top: auto;
|
||||
left: auto;
|
||||
@@ -451,9 +451,8 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.blog-header,
|
||||
.blog-grid {
|
||||
@media (max-width: 768px) {
|
||||
.blog-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -469,10 +468,10 @@
|
||||
|
||||
.blog-list {
|
||||
display: none;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.blog-list.is-visible {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -490,6 +489,13 @@
|
||||
|
||||
.blog-list.is-visible .blog-category-filter {
|
||||
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 {
|
||||
@@ -498,22 +504,18 @@
|
||||
|
||||
.blog-article {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.blog-header h1 {
|
||||
font-size: clamp(24px, 6vw, 32px);
|
||||
}
|
||||
|
||||
.blog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.blog-list {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.blog-list__item-btn {
|
||||
padding: 14px;
|
||||
}
|
||||
@@ -526,10 +528,6 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.blog-article {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.blog-article__body h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -766,4 +764,19 @@
|
||||
align-self: stretch;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
updateBlogPost,
|
||||
deleteBlogPost,
|
||||
} from '../../api';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import './Blog.css';
|
||||
|
||||
// ── 마크다운 렌더러 ──────────────────────────────────────────────────────────
|
||||
@@ -359,9 +361,8 @@ const Blog = () => {
|
||||
const [editorPost, setEditorPost] = useState(null); // null=닫힘, {}=새글, post=수정
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
// API 글 불러오기
|
||||
useEffect(() => {
|
||||
getBlogPostsApi()
|
||||
const fetchPosts = useCallback(() => {
|
||||
return getBlogPostsApi()
|
||||
.then((data) => {
|
||||
const posts = Array.isArray(data) ? data : (data?.posts ?? []);
|
||||
setApiPosts(posts.map((p) => ({ ...p, slug: `api-${p.id}` })));
|
||||
@@ -369,6 +370,11 @@ const Blog = () => {
|
||||
.catch(() => setApiError(true));
|
||||
}, []);
|
||||
|
||||
// API 글 불러오기
|
||||
useEffect(() => {
|
||||
fetchPosts();
|
||||
}, [fetchPosts]);
|
||||
|
||||
// 정적 + API 글 병합 (API 글이 앞에 표시)
|
||||
const allPosts = useMemo(() => {
|
||||
const combined = [...apiPosts, ...staticPosts];
|
||||
@@ -450,6 +456,7 @@ const Blog = () => {
|
||||
const closeEditor = useCallback(() => { setIsEditorOpen(false); setEditorPost(null); }, []);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={fetchPosts}>
|
||||
<div className="blog">
|
||||
<header className="blog-header">
|
||||
<div>
|
||||
@@ -651,7 +658,10 @@ const Blog = () => {
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FAB onClick={openNewEditor} label="글 쓰기" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,17 @@ const LAB_ITEMS = [
|
||||
icon: '📅',
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
id: 'agent-office',
|
||||
path: '/agent-office',
|
||||
title: 'Agent Office',
|
||||
category: 'AI · 자동화',
|
||||
desc: 'AI 에이전트들이 사무실에서 자동으로 작업하는 가상 오피스',
|
||||
tags: ['Canvas 2D', 'WebSocket', 'AI Agent', 'Telegram'],
|
||||
accent: '#8b5cf6',
|
||||
icon: '🏢',
|
||||
status: 'wip',
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_LABEL = {
|
||||
|
||||
@@ -80,3 +80,14 @@
|
||||
max-width: 400px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sword-stream {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sword-stream__overlay {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,7 +727,7 @@
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@media (max-width: 1024px) {
|
||||
.home-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -803,15 +803,27 @@
|
||||
.home-profile__name {
|
||||
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) {
|
||||
.home-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.home-hero__stats {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { navLinks } from '../../routes.jsx';
|
||||
import { getBlogPosts } from '../../data/blog';
|
||||
import { getTodos } from '../../api';
|
||||
import { getCurrentTheme } from '../../data/heroConfig';
|
||||
import myPhoto from '../../assets/myPhoto.jpg';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import SwipeableView from '../../components/SwipeableView';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import './Home.css';
|
||||
|
||||
const TODO_COLUMNS = [
|
||||
@@ -17,22 +20,24 @@ const Home = () => {
|
||||
const posts = getBlogPosts().slice(0, 3);
|
||||
const highlights = navLinks.filter((link) => link.id !== 'home');
|
||||
const theme = getCurrentTheme();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [todosByStatus, setTodosByStatus] = useState({ todo: [], in_progress: [], done: [] });
|
||||
|
||||
useEffect(() => {
|
||||
getTodos()
|
||||
.then((data) => {
|
||||
if (!Array.isArray(data)) return;
|
||||
setTodosByStatus({
|
||||
todo: data.filter((t) => t.status === 'todo'),
|
||||
in_progress: data.filter((t) => t.status === 'in_progress'),
|
||||
done: data.filter((t) => t.status === 'done'),
|
||||
});
|
||||
})
|
||||
.catch(() => { /* 조용히 실패 */ });
|
||||
const loadTodos = useCallback(async () => {
|
||||
const data = await getTodos();
|
||||
if (!Array.isArray(data)) return;
|
||||
setTodosByStatus({
|
||||
todo: data.filter((t) => t.status === 'todo'),
|
||||
in_progress: data.filter((t) => t.status === 'in_progress'),
|
||||
done: data.filter((t) => t.status === 'done'),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTodos().catch(() => { /* 조용히 실패 */ });
|
||||
}, [loadTodos]);
|
||||
|
||||
const totalTasks = todosByStatus.todo.length + todosByStatus.in_progress.length + todosByStatus.done.length;
|
||||
const doneTasks = todosByStatus.done.length;
|
||||
const inProgress = todosByStatus.in_progress.length;
|
||||
@@ -132,7 +137,79 @@ const Home = () => {
|
||||
<h2>TODO</h2>
|
||||
<p>계획 · 진행 중 · 완료 태스크를 한눈에 확인합니다.</p>
|
||||
</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 className="home-section">
|
||||
|
||||
@@ -1,460 +1,56 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
fmtKST, Ball, NumberRow, copyNumbers,
|
||||
buildMetricsFromFrequency, BEST_PICKS_DEFAULT_SHOW,
|
||||
} from './lottoUtils';
|
||||
import { useCallback, useState } from 'react';
|
||||
import BriefingTab from './tabs/BriefingTab';
|
||||
import AnalysisTab from './tabs/AnalysisTab';
|
||||
import PurchaseTab from './tabs/PurchaseTab';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import SwipeableView from '../../components/SwipeableView';
|
||||
|
||||
/* ── hooks ──────────────────────────────────────────────────────── */
|
||||
import useLottoData from './hooks/useLottoData';
|
||||
import usePurchases from './hooks/usePurchases';
|
||||
import useManualRecommend from './hooks/useManualRecommend';
|
||||
const TABS = [
|
||||
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
|
||||
{ id: 'analysis', label: '📊 분석·통계' },
|
||||
{ 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() {
|
||||
const ld = useLottoData();
|
||||
const pur = usePurchases();
|
||||
const mr = useManualRecommend();
|
||||
const [tab, setTab] = useState('briefing');
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
/* ── derived ────────────────────────────────────────────────── */
|
||||
const overallMetrics = useMemo(() => buildMetricsFromFrequency(ld.stats?.frequency), [ld.stats]);
|
||||
const visibleBestPicks = ld.bestPicksExpanded ? ld.bestPicks : ld.bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
|
||||
const tabIndex = TABS.findIndex(t => t.id === tab);
|
||||
|
||||
/* ── merged error ───────────────────────────────────────────── */
|
||||
const error = ld.error || mr.error;
|
||||
const clearError = () => { ld.setError(''); mr.setError(''); };
|
||||
const handleTabChange = useCallback((index) => {
|
||||
setTab(TABS[index].id);
|
||||
}, []);
|
||||
|
||||
/* ── render ──────────────────────────────────────────────────── */
|
||||
return (
|
||||
<div className="lotto-functions">
|
||||
{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} />
|
||||
|
||||
{/* ── 구매 기록 ── */}
|
||||
<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>
|
||||
{isMobile ? (
|
||||
<SwipeableView
|
||||
tabs={TABS.map(t => ({
|
||||
key: t.id,
|
||||
label: t.label,
|
||||
content: t.id === 'briefing' ? <BriefingTab /> : t.id === 'analysis' ? <AnalysisTab /> : <PurchaseTab />,
|
||||
}))}
|
||||
activeIndex={tabIndex}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<nav className="lotto-tabs">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={tab === t.id ? 'active' : ''}
|
||||
onClick={() => setTab(t.id)}
|
||||
>{t.label}</button>
|
||||
))}
|
||||
<span ref={mr.historyEndRef} />
|
||||
</nav>
|
||||
<div className="lotto-tab-body">
|
||||
{tab === 'briefing' && <BriefingTab />}
|
||||
{tab === 'analysis' && <AnalysisTab />}
|
||||
{tab === 'purchase' && <PurchaseTab />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1074,41 +1074,7 @@
|
||||
|
||||
/* ── 반응형 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.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) {
|
||||
@media (max-width: 480px) {
|
||||
.lotto-purchase-stats {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1157,6 +1123,34 @@
|
||||
}
|
||||
|
||||
@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 {
|
||||
font-size: clamp(24px, 6vw, 32px);
|
||||
}
|
||||
@@ -1181,9 +1175,9 @@
|
||||
}
|
||||
|
||||
.lotto-ball {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.lotto-meta__title {
|
||||
@@ -1191,6 +1185,7 @@
|
||||
}
|
||||
|
||||
.lotto-history__item {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -1459,7 +1454,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 480px) {
|
||||
.lotto-combined__method {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -1475,3 +1470,59 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
12
src/pages/lotto/components/briefing/BriefingEmpty.jsx
Normal file
12
src/pages/lotto/components/briefing/BriefingEmpty.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/pages/lotto/components/briefing/BriefingHeader.jsx
Normal file
28
src/pages/lotto/components/briefing/BriefingHeader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/pages/lotto/components/briefing/BriefingSummary.jsx
Normal file
16
src/pages/lotto/components/briefing/BriefingSummary.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/pages/lotto/components/briefing/CuratorUsageFooter.jsx
Normal file
17
src/pages/lotto/components/briefing/CuratorUsageFooter.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/pages/lotto/components/briefing/PickSetCard.jsx
Normal file
18
src/pages/lotto/components/briefing/PickSetCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/pages/lotto/components/briefing/pricing.js
Normal file
23
src/pages/lotto/components/briefing/pricing.js
Normal 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);
|
||||
}
|
||||
56
src/pages/lotto/hooks/useBriefing.js
Normal file
56
src/pages/lotto/hooks/useBriefing.js
Normal 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 };
|
||||
}
|
||||
17
src/pages/lotto/hooks/useCuratorUsage.js
Normal file
17
src/pages/lotto/hooks/useCuratorUsage.js
Normal 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 };
|
||||
}
|
||||
428
src/pages/lotto/tabs/AnalysisTab.jsx
Normal file
428
src/pages/lotto/tabs/AnalysisTab.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
src/pages/lotto/tabs/BriefingTab.jsx
Normal file
25
src/pages/lotto/tabs/BriefingTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/pages/lotto/tabs/PurchaseTab.jsx
Normal file
25
src/pages/lotto/tabs/PurchaseTab.jsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -317,7 +317,7 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@media (max-width: 1024px) {
|
||||
.ms-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -487,7 +487,7 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 480px) {
|
||||
.ms-genre-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@@ -1696,7 +1696,19 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
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 {
|
||||
font-size: clamp(44px, 14vw, 70px);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
generateStyleBoost,
|
||||
generateVideo,
|
||||
} from '../../api';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import './MusicStudio.css';
|
||||
import AudioPlayer from './components/AudioPlayer';
|
||||
import { fmtTime } from './components/AudioPlayer';
|
||||
@@ -1123,6 +1125,7 @@ export default function MusicStudio() {
|
||||
|
||||
{/* ═══ LIBRARY TAB ═══ */}
|
||||
{tab === 'library' && (
|
||||
<PullToRefresh onRefresh={loadLibrary}>
|
||||
<Library
|
||||
tracks={library}
|
||||
loading={libLoading}
|
||||
@@ -1137,6 +1140,7 @@ export default function MusicStudio() {
|
||||
onVideoGenerate={handleVideoGenerate}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
)}
|
||||
|
||||
{/* ═══ LYRICS TAB ═══ */}
|
||||
@@ -1760,6 +1764,10 @@ export default function MusicStudio() {
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'library' && (
|
||||
<FAB onClick={() => setTab('create')} label="음악 생성" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -952,13 +952,13 @@
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@media (max-width: 1024px) {
|
||||
.re-list-layout {
|
||||
grid-template-columns: 1fr 340px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 768px) {
|
||||
.re-list-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -967,9 +967,6 @@
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.re-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -2943,3 +2943,41 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { getStockIndices, getStockNews, getFearAndGreed, getVix, getTreasury10Y, getWTI, getBrent } from '../../api';
|
||||
import Loading from '../../components/Loading';
|
||||
import FearGreedGauge from '../../components/FearGreedGauge';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import './Stock.css';
|
||||
|
||||
const formatDate = (value) => {
|
||||
@@ -109,6 +112,7 @@ const getVixLevel = (score) => {
|
||||
};
|
||||
|
||||
const Stock = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [newsDomestic, setNewsDomestic] = useState([]);
|
||||
const [newsOverseas, setNewsOverseas] = useState([]);
|
||||
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 () => {
|
||||
setIndicesLoading(true);
|
||||
setIndicesError('');
|
||||
@@ -217,6 +225,7 @@ const Stock = () => {
|
||||
newsCategory === 'domestic' ? newsDomestic : newsOverseas;
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div className="stock">
|
||||
<header className="stock-header">
|
||||
<div>
|
||||
@@ -559,6 +568,13 @@ const Stock = () => {
|
||||
)}
|
||||
</section>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import './Stock.css';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import SwipeableView from '../../components/SwipeableView';
|
||||
import {
|
||||
formatNumber, formatPercent,
|
||||
toNumeric, profitColorClass,
|
||||
@@ -28,6 +30,12 @@ import SellHistoryDrawer from './components/SellHistoryDrawer';
|
||||
|
||||
const StockTrade = () => {
|
||||
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 ────────────────────────────────────────────────────── */
|
||||
const pf = usePortfolio();
|
||||
@@ -166,35 +174,54 @@ const StockTrade = () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="stock-main-tabs">
|
||||
{[
|
||||
{ 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 bar + Tab content */}
|
||||
{isMobile ? (
|
||||
<SwipeableView
|
||||
tabs={TAB_ORDER.map((tabId, i) => ({
|
||||
key: tabId,
|
||||
label: tabLabels[i],
|
||||
content: tabId === TAB_PORTFOLIO
|
||||
? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
|
||||
: tabId === TAB_AI
|
||||
? <AiTradeTab aib={aib} />
|
||||
: tabId === TAB_REPORT
|
||||
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
|
||||
: <AdvisorTab pf={pf} advisor={advisor} />,
|
||||
}))}
|
||||
activeIndex={tabIndex}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="stock-main-tabs">
|
||||
{[
|
||||
{ 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 && (
|
||||
<PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
|
||||
{activeTab === TAB_PORTFOLIO && (
|
||||
<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) */}
|
||||
<SellHistoryDrawer
|
||||
|
||||
@@ -96,7 +96,7 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
평균 매입가 (원)
|
||||
평균단가 (원)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -108,6 +108,19 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||
required
|
||||
/>
|
||||
</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
|
||||
className="button primary"
|
||||
type="submit"
|
||||
@@ -386,7 +399,8 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||
</p>
|
||||
<h3>{broker} 보유 현황</h3>
|
||||
<p className="stock-panel__sub">
|
||||
{items.length}종목 · 평가{' '}
|
||||
{items.length}종목 · 총 매입{' '}
|
||||
{formatNumber(bSummary.totalBuy)} · 평가{' '}
|
||||
{formatNumber(bSummary.totalEval)} · 손익{' '}
|
||||
<span className={`stock-profit ${profitColorClass(bSummary.totalProfit)}`}>
|
||||
{formatNumber(bSummary.totalProfit)} (
|
||||
@@ -435,7 +449,7 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
평균매입가
|
||||
평균단가
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -448,6 +462,20 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||
}
|
||||
/>
|
||||
</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 className="pf-edit-actions">
|
||||
<button
|
||||
@@ -480,9 +508,13 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||
<strong>{formatNumber(item.quantity)}</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>매입가</span>
|
||||
<span>평균단가</span>
|
||||
<strong>{formatNumber(item.avg_price)}</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>매입가</span>
|
||||
<strong>{formatNumber(item.purchase_price ?? item.avg_price)}</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>현재가</span>
|
||||
<strong className={item.current_price == null ? 'pf-null-price' : ''}>
|
||||
|
||||
@@ -70,14 +70,20 @@ export default function usePortfolio() {
|
||||
}, [brokerGroups]);
|
||||
|
||||
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) {
|
||||
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;
|
||||
else hasNullPrice = true;
|
||||
}
|
||||
const totalProfit = totalEvalAmt - totalBuy;
|
||||
const totalProfitRate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
|
||||
const totalProfit = totalEvalAmt - totalCostBasis;
|
||||
const totalProfitRate = totalCostBasis > 0 ? (totalProfit / totalCostBasis) * 100 : 0;
|
||||
return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice };
|
||||
};
|
||||
|
||||
@@ -108,6 +114,9 @@ export default function usePortfolio() {
|
||||
name: addForm.name.trim(),
|
||||
quantity: Number(addForm.quantity),
|
||||
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 });
|
||||
setAddFormOpen(false);
|
||||
@@ -121,7 +130,13 @@ export default function usePortfolio() {
|
||||
|
||||
const handleEditStart = (item) => {
|
||||
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);
|
||||
editOrigRef.current = { ...data };
|
||||
};
|
||||
|
||||
@@ -95,6 +95,7 @@ export const emptyPortfolioForm = {
|
||||
name: '',
|
||||
quantity: '',
|
||||
avg_price: '',
|
||||
purchase_price: '',
|
||||
};
|
||||
|
||||
/* ── empty sell-history form ─────────────────────────────────────── */
|
||||
|
||||
@@ -1139,19 +1139,16 @@
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@media (max-width: 1024px) {
|
||||
.sub-list-layout { grid-template-columns: 1fr 360px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 768px) {
|
||||
.sub-list-layout { grid-template-columns: 1fr; }
|
||||
.sub-detail-panel { position: static; }
|
||||
.sub-profile-card { grid-template-columns: 1fr; }
|
||||
.sub-profile-card__right { flex-direction: column; align-items: flex-start; }
|
||||
.sub-profile-score__breakdown { min-width: 0; width: 100%; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sub-header { grid-template-columns: 1fr; }
|
||||
.sub-stats-bar { display: grid; grid-template-columns: repeat(2, 1fr); }
|
||||
.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-sched-row { grid-template-columns: 90px 10px 1fr; gap: 0 10px; }
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
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) => {
|
||||
try {
|
||||
const updated = await apiPatch(`/api/realestate/announcements/${id}/bookmark`);
|
||||
@@ -680,6 +694,14 @@ function AnnouncementsTab() {
|
||||
onChange={(e) => { setRegionFilter(e.target.value); setPage(1); }}
|
||||
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>
|
||||
|
||||
@@ -1276,8 +1298,18 @@ function ProfileTab() {
|
||||
// ── Subscription (Main) ──────────────────────────────────────────────────────
|
||||
function Subscription() {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
const handleFABClick = useCallback(() => {
|
||||
setActiveTab(1); // 공고 목록 탭으로 이동
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div className="sub">
|
||||
{/* Header */}
|
||||
<div className="sub-header">
|
||||
@@ -1307,12 +1339,15 @@ function Subscription() {
|
||||
|
||||
{/* Body */}
|
||||
<div className="sub-body">
|
||||
{activeTab === 0 && <DashboardTab />}
|
||||
{activeTab === 1 && <AnnouncementsTab />}
|
||||
{activeTab === 2 && <MatchesTab />}
|
||||
{activeTab === 3 && <ProfileTab />}
|
||||
{activeTab === 0 && <DashboardTab key={`dash-${refreshKey}`} />}
|
||||
{activeTab === 1 && <AnnouncementsTab key={`ann-${refreshKey}`} />}
|
||||
{activeTab === 2 && <MatchesTab key={`match-${refreshKey}`} />}
|
||||
{activeTab === 3 && <ProfileTab key={`prof-${refreshKey}`} />}
|
||||
</div>
|
||||
|
||||
<FAB onClick={handleFABClick} label="공고 목록" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -222,8 +222,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
@@ -233,6 +233,7 @@
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.todo-card__btn:hover {
|
||||
@@ -288,6 +289,24 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.todo-done-panel__clear-btn {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.15s ease;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.todo-done-panel__clear-btn:active {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* ── 날짜 필터 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.todo-done-panel__filter {
|
||||
@@ -311,6 +330,7 @@
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.todo-date-btn:hover {
|
||||
@@ -370,30 +390,183 @@
|
||||
text-decoration-color: rgba(244, 114, 182, 0.4);
|
||||
}
|
||||
|
||||
/* ── 스와이프 보드 (모바일 전용) ──────────────────────────────────────── */
|
||||
|
||||
.todo-swipe-board {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.todo-board {
|
||||
grid-template-columns: 1fr;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.todo-swipe-board {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.todo-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.todo-col {
|
||||
min-height: 120px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.todo-col__head {
|
||||
padding: 10px 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.todo-col__body {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* 카드 버튼 44px 터치 타겟 */
|
||||
.todo-card__btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.todo-card__actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.todo-card__title {
|
||||
font-size: 15px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 날짜 필터 버튼 44px 터치 타겟 */
|
||||
.todo-date-btn {
|
||||
font-size: 12px;
|
||||
padding: 8px 14px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.todo-date-input {
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.todo-done-panel {
|
||||
border: none;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.todo-done-panel__head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.todo-done-panel__filter {
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.todo-done-panel__body {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* 폼 라벨 가독성 */
|
||||
.todo-form__field {
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.todo-form__field input,
|
||||
.todo-form__field textarea {
|
||||
font-size: 16px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.todo-form__actions .button {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 빈 상태 메시지 */
|
||||
.todo-col__empty {
|
||||
font-size: 13px;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
/* 라벨 버튼 (모바일) */
|
||||
.todo-card__btn--labeled {
|
||||
width: auto;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.todo-card__btn-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.todo-card__actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 확인 시트 (모바일) ─────────────────────────────────────────────── */
|
||||
|
||||
.todo-confirm-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.todo-confirm-sheet__msg {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.todo-confirm-sheet__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.todo-confirm-sheet__actions .button {
|
||||
flex: 1;
|
||||
min-height: 48px;
|
||||
font-size: 15px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
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';
|
||||
|
||||
const ACTIVE_COLUMNS = [
|
||||
@@ -19,15 +24,18 @@ const toDateStr = (iso) => {
|
||||
};
|
||||
|
||||
const Todo = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [todos, setTodos] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dragging, setDragging] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(null);
|
||||
const [doneDate, setDoneDate] = useState(''); // '' = 전체
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const dragItem = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -86,6 +94,7 @@ const Todo = () => {
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setConfirmClear(false);
|
||||
try {
|
||||
await clearTodos();
|
||||
setTodos((prev) => prev.filter((t) => t.status !== 'done'));
|
||||
@@ -165,27 +174,90 @@ const Todo = () => {
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className="todo-card__btn"
|
||||
className={`todo-card__btn${isMobile ? ' todo-card__btn--labeled' : ''}`}
|
||||
title={`${c.label}으로 이동`}
|
||||
onClick={() => handleMove(todo.id, c.id)}
|
||||
>
|
||||
{c.id === 'todo' ? '↩' : c.id === 'in_progress' ? '▶' : '✓'}
|
||||
<span className="todo-card__btn-icon">{c.id === 'todo' ? '↩' : c.id === 'in_progress' ? '▶' : '✓'}</span>
|
||||
{isMobile && <span className="todo-card__btn-label">{c.label}</span>}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="todo-card__btn todo-card__btn--danger"
|
||||
className={`todo-card__btn todo-card__btn--danger${isMobile ? ' todo-card__btn--labeled' : ''}`}
|
||||
title="삭제"
|
||||
onClick={() => handleDelete(todo.id)}
|
||||
>
|
||||
✕
|
||||
<span className="todo-card__btn-icon">✕</span>
|
||||
{isMobile && <span className="todo-card__btn-label">삭제</span>}
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
{isMobile ? '아직 항목이 없습니다' : '드래그하여 이동'}
|
||||
</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 (
|
||||
<PullToRefresh onRefresh={load}>
|
||||
<div className="todo-page">
|
||||
{/* 툴바 */}
|
||||
<div className="todo-toolbar">
|
||||
@@ -194,138 +266,164 @@ const Todo = () => {
|
||||
className="button primary"
|
||||
onClick={() => setFormOpen((v) => !v)}
|
||||
>
|
||||
{formOpen ? '취소' : '+ 태스크 추가'}
|
||||
{formOpen ? '취소' : '+ 할일 추가'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button ghost"
|
||||
onClick={handleClear}
|
||||
onClick={() => {
|
||||
const doneCount = todos.filter(t => t.status === 'done').length;
|
||||
if (doneCount === 0) return;
|
||||
if (isMobile) { setConfirmClear(true); } else { handleClear(); }
|
||||
}}
|
||||
>
|
||||
완료 비우기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 추가 폼 */}
|
||||
{formOpen && (
|
||||
<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>
|
||||
)}
|
||||
{/* 추가 폼 (데스크탑) */}
|
||||
{formOpen && !isMobile && addForm}
|
||||
|
||||
{error && <p className="todo-error">{error}</p>}
|
||||
{loading && todos.length === 0 && <p className="todo-loading">불러오는 중...</p>}
|
||||
|
||||
{/* 활성 보드 (할 일 + 진행 중) */}
|
||||
<div className="todo-board">
|
||||
{ACTIVE_COLUMNS.map((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>
|
||||
{/* 모바일: SwipeableView 칸반 */}
|
||||
{isMobile ? (
|
||||
<div className="todo-swipe-board">
|
||||
<SwipeableView
|
||||
tabs={[
|
||||
{ key: 'todo', label: '할 일', content: renderColumn({ id: 'todo', label: '할 일' }) },
|
||||
{ key: 'in_progress', label: '진행 중', content: renderColumn({ id: 'in_progress', label: '진행 중' }) },
|
||||
{ key: 'done', label: '완료', content: (
|
||||
<div className="todo-done-panel">
|
||||
<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>
|
||||
{doneTodos.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="todo-done-panel__clear-btn"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
>
|
||||
비우기
|
||||
</button>
|
||||
)}
|
||||
</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 className="todo-done-panel__body">
|
||||
{doneTodos.length === 0 ? (
|
||||
<p className="todo-col__empty">
|
||||
{doneDate ? '해당 날짜에 완료된 항목이 없습니다' : (isMobile ? '완료된 항목이 없습니다' : '드래그하여 이동')}
|
||||
</p>
|
||||
) : (
|
||||
doneTodos.map((todo) => renderCard(todo, 'done'))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 완료 패널 */}
|
||||
<div
|
||||
className={`todo-done-panel${dragOver === 'done' ? ' is-drag-over' : ''}`}
|
||||
onDragOver={(e) => onDragOver(e, 'done')}
|
||||
onDrop={(e) => onDrop(e, 'done')}
|
||||
{/* 모바일: 추가 바텀시트 */}
|
||||
<MobileSheet
|
||||
open={addSheetOpen}
|
||||
onClose={() => { setAddSheetOpen(false); setForm(emptyForm); }}
|
||||
title="할일 추가"
|
||||
>
|
||||
{/* 완료 패널 헤더 */}
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
{addForm}
|
||||
</MobileSheet>
|
||||
|
||||
{/* 완료 카드 그리드 */}
|
||||
<div className="todo-done-panel__body">
|
||||
{doneTodos.length === 0 ? (
|
||||
<p className="todo-col__empty">
|
||||
{doneDate ? '해당 날짜에 완료된 항목이 없습니다' : '드래그하여 이동'}
|
||||
</p>
|
||||
) : (
|
||||
doneTodos.map((todo) => renderCard(todo, 'done'))
|
||||
)}
|
||||
{/* 모바일: 완료 비우기 확인 시트 */}
|
||||
<MobileSheet
|
||||
open={confirmClear}
|
||||
onClose={() => setConfirmClear(false)}
|
||||
title="완료 항목 비우기"
|
||||
snap="half"
|
||||
>
|
||||
<div className="todo-confirm-sheet">
|
||||
<p className="todo-confirm-sheet__msg">
|
||||
완료된 항목 {todos.filter(t => t.status === 'done').length}건을 모두 삭제합니다.
|
||||
</p>
|
||||
<div className="todo-confirm-sheet__actions">
|
||||
<button type="button" className="button ghost" onClick={() => setConfirmClear(false)}>취소</button>
|
||||
<button type="button" className="button danger" onClick={handleClear}>삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileSheet>
|
||||
|
||||
<FAB onClick={() => setAddSheetOpen(true)} label="할일 추가" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
116
src/pages/travel/AlbumCard.css
Normal file
116
src/pages/travel/AlbumCard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
55
src/pages/travel/AlbumCard.jsx
Normal file
55
src/pages/travel/AlbumCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
334
src/pages/travel/AlbumDetail.css
Normal file
334
src/pages/travel/AlbumDetail.css
Normal file
@@ -0,0 +1,334 @@
|
||||
/* ─────────────────────────────────────────────
|
||||
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__region--editable {
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.album-detail__region--editable:hover {
|
||||
border-color: var(--tv-line-bright, rgba(232, 221, 208, 0.22));
|
||||
background: var(--tv-surface-2, #221c14);
|
||||
}
|
||||
|
||||
.album-detail__region-edit-icon {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.album-detail__region--editable:hover .album-detail__region-edit-icon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.album-detail__region-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.album-detail__region-ok {
|
||||
color: #c8905e;
|
||||
}
|
||||
.album-detail__region-err {
|
||||
color: #dc5050;
|
||||
}
|
||||
|
||||
.album-detail__pin-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22));
|
||||
background: transparent;
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s, background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.album-detail__pin-btn:hover {
|
||||
border-color: #c8905e;
|
||||
color: #c8905e;
|
||||
background: rgba(200, 144, 94, 0.08);
|
||||
}
|
||||
|
||||
/* ── Region editor ── */
|
||||
.album-detail__region-editor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.album-detail__region-input {
|
||||
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--tv-text, #e8ddd0);
|
||||
background: var(--tv-surface-2, #221c14);
|
||||
border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22));
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
width: 160px;
|
||||
outline: none;
|
||||
}
|
||||
.album-detail__region-input:focus {
|
||||
border-color: var(--tv-accent, #c8905e);
|
||||
}
|
||||
|
||||
.album-detail__region-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
margin: 4px 0 0;
|
||||
padding: 4px 0;
|
||||
list-style: none;
|
||||
background: var(--tv-surface, #1a1510);
|
||||
border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22));
|
||||
border-radius: 6px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
min-width: 160px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.album-detail__region-suggestion {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
|
||||
font-size: 11px;
|
||||
color: var(--tv-text, #e8ddd0);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.album-detail__region-suggestion:hover {
|
||||
background: var(--tv-line, rgba(232, 221, 208, 0.1));
|
||||
}
|
||||
|
||||
.album-detail__region-cancel {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--tv-line, rgba(232, 221, 208, 0.1));
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.album-detail__region-cancel:hover {
|
||||
background: var(--tv-line-bright, rgba(232, 221, 208, 0.22));
|
||||
}
|
||||
|
||||
.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 {
|
||||
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));
|
||||
}
|
||||
|
||||
/* ── SwipeableView height chain fix ──
|
||||
AlbumDetail is a fixed flex-column overlay.
|
||||
SwipeableView must fill the remaining space so its panel can scroll. */
|
||||
.album-detail .swipeable-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.album-detail .swipeable-view__track {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.album-detail .swipeable-view__panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
}
|
||||
406
src/pages/travel/AlbumDetail.jsx
Normal file
406
src/pages/travel/AlbumDetail.jsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } 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 RegionPinPicker from './RegionPinPicker';
|
||||
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,
|
||||
onCoverChange,
|
||||
regions,
|
||||
}) {
|
||||
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);
|
||||
|
||||
/* ── Region editing ── */
|
||||
const [editingRegion, setEditingRegion] = useState(false);
|
||||
const [regionInput, setRegionInput] = useState('');
|
||||
const [regionSaving, setRegionSaving] = useState(false);
|
||||
const [regionMsg, setRegionMsg] = useState(null); // { type: 'ok'|'err', text }
|
||||
const [pinPickerOpen, setPinPickerOpen] = useState(false);
|
||||
const [savedRegionId, setSavedRegionId] = useState(null); // region id after save
|
||||
const regionInputRef = useRef(null);
|
||||
const regionMsgTimer = useRef(null);
|
||||
|
||||
const regionNames = useMemo(() => {
|
||||
if (!regions?.features) return [];
|
||||
return regions.features
|
||||
.map((f) => f.properties?.name || f.properties?.id || '')
|
||||
.filter(Boolean);
|
||||
}, [regions]);
|
||||
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
if (!regionInput.trim()) return regionNames;
|
||||
const q = regionInput.toLowerCase();
|
||||
return regionNames.filter((n) => n.toLowerCase().includes(q));
|
||||
}, [regionInput, regionNames]);
|
||||
|
||||
const handleRegionEditStart = useCallback(() => {
|
||||
setEditingRegion(true);
|
||||
setRegionInput(album?.regionName || '');
|
||||
setRegionMsg(null);
|
||||
setTimeout(() => regionInputRef.current?.focus(), 50);
|
||||
}, [album]);
|
||||
|
||||
const handleRegionEditCancel = useCallback(() => {
|
||||
setEditingRegion(false);
|
||||
setRegionInput('');
|
||||
setRegionMsg(null);
|
||||
}, []);
|
||||
|
||||
const handleRegionSave = useCallback(async (value) => {
|
||||
const name = (value ?? regionInput).trim();
|
||||
if (!name || !album?.name) return;
|
||||
if (name === album.regionName) {
|
||||
setEditingRegion(false);
|
||||
return;
|
||||
}
|
||||
setRegionSaving(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/travel/albums/${encodeURIComponent(album.name)}/region`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ region: name }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data = await res.json();
|
||||
const newRegionId = data.new_region || name;
|
||||
setSavedRegionId(newRegionId);
|
||||
setRegionMsg({ type: 'ok', text: `→ ${name}` });
|
||||
setEditingRegion(false);
|
||||
onCoverChange?.(); // refresh album list
|
||||
} catch {
|
||||
setRegionMsg({ type: 'err', text: '변경 실패' });
|
||||
} finally {
|
||||
setRegionSaving(false);
|
||||
}
|
||||
if (regionMsgTimer.current) clearTimeout(regionMsgTimer.current);
|
||||
regionMsgTimer.current = setTimeout(() => setRegionMsg(null), 3000);
|
||||
}, [regionInput, album, onCoverChange]);
|
||||
|
||||
const handleRegionKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleRegionSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleRegionEditCancel();
|
||||
}
|
||||
}, [handleRegionSave, handleRegionEditCancel]);
|
||||
|
||||
const handleOpenPinPicker = useCallback(() => {
|
||||
setPinPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handlePinSave = useCallback(() => {
|
||||
onCoverChange?.(); // refresh regions + albums on map
|
||||
}, [onCoverChange]);
|
||||
|
||||
// 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 && !editingRegion) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [selectedPhotoIndex, editingRegion]); // 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 || '');
|
||||
|
||||
// Determine if the album's region is a custom region missing coordinates
|
||||
const currentRegionId = savedRegionId || album?.region || album?.id || '';
|
||||
const needsPin = useMemo(() => {
|
||||
if (!currentRegionId || !regions?.features) return false;
|
||||
const feature = regions.features.find((f) => {
|
||||
const rid = f.properties?.id || f.properties?.name || '';
|
||||
return rid.toLowerCase() === currentRegionId.toLowerCase();
|
||||
});
|
||||
if (!feature) return true; // region not in GeoJSON at all → needs pin
|
||||
if (feature.properties?.custom && !feature.geometry) return true; // custom without coords
|
||||
return false;
|
||||
}, [currentRegionId, regions]);
|
||||
|
||||
// Coords of existing pin (for initialCoords in picker)
|
||||
const existingCoords = useMemo(() => {
|
||||
if (!currentRegionId || !regions?.features) return null;
|
||||
const feature = regions.features.find((f) => {
|
||||
const rid = f.properties?.id || f.properties?.name || '';
|
||||
return rid.toLowerCase() === currentRegionId.toLowerCase();
|
||||
});
|
||||
if (feature?.geometry?.type === 'Point') return feature.geometry.coordinates;
|
||||
return null;
|
||||
}, [currentRegionId, regions]);
|
||||
|
||||
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>
|
||||
|
||||
{/* Region label / editor */}
|
||||
{editingRegion ? (
|
||||
<div className="album-detail__region-editor">
|
||||
<input
|
||||
ref={regionInputRef}
|
||||
className="album-detail__region-input"
|
||||
type="text"
|
||||
value={regionInput}
|
||||
onChange={(e) => setRegionInput(e.target.value)}
|
||||
onKeyDown={handleRegionKeyDown}
|
||||
placeholder="지역명 입력…"
|
||||
disabled={regionSaving}
|
||||
/>
|
||||
{filteredSuggestions.length > 0 && (
|
||||
<ul className="album-detail__region-suggestions">
|
||||
{filteredSuggestions.map((name) => (
|
||||
<li key={name}>
|
||||
<button
|
||||
className="album-detail__region-suggestion"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleRegionSave(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button
|
||||
className="album-detail__region-cancel"
|
||||
onClick={handleRegionEditCancel}
|
||||
aria-label="취소"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="album-detail__region-row">
|
||||
<button
|
||||
className="album-detail__region album-detail__region--editable"
|
||||
onClick={handleRegionEditStart}
|
||||
title="클릭하여 지역 변경"
|
||||
>
|
||||
{regionMsg
|
||||
? <span className={regionMsg.type === 'ok' ? 'album-detail__region-ok' : 'album-detail__region-err'}>{regionMsg.text}</span>
|
||||
: (album?.regionName || '미분류')
|
||||
}
|
||||
<svg className="album-detail__region-edit-icon" width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M7.5 1.5l1 1-5.5 5.5H2V7z" stroke="currentColor" strokeWidth="0.8" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{(needsPin || (regionMsg?.type === 'ok' && savedRegionId)) && (
|
||||
<button
|
||||
className="album-detail__pin-btn"
|
||||
onClick={handleOpenPinPicker}
|
||||
title="지도에 위치 지정"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M6 1C4.07 1 2.5 2.57 2.5 4.5 2.5 7.25 6 11 6 11s3.5-3.75 3.5-6.5C9.5 2.57 7.93 1 6 1zm0 4.75a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5z" fill="currentColor"/>
|
||||
</svg>
|
||||
위치 지정
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{photoCountLabel && (
|
||||
<span className="album-detail__count">{photoCountLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<SwipeableView tabs={tabs} />
|
||||
</div>
|
||||
|
||||
{/* Pin Picker */}
|
||||
{pinPickerOpen && currentRegionId && (
|
||||
<RegionPinPicker
|
||||
regionId={currentRegionId}
|
||||
regionName={album?.regionName || currentRegionId}
|
||||
initialCoords={existingCoords}
|
||||
accent={regionAccent}
|
||||
onSave={handlePinSave}
|
||||
onClose={() => setPinPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
onCoverChange={onCoverChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
314
src/pages/travel/HeroLightbox.css
Normal file
314
src/pages/travel/HeroLightbox.css
Normal file
@@ -0,0 +1,314 @@
|
||||
/* ═══════════════════════════════════════════
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Cover button ── */
|
||||
.hero-lb__cover-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
font-family: var(--tv-mono, 'Space Mono', 'Courier New', monospace);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hero-lb__cover-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: var(--tv-text, #e8ddd0);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.hero-lb__cover-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.hero-lb__cover-btn--done {
|
||||
border-color: rgba(200, 144, 94, 0.5);
|
||||
color: #c8905e;
|
||||
background: rgba(200, 144, 94, 0.12);
|
||||
}
|
||||
.hero-lb__cover-btn--error {
|
||||
border-color: rgba(220, 80, 80, 0.5);
|
||||
color: #dc5050;
|
||||
background: rgba(220, 80, 80, 0.12);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
305
src/pages/travel/HeroLightbox.jsx
Normal file
305
src/pages/travel/HeroLightbox.jsx
Normal file
@@ -0,0 +1,305 @@
|
||||
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,
|
||||
onCoverChange,
|
||||
}) {
|
||||
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 [coverStatus, setCoverStatus] = useState(null); // 'saving' | 'done' | 'error'
|
||||
const coverTimerRef = useRef(null);
|
||||
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,
|
||||
});
|
||||
|
||||
/* — Set as album cover — */
|
||||
const handleSetCover = useCallback(async () => {
|
||||
const p = photos[selectedIndex];
|
||||
if (!p || !albumName || coverStatus === 'saving') return;
|
||||
const filename = p.file || p.filename || p.name || '';
|
||||
if (!filename) return;
|
||||
setCoverStatus('saving');
|
||||
try {
|
||||
const res = await fetch(`/api/travel/albums/${encodeURIComponent(albumName)}/cover`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
setCoverStatus('done');
|
||||
onCoverChange?.();
|
||||
} catch {
|
||||
setCoverStatus('error');
|
||||
}
|
||||
if (coverTimerRef.current) clearTimeout(coverTimerRef.current);
|
||||
coverTimerRef.current = setTimeout(() => setCoverStatus(null), 2000);
|
||||
}, [selectedIndex, photos, albumName, coverStatus, onCoverChange]);
|
||||
|
||||
/* — 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__cover-btn${coverStatus === 'done' ? ' hero-lb__cover-btn--done' : coverStatus === 'error' ? ' hero-lb__cover-btn--error' : ''}`}
|
||||
onClick={handleSetCover}
|
||||
disabled={coverStatus === 'saving'}
|
||||
title="이 사진을 앨범 커버로 지정"
|
||||
>
|
||||
{coverStatus === 'saving' ? '저장 중…'
|
||||
: coverStatus === 'done' ? '커버 지정됨'
|
||||
: coverStatus === 'error' ? '실패'
|
||||
: '커버로 지정'}
|
||||
</button>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
139
src/pages/travel/MasonryGrid.css
Normal file
139
src/pages/travel/MasonryGrid.css
Normal file
@@ -0,0 +1,139 @@
|
||||
/* ── MasonryGrid — stable CSS Grid layout ── */
|
||||
|
||||
.masonry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* item */
|
||||
.masonry-item {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
aspect-ratio: 4 / 3;
|
||||
|
||||
/* 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: 100%;
|
||||
object-fit: cover;
|
||||
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;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* loading dots */
|
||||
.masonry-loading {
|
||||
grid-column: 1 / -1;
|
||||
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 {
|
||||
grid-column: 1 / -1;
|
||||
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 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.masonry-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
117
src/pages/travel/MasonryGrid.jsx
Normal file
117
src/pages/travel/MasonryGrid.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/pages/travel/MiniMap.css
Normal file
106
src/pages/travel/MiniMap.css
Normal 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;
|
||||
}
|
||||
}
|
||||
224
src/pages/travel/MiniMap.jsx
Normal file
224
src/pages/travel/MiniMap.jsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { MapContainer, TileLayer, GeoJSON, CircleMarker, Tooltip, 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();
|
||||
|
||||
/* Split features: polygons (for GeoJSON) vs points (for CircleMarker) */
|
||||
const { polygonGeoJson, pointFeatures } = useMemo(() => {
|
||||
if (!geojson?.features) return { polygonGeoJson: geojson, pointFeatures: [] };
|
||||
const polys = [];
|
||||
const points = [];
|
||||
for (const f of geojson.features) {
|
||||
if (f.geometry?.type === 'Point') {
|
||||
points.push(f);
|
||||
} else if (f.geometry) {
|
||||
polys.push(f);
|
||||
}
|
||||
// null geometry → skip (no location yet)
|
||||
}
|
||||
return {
|
||||
polygonGeoJson: { ...geojson, features: polys },
|
||||
pointFeatures: points,
|
||||
};
|
||||
}, [geojson]);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
const handlePinClick = useCallback(
|
||||
(feature) => {
|
||||
const rid = feature.properties?.id || feature.properties?.name || '';
|
||||
onSelectRegion(rid);
|
||||
const [lng, lat] = feature.geometry.coordinates;
|
||||
map.setView([lat, lng], 5, { animate: true });
|
||||
},
|
||||
[map, onSelectRegion],
|
||||
);
|
||||
|
||||
if (!geojson) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{polygonGeoJson.features.length > 0 && (
|
||||
<GeoJSON
|
||||
key={selectedRegionId || '__all__'}
|
||||
data={polygonGeoJson}
|
||||
style={style}
|
||||
onEachFeature={onEachFeature}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pointFeatures.map((feature) => {
|
||||
const rid = feature.properties?.id || feature.properties?.name || '';
|
||||
const name = feature.properties?.name || rid;
|
||||
const [lng, lat] = feature.geometry.coordinates;
|
||||
const isSelected =
|
||||
selectedRegionId && rid.toLowerCase() === selectedRegionId.toLowerCase();
|
||||
const accent = getRegionAccent(rid);
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`pin-${rid}`}
|
||||
center={[lat, lng]}
|
||||
radius={isSelected ? 8 : 5}
|
||||
pathOptions={{
|
||||
fillColor: accent,
|
||||
fillOpacity: isSelected ? 0.9 : 0.7,
|
||||
color: isSelected ? '#fff' : accent,
|
||||
weight: isSelected ? 2 : 1,
|
||||
}}
|
||||
eventHandlers={{ click: () => handlePinClick(feature) }}
|
||||
>
|
||||
<Tooltip className="minimap-tooltip">{name}</Tooltip>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
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>
|
||||
);
|
||||
}
|
||||
180
src/pages/travel/RegionPinPicker.css
Normal file
180
src/pages/travel/RegionPinPicker.css
Normal file
@@ -0,0 +1,180 @@
|
||||
/* ── RegionPinPicker — modal overlay ── */
|
||||
|
||||
.pin-picker {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
animation: pinPickerFadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes pinPickerFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.pin-picker__panel {
|
||||
width: 90vw;
|
||||
max-width: 560px;
|
||||
background: var(--tv-surface, #1a1510);
|
||||
border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22));
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.pin-picker__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pin-picker__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pin-picker__label {
|
||||
font-family: var(--tv-mono, 'Space Mono', monospace);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
}
|
||||
|
||||
.pin-picker__region {
|
||||
font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pin-picker__close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.pin-picker__close:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
/* Hint */
|
||||
.pin-picker__hint {
|
||||
font-family: var(--tv-mono, 'Space Mono', monospace);
|
||||
font-size: 11px;
|
||||
color: var(--tv-dim, rgba(232, 221, 208, 0.25));
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Map */
|
||||
.pin-picker__map-wrap {
|
||||
height: 320px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--tv-line, rgba(232, 221, 208, 0.1));
|
||||
}
|
||||
|
||||
.pin-picker__map {
|
||||
background: var(--tv-bg, #0f0c09);
|
||||
}
|
||||
|
||||
.pin-picker__map .leaflet-tile-pane {
|
||||
filter: brightness(0.7) saturate(0.4);
|
||||
}
|
||||
|
||||
/* Coords */
|
||||
.pin-picker__coords {
|
||||
font-family: var(--tv-mono, 'Space Mono', monospace);
|
||||
font-size: 11px;
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.pin-picker__error {
|
||||
font-family: var(--tv-mono, 'Space Mono', monospace);
|
||||
font-size: 11px;
|
||||
color: #dc5050;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.pin-picker__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pin-picker__cancel,
|
||||
.pin-picker__save {
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
font-family: var(--tv-mono, 'Space Mono', monospace);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.pin-picker__cancel {
|
||||
background: none;
|
||||
border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22));
|
||||
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
|
||||
}
|
||||
.pin-picker__cancel:hover {
|
||||
border-color: var(--tv-text, #e8ddd0);
|
||||
color: var(--tv-text, #e8ddd0);
|
||||
}
|
||||
|
||||
.pin-picker__save {
|
||||
background: var(--accent, #c8905e);
|
||||
border: none;
|
||||
color: #0f0c09;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pin-picker__save:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.pin-picker__save:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 480px) {
|
||||
.pin-picker__panel {
|
||||
width: 95vw;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pin-picker__map-wrap {
|
||||
height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pin-picker {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
152
src/pages/travel/RegionPinPicker.jsx
Normal file
152
src/pages/travel/RegionPinPicker.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { MapContainer, TileLayer, CircleMarker, useMapEvents } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './RegionPinPicker.css';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
ClickHandler — captures map clicks
|
||||
───────────────────────────────────────────── */
|
||||
function ClickHandler({ onClickLatLng }) {
|
||||
useMapEvents({
|
||||
click(e) {
|
||||
onClickLatLng([e.latlng.lng, e.latlng.lat]); // [lng, lat]
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
RegionPinPicker — modal for picking a
|
||||
location on the map
|
||||
───────────────────────────────────────────── */
|
||||
export default function RegionPinPicker({
|
||||
regionId,
|
||||
regionName,
|
||||
initialCoords, // [lng, lat] or null
|
||||
accent,
|
||||
onSave, // (coords: [lng, lat]) => void
|
||||
onClose,
|
||||
}) {
|
||||
const [coords, setCoords] = useState(initialCoords || null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const backdropRef = useRef(null);
|
||||
|
||||
/* ESC to close */
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
const handleMapClick = useCallback((lngLat) => {
|
||||
setCoords(lngLat);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!coords) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/travel/regions/${encodeURIComponent(regionId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ coordinates: coords }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
onSave?.(coords);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(`저장 실패: ${e.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [coords, regionId, onSave, onClose]);
|
||||
|
||||
const handleBackdrop = useCallback(
|
||||
(e) => { if (e.target === backdropRef.current) onClose(); },
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const center = coords
|
||||
? [coords[1], coords[0]]
|
||||
: initialCoords
|
||||
? [initialCoords[1], initialCoords[0]]
|
||||
: [30, 125];
|
||||
const zoom = coords || initialCoords ? 4 : 2;
|
||||
|
||||
return (
|
||||
<div className="pin-picker" ref={backdropRef} onClick={handleBackdrop}>
|
||||
<div className="pin-picker__panel">
|
||||
{/* Header */}
|
||||
<div className="pin-picker__header">
|
||||
<div className="pin-picker__title">
|
||||
<span className="pin-picker__label">위치 지정</span>
|
||||
<span className="pin-picker__region" style={{ color: accent }}>
|
||||
{regionName || regionId}
|
||||
</span>
|
||||
</div>
|
||||
<button className="pin-picker__close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Instruction */}
|
||||
<p className="pin-picker__hint">지도를 클릭하여 핀을 놓으세요</p>
|
||||
|
||||
{/* Map */}
|
||||
<div className="pin-picker__map-wrap">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
minZoom={2}
|
||||
maxZoom={12}
|
||||
zoomControl={true}
|
||||
attributionControl={false}
|
||||
className="pin-picker__map"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution=""
|
||||
/>
|
||||
<ClickHandler onClickLatLng={handleMapClick} />
|
||||
{coords && (
|
||||
<CircleMarker
|
||||
center={[coords[1], coords[0]]}
|
||||
radius={8}
|
||||
pathOptions={{
|
||||
fillColor: accent || '#c8905e',
|
||||
fillOpacity: 0.9,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{/* Coords display */}
|
||||
{coords && (
|
||||
<p className="pin-picker__coords">
|
||||
{coords[1].toFixed(4)}, {coords[0].toFixed(4)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="pin-picker__error">{error}</p>}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pin-picker__actions">
|
||||
<button className="pin-picker__cancel" onClick={onClose}>취소</button>
|
||||
<button
|
||||
className="pin-picker__save"
|
||||
onClick={handleSave}
|
||||
disabled={!coords || saving}
|
||||
style={{ '--accent': accent || '#c8905e' }}
|
||||
>
|
||||
{saving ? '저장 중…' : '핀 저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,6 +92,64 @@
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
/* Sync button */
|
||||
.tv-sync-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
padding: 6px 14px;
|
||||
background: var(--tv-surface-2);
|
||||
border: 1px solid var(--tv-line);
|
||||
border-radius: var(--tv-r-sm);
|
||||
color: var(--tv-muted);
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.tv-sync-btn:hover:not(:disabled) {
|
||||
color: var(--tv-text);
|
||||
border-color: var(--tv-line-bright);
|
||||
}
|
||||
|
||||
.tv-sync-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tv-sync-btn.is-syncing svg {
|
||||
animation: tv-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes tv-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.tv-sync-toast {
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(200, 144, 94, 0.12);
|
||||
border: 1px solid rgba(200, 144, 94, 0.25);
|
||||
border-radius: var(--tv-r-sm);
|
||||
font-family: var(--tv-mono);
|
||||
font-size: 11px;
|
||||
color: var(--tv-accent);
|
||||
animation: tv-fade-in 0.3s ease;
|
||||
}
|
||||
|
||||
.tv-sync-toast.is-error {
|
||||
background: rgba(220, 80, 80, 0.12);
|
||||
border-color: rgba(220, 80, 80, 0.25);
|
||||
color: #dc5050;
|
||||
}
|
||||
|
||||
@keyframes tv-fade-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Active region info */
|
||||
.tv-header__active-region {
|
||||
display: flex;
|
||||
@@ -166,73 +224,16 @@
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
MAP SECTION
|
||||
ALBUMS SECTION — card grid
|
||||
═══════════════════════════════════════════════════ */
|
||||
.tv-map-section {
|
||||
.tv-albums {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.tv-albums__grid {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
.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);
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ── Loading / Error states ──────────────────────── */
|
||||
@@ -286,693 +287,16 @@
|
||||
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
|
||||
═══════════════════════════════════════════════════ */
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 768px) {
|
||||
.tv-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 480px) {
|
||||
.travel {
|
||||
gap: 28px;
|
||||
}
|
||||
@@ -986,41 +310,9 @@
|
||||
font-size: clamp(40px, 12vw, 60px);
|
||||
}
|
||||
|
||||
.lightbox__topbar {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 8px;
|
||||
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;
|
||||
.tv-albums__grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1028,19 +320,8 @@
|
||||
REDUCED MOTION
|
||||
═══════════════════════════════════════════════════ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.photo-card,
|
||||
[data-reveal] {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.lightbox__photo.slide-next,
|
||||
.lightbox__photo.slide-prev {
|
||||
.tv-state__loader span {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
28
src/pages/travel/VideoTab.css
Normal file
28
src/pages/travel/VideoTab.css
Normal 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);
|
||||
}
|
||||
44
src/pages/travel/VideoTab.jsx
Normal file
44
src/pages/travel/VideoTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
325
src/pages/travel/useTravelData.js
Normal file
325
src/pages/travel/useTravelData.js
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Constants
|
||||
───────────────────────────────────────────── */
|
||||
const PAGE_SIZE = 40;
|
||||
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 from /api/travel/albums ── */
|
||||
const fetchAlbums = useCallback(async (signal) => {
|
||||
setLoadingAlbums(true);
|
||||
try {
|
||||
const res = await fetch('/api/travel/albums', { signal });
|
||||
if (!res.ok) throw new Error(`앨범 로딩 실패 (${res.status})`);
|
||||
const rows = await res.json();
|
||||
const builtAlbums = rows.map((r) => ({
|
||||
id: `${r.region}::${r.album}`,
|
||||
name: r.album,
|
||||
region: r.region,
|
||||
regionName: r.regionName || r.region,
|
||||
photoCount: r.count ?? 0,
|
||||
coverThumb: r.cover_thumb || '',
|
||||
}));
|
||||
setAlbums(builtAlbums);
|
||||
} catch (err) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
setError(err?.message ?? String(err));
|
||||
}
|
||||
} finally {
|
||||
setLoadingAlbums(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchAlbums(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [fetchAlbums]);
|
||||
|
||||
/* ── 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,
|
||||
refreshAlbums: () => fetchAlbums(),
|
||||
};
|
||||
};
|
||||
|
||||
export default useTravelData;
|
||||
@@ -117,6 +117,15 @@ export const navLinks = [
|
||||
icon: <IconTodo />,
|
||||
accent: '#f472b6',
|
||||
},
|
||||
{
|
||||
id: 'agent-office',
|
||||
label: 'Agent Office',
|
||||
path: '/agent-office',
|
||||
subtitle: 'AI LAB',
|
||||
description: 'AI 에이전트 사무실',
|
||||
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
|
||||
accent: '#8b5cf6',
|
||||
},
|
||||
];
|
||||
|
||||
export const appRoutes = [
|
||||
@@ -172,4 +181,8 @@ export const appRoutes = [
|
||||
path: 'todo',
|
||||
element: <Todo />,
|
||||
},
|
||||
{
|
||||
path: 'agent-office',
|
||||
lazy: () => import('./pages/agent-office/AgentOffice'),
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user