Compare commits

209 Commits

Author SHA1 Message Date
e6659a416a style(stock): 스크리너 모바일 적층 + 표 가로 스크롤 2026-05-12 14:23:16 +09:00
3abd46c0fd docs(stock): CLAUDE.md 스크리너 API 표 추가 + Stock 페이지 링크 2026-05-12 14:22:18 +09:00
c42d3fe8d4 feat(stock): ResultTable 본구현 + ScoreChips (노드 칩 + 70점 강조) 2026-05-12 14:21:05 +09:00
1e8542f6c7 feat(stock): GatePanel 자동 폼 + GlobalControls (TopN/ATR/RR + 3버튼) 2026-05-12 14:19:36 +09:00
a11475db57 feat(stock): NodeCard 자동 폼 (param_schema 기반) + NodePanel 통합 2026-05-12 14:18:22 +09:00
bc2c020f71 feat(stock): /stock/screener 페이지 골격 + hooks 4개 + 컴포넌트 stub 6개 2026-05-12 14:15:36 +09:00
cd6072727f feat(stock): /stock/screener 라우트 + 임시 placeholder 2026-05-12 14:13:26 +09:00
42ebd5a87c feat(stock): screener API 헬퍼 7개 2026-05-12 14:11:51 +09:00
3b66a47316 docs(plan): 데이터 소스 pykrx → FDR + 네이버 스크래핑 (Task 0.1/0.3)
실측 결과 pykrx의 시장 전체 함수 (get_market_ticker_list,
get_market_cap, get_market_ohlcv_by_ticker)가 모두 KRX 인증
요구로 깨짐. Task 0.1 의존성을 finance-datareader + bs4 + lxml
로 교체하고 Task 0.3 snapshot.py는 FDR + 네이버 frgn 스크래핑
방식으로 재작성 (implementer dispatch 시 인라인 안내).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 04:03:31 +09:00
f7323a5b72 docs(plan): Stock Screener Board MVP 구현 plan
6 Phase × 35 task. Phase 0(백엔드 기반)·Phase 1(노드 8개 TDD)·
Phase 2(엔진/사이저/텔레그램)·Phase 3(라우터)·Phase 4(프론트)·
Phase 5(agent-office 통합)·Phase 6(백필·검증·배포).
모든 task에 TDD step + 코드 + 명령 명시. 로컬 venv 기반
실행으로 메모리 규약(로컬 docker 금지) 준수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 03:48:20 +09:00
ccf6d4e551 docs(spec): Stock Screener Board MVP 설계 문서
KRX 강세주 발굴 노드 기반 분석 보드의 첫 슬라이스 설계.
pykrx 일봉·수급 캐시 + 위생 게이트 1 + 점수 노드 7
(외국인 누적 매수·거래량 급증·20일 모멘텀·52주 신고가 근접도·
RS Rating·이평선 정배열·VCP-lite) + 가중합 + ATR 포지션 사이징.
평일 16:30 KST agent-office 자동 잡으로 텔레그램 전송.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 03:33:35 +09:00
a20315ce34 feat(stock): 포트폴리오 현재가에 NXT 시간외 거래 뱃지
백엔드 응답의 price_session에 따라 NXT 프리마켓/애프터마켓 거래 중인
종목에 작은 'NXT' / 'NXT 프리' 뱃지를 표시. 툴팁에 거래 시각 노출.
정규장 마감 후에도 평가금액이 자연스럽게 이어지는 흐름을 시각적으로 보강.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:32:26 +09:00
3fa4dbda3c Merge feature/lotto-curator-evolution: Lotto Curator Evolution (frontend)
7 commits for Phase F + G:
- api.js: getLatestReview / getReviewHistory / bulkPurchase 헬퍼
- useReview 훅 + useBriefing 4계층 normalize
- DecisionCard + RetrospectiveBox + TierModeToggle + TierSection + PickCard + BulkPurchaseButton
- BriefingTab 단일 화면 재구성
- 분석탭 → 자료실 라벨 + 9개 패널 details 접힘
- PurchasePanel 자동 채점 일치수 배지 + 4등↑ 플래그
- 4주 추세 차트(너 vs 큐레이터 평균 일치)

자세한 컨셉/계획: docs/superpowers/{specs,plans}/2026-05-11-*.md
2026-05-11 09:39:08 +09:00
baf34dd7aa feat(lotto): 구매탭 4주 추세 차트(너 vs 큐레이터 평균 일치) 2026-05-11 09:06:54 +09:00
4ef76f6cce feat(lotto): 구매탭에 자동 채점 일치수 배지 + 4등↑ 플래그 2026-05-11 09:05:22 +09:00
0bf1233e96 feat(lotto): 분석탭 → 자료실 라벨 + 첫 진입 모든 패널 접힘 2026-05-11 09:03:10 +09:00
ff7ac48c6b feat(lotto): DecisionCard + BulkPurchaseButton, BriefingTab 단일 화면 재구성 2026-05-11 09:00:59 +09:00
329141c732 feat(lotto): DecisionCard 하위 컴포넌트(Pick/Tier/Toggle/Retro) + 스타일 2026-05-11 08:59:00 +09:00
cd3c538eb7 feat(lotto): useReview 훅 + useBriefing 4계층 정규화 2026-05-11 08:57:14 +09:00
9d2dfad512 feat(api): review + bulkPurchase 헬퍼 2026-05-11 08:56:10 +09:00
42073a5bf3 docs(plan): Lotto Curator Evolution 구현 plan
23 task로 분해 (TDD 사이클 + 빈번한 commit):
- Phase A (1-2): weekly_review 테이블 + 4계층 마이그레이션
- Phase B (3-5): 채점 보조 함수 + 통합 잡 + cron
- Phase C (6-8): review/bulk/briefing 라우터
- Phase D (9-12): 큐레이터 4계층 스키마 + 회고 + pipeline
- Phase E (13-15): 텔레그램 알림 + webhook + cron 변경
- Phase F (16-19): api 헬퍼 + 훅 + DecisionCard
- Phase G (20-22): 자료실 강등 + 자동채점 표시 + 추세 차트
- Phase H (23): 1주차 운영 점검

스펙→코드베이스 보정 사항(테이블명/기존 컬럼/기존 자동채점) plan 상단에 명시
2026-05-11 04:26:00 +09:00
6b2fcda2af docs(spec): Lotto Curator Evolution 설계 문서
매주 같은 시간에 큐레이터가 한 번 더 똑똑해지는 컨셉으로
- 회고 컨텍스트(weekly_review + 자동 채점 잡)
- 4계층 위계(코어/보너스/확장/풀, 5~20세트)
- 결정 카드 단일 화면(브리핑 탭 재구성)
- 분석 탭은 자료실로 강등
- 월요일 09:00 큐레이션 + 텔레그램 푸시
2026-05-11 03:19:58 +09:00
acac2cd20e chore: ignore .superpowers/ (visual companion mockup files) 2026-05-11 03:19:57 +09:00
95edc9d232 feat(web-ui): 배치 장르 목록 동적 fetch (POOLS 추가 시 자동 반영) 2026-05-10 23:53:49 +09:00
ec22321d56 fix(deploy): NAS_FRONTEND_DEST_WIN env로 Z: 매핑 변경 대응 2026-05-10 19:02:49 +09:00
a80b869878 feat(web-ui): Create 탭 배치 생성 섹션 + BatchProgress 폴링 2026-05-10 19:00:42 +09:00
93d5f49cdb feat(web-ui): PipelineStartModal '원하는 이미지 분위기' 메인 필드로 노출 2026-05-10 16:17:36 +09:00
3f5cd32c77 feat(web-ui): SetupTab visual_defaults 6옵션 확장 2026-05-09 13:36:26 +09:00
120c39a3ef feat(web-ui): PipelineDetailModal + 카드 mini 미리보기 2026-05-09 13:34:54 +09:00
08fce2d4f6 feat(web-ui): PipelineStartModal Mix 입력 라디오 + 고급 옵션 2026-05-09 13:32:23 +09:00
9c12de4593 feat(web-ui): CompileTab '영상 만들기' 버튼 + createPipeline payload 시그니처 2026-05-09 13:30:31 +09:00
53e9938903 fix(web-ui): PipelineStartModal에 initialTrackId 전달 2026-05-07 17:44:00 +09:00
522b7695aa feat(web-ui): YouTube 6 서브탭 + Library 영상 파이프라인 트리거 2026-05-07 17:31:37 +09:00
9ffd7889e7 feat(web-ui): PipelineTab — 진행 중 파이프라인 카드 보드 2026-05-07 17:28:14 +09:00
5bba880c23 feat(web-ui): SetupTab — YouTube 자동화 구성 허브 2026-05-07 17:25:53 +09:00
4498124514 feat(web-ui): pipeline/setup/youtube API 헬퍼 2026-05-07 17:23:51 +09:00
b6748ecd27 chore: 하네스 settings.json + CLAUDE.md 깨진 spec 참조 제거
- .claude/settings.json: git/npm/npx/node/ls allowlist + 민감파일 deny.
- CLAUDE.md: 삭제된 realestate-targeting-enhancement-design.md 참조 제거.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:06:15 +09:00
397257cf3b docs: STATUS.md — 구현 현황 + 향후 계획 정리
페이지·서비스별 완료/예정 항목 인덱스. CLAUDE.md를 보완.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:57:12 +09:00
d38ee553c3 fix(stock): 포트폴리오 카드 모바일 금액 줄바꿈 방지
천만원 단위 이상에서 '원'이 다음 줄로 넘어가던 문제 해결.
값 길이별 폰트 단계 축소(is-fit-sm/xs) + nowrap 적용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 03:54:20 +09:00
4acdc451c0 feat(music): YouTube 탭 + 컴파일 기능 통합
- YouTube 탭 (영상 제작, 수익 추적, 시장 트렌드, 컴파일) 연결
- Create 탭 트랙 제목 직접 입력
- TrendsTab 히스토리 상세 + 메타데이터 수정
- 다중 트랙 FFmpeg concat 컴파일 서브탭 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:01:12 +09:00
f3b0b2c109 feat(music): YouTube 탭 컴파일 서브탭 추가 (다중 트랙 FFmpeg concat)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 16:58:40 +09:00
4281c1873f feat(music): Create 탭 트랙 제목 직접 입력 추가 2026-05-01 15:49:56 +09:00
8a7b5e8a38 fix(music): setTimeout 정리 + useCallback 폴링 deps
- TrendsTab: useRef로 타이머 ID 추적 후 언마운트 시 clearTimeout 호출 (stale setState 방지)
- VideoProjectsTab: loadProjects를 useCallback으로 감싸고 폴링 useEffect deps에 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:13:04 +09:00
08981a292a feat(youtube-tab): MusicStudio YouTube 탭 연결 + CSS + Library 버튼
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:08:15 +09:00
ed95f6678f fix(music): TrendsTab 리포트 이력 메타데이터 장르/추천수 표시
리포트 목록 행의 메타 정보를 insights 미리보기에서 장르/추천 개수로 교체.
이제 list 응답에 top_genres·recommended_styles가 포함되므로 클릭 시
장르 차트와 Suno 프롬프트가 정상 표시됨.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:03:34 +09:00
1847771ad2 fix(music): TrendsTab 로딩상태·에러피드백·메타데이터 수정
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:01:38 +09:00
0f0ca8610d fix(music): TrendsTab 리포트 selected_styles 표시 + created_at 시간 포맷
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 14:56:47 +09:00
3f2fdb095c feat(youtube-tab): TrendsTab 시장 트렌드 서브탭 2026-05-01 14:51:10 +09:00
3e54b2c98d feat(youtube-tab): RevenueTab 수익 추적 서브탭 2026-05-01 14:48:47 +09:00
16b8cc59ae feat(youtube-tab): VideoProjectsTab 영상 제작 서브탭 2026-05-01 14:46:27 +09:00
a89de57b79 feat(youtube-tab): YoutubeTab 서브탭 shell 컴포넌트 + 스텁 탭 추가 2026-05-01 14:44:21 +09:00
413dccb655 feat(api): video-project / revenue / market-trends API 함수 추가 2026-05-01 14:42:15 +09:00
d1526af32c feat(subscription): 청약 일정 캘린더 뷰 추가
공고 목록 탭에 📅 캘린더 토글 버튼 추가.
캘린더 모드: 월간 그리드, 접수 시작일 기준 도트 표시 (상태별 색상).
날짜 클릭 시 해당일 공고 목록 패널 표시, 항목 클릭 시 상세 뷰로 전환.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:35:44 +09:00
abd8762b5c feat(subscription): 프로필 완성도 힌트 배너 추가
소득·면적·예산·자치구 티어 중 미입력 항목이 있으면
프로필 패널 상단에 입력 권장 안내 배너 표시.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:31:37 +09:00
8514232775 feat(subscription): 소득 기준 힌트 표시 + input 범위 제한
income_level 입력 필드에 특별공급별 소득 기준(%)'을 안내 텍스트로 표시.
미입력 시 검증 생략됨을 명시.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:12:34 +09:00
6c1f19e690 fix(deploy): rsync 대신 tar|ssh 방식으로 전환 (Synology rsync --server 차단 우회)
Synology가 rsync 바이너리를 패치하여 --server 모드도 데몬 인증을 요구함.
SSH는 정상 동작하므로 tar czf | ssh로 대체하여 배포 가능하도록 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:48:37 +09:00
35ce362d20 fix(deploy): -e 인자 단따옴표 변경 + 키파일 존재 검증
- -e 'ssh ...' 단따옴표 사용으로 -i 경로의 따옴표 충돌 방지
- 키 파일 없을 때 명확한 에러 메시지 출력 후 종료

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:39:27 +09:00
11e4f00ae6 fix(deploy): rsync SSH에 -i 키파일 명시 (macOS Keychain 우회)
macOS에서 rsync 서브프로세스는 Keychain 키를 자동 로드하지 못해
비밀번호 프롬프트로 fallback됨. -i ~/.ssh/id_rsa 명시로 해결.
- BatchMode=yes: 비밀번호 프롬프트 차단 (명확한 에러 반환)
- StrictHostKeyChecking=accept-new: 최초 연결 host key 자동 수락
- NAS_SSH_KEY 환경변수로 다른 키 파일 지정 가능

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:35:56 +09:00
b11d1c421d fix(deploy): SSH env값 제어문자 sanitize + 포트 검증
- NAS_SSH_TARGET/PORT/PATH에서 \r\n\t 제거 (잘못된 export·copy-paste 대비)
- NAS_SSH_PORT는 숫자만 허용, 잘못된 값이면 명확한 오류 메시지 출력
- SSH deploy 실행 시 cleanTarget/sshCmd 값 콘솔에 출력 (디버그용)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:27:11 +09:00
f6d95264c3 fix(deploy): Mac SSH 배포 지원 + .env.local 자동 로드
- .env.local 파일에서 NAS_SSH_TARGET 등 SSH 설정 자동 로드
- NAS_SSH_TARGET 설정 시 SMB 마운트보다 SSH 우선 사용
- SMB 쓰기 실패(EIO) 시 스택트레이스 대신 SSH 설정 안내 메시지 출력
- .env.local을 .gitignore에 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:21:32 +09:00
7cbdbe6e8b feat(subscription): 5축 점수 breakdown 시각화 + 알림 대상 카운트
- AnnouncementDetail: 5축(지역/유형/면적/가격/자격) progress bar 추가
- MatchesTab: 카드마다 미니 5축 비례 바 추가 (색상 구분)
- ProfileTab: 마운트 시 dashboard도 함께 fetch → pass_count 취득
- NotificationSettings: passCount prop → "현재 N건 대상" 인라인 표시
- Subscription.css: .ns-pass-count 스타일 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 08:56:36 +09:00
573c0364bb style(subscription): 5티어/드래그/토글/슬라이더 다크 네온 테마 정렬
- 5티어 뱃지: light pastel → 페이지 accent 팔레트 + neon glow
  S=rose / A=orange / B=mint / C=cyan / D=purple (모두 반투명 bg + bright text + glow)
- DistrictTierEditor: surface-card glass + rose dragOver glow
- 자치구 칩: surface-raised + rose hover lift/glow
- sub-toggle: 다크 호환 + rose 활성 glow
- ns-slider: custom thumb (rose + glow + scale on hover)
- 매칭 분석: surface-card + rose 사이드 그라데이션 + 점수 text-shadow
- 모든 텍스트는 --text/--text-bright/--text-dim/--text-muted 토큰
- font-family: --font-display(--font-body)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:36:01 +09:00
7f42ff3594 Merge branch 'feat/realestate-frontend-targeting'
청약 페이지 자치구 5티어 + 알림 설정 UI — 9 task TDD 구현
- DistrictTierEditor: 데스크톱 드래그&드롭 + 모바일 read-only
- NotificationSettings: 임계값 슬라이더 + 알림 토글
- AnnouncementCard / MatchesTab: district + 5티어 뱃지
- AnnouncementDetail: 매칭 분석 섹션 (점수 + 사유 + 자격)
- 백엔드 스펙: web-backend 2026-04-28-realestate-targeting-enhancement
- 빌드 clean, 린트 baseline 유지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:37:10 +09:00
1c331f209a fix(subscription): CLAUDE.md districts shape + dragLeave 정확도
preferred_districts 문서 형태를 백엔드 실제 구조(tier-keyed Dict[str, List[str]])로 수정.
onDragLeave가 자식 요소로 커서 이동 시 flicker 발생하던 문제 수정(relatedTarget 체크).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:31:24 +09:00
c87e764063 docs(web-ui): 청약 5티어 + 알림 설정 문서 업데이트
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:26:19 +09:00
80fcb07fc0 feat(subscription): MatchesTab 카드에 district + 5티어 뱃지
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:26:15 +09:00
a9a6808005 feat(subscription): AnnouncementDetail에 매칭 분석 섹션
match_score가 있는 공고에 한해 매칭 분석 섹션을 상세 패널 하단에 노출.
점수·매칭 사유·신청 자격 타입을 조건부 렌더링.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 11:08:10 +09:00
0a0ab05e41 feat(subscription): AnnouncementCard에 district + 5티어 뱃지 2026-04-28 11:06:32 +09:00
f6e78ac0ca feat(subscription): 5티어 뱃지 + 드래그영역 + 토글 + 슬라이더 스타일
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 11:04:13 +09:00
60f17ff3e0 feat(subscription): ProfileTab에 5티어/알림 설정 통합
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 11:01:41 +09:00
344caace3a feat(subscription): NotificationSettings — 임계값 슬라이더 + 알림 토글 2026-04-28 10:58:45 +09:00
9e5521d784 feat(subscription): DistrictTierEditor — 자치구 5티어 드래그앤드롭
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 10:55:54 +09:00
3b3e4a1ee1 feat(subscription): DEFAULT_PROFILE 신규 3필드 + extractTier 헬퍼 2026-04-28 10:51:45 +09:00
a9d9540f61 fix(portfolio): 기술 스택 로고를 정적 4줄 레이아웃으로 변경
LogoLoop 무한 캐러셀이 항목 수가 적은 카테고리에서 반복돼 시각적으로 산만한
문제. 카테고리별로 단순 flex-wrap 줄로 정적 표시. SkillLogoNode와 fallback
로직은 유지. LogoLoop 컴포넌트 자체는 다른 페이지에서 재사용 여지를 위해 보존.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:49:00 +09:00
c68cee502a feat(portfolio): 기술 스택을 SimpleIcons 로고 무한 캐러셀로 표시
LogoLoop 컴포넌트를 추가해 카테고리별(언어/프레임워크/인프라/도구) 4줄
가로 스크롤 캐러셀로 skill 로고를 표시. simpleicons CDN을 사용하며
매칭 안 되는 항목(APScheduler, KIS Open API)은 텍스트 칩으로 자동 fallback.
편집 모드에서는 기존 칩 UI를 유지해 편집·삭제 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:43:57 +09:00
1bd680e47f chore(nav): 사이드바 메뉴 순서 재배치
Home → Portfolio → Blog → Travel → Lotto → Stock → Music → Realestate
→ Blog Lab → Todo → Agent Office → Lab 순으로 navLinks 재정렬.
BottomNav도 동일 source를 사용해 모바일 더보기 패널까지 함께 반영됨.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:28:16 +09:00
60655f8ba9 fix(portfolio): apiFetch에서 Content-Type 헤더가 options.headers에 덮여 사라지는 문제 수정
PUT /api/profile/profile 등 인증 헤더를 함께 보내는 요청에서 Content-Type이
빠져 FastAPI가 body를 JSON으로 파싱하지 못해 422를 반환하던 문제. spread 순서를
뒤집어 options 펼친 뒤 headers를 마지막에 머지하도록 수정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:01:14 +09:00
a50c6c8be2 docs: CLAUDE.md 서비스 네이밍 변경 + personal 서비스 반영
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:29:23 +09:00
b88ae331d7 fix(portfolio): 모바일에서 편집/PDF 툴바 플로팅 버튼으로 표시
768px 이하에서 display:none이던 toolbar를 우하단 FAB 스타일로 변경.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 15:21:14 +09:00
a56923a6b3 refactor(home): Profile 섹션 portfolio API 연동
- /api/profile/public에서 프로필·기술스택 동적 로드
- 서비스 미가동 시 하드코딩 폴백 유지
- "프로필 수정" → "포트폴리오 보기" Link로 교체
- 타임라인 섹션 제거 (포트폴리오 페이지에서 관리)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:38:18 +09:00
a6dd2ef747 feat(portfolio): 포트폴리오 페이지 전체 구현
- 3탭 구조: 프로필&경력, 프로젝트, 자기소개
- 비밀번호 인증 → 편집 모드
- 클립보드 복사, PDF 내보내기 (window.print)
- 사이버펑크 테마 CSS, 모바일 반응형

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:37:25 +09:00
bebd55874c fix(todo): 모바일 최적화 — 터치 타겟 44px, 라벨 버튼, 확인 시트, 탭 인디케이터
- 카드 액션 버튼 36px→44px + 아이콘+텍스트 라벨 (모바일)
- 날짜 필터/입력 터치 타겟 36px min-height로 확대
- 빈 상태 메시지 모바일 적절하게 변경 ("드래그하여 이동"→"아직 항목이 없습니다")
- 완료 비우기 MobileSheet 확인 다이얼로그 (모바일)
- 완료 탭 내 "비우기" 버튼 추가
- SwipeableView 활성 탭 하단 인디케이터 + 44px 높이
- 폼 라벨 14px, 입력 16px (iOS 줌 방지)
- 모바일 컬럼/패널 배경·보더 제거로 공간 절약

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:39:09 +09:00
6cbdf95596 fix(agent-office): critical bug fixes from code review — wall pathfinding, drag/click, DPR, culling
- Pathfinder.setBlocked: remove blocked.clear() to preserve wall tiles set by setWalls()
- Pathfinder.findPath: fix dead-code goal exception — remove redundant isBlocked check, keep goal-tile exception in single guard
- OfficeRenderer: track mouseDownPos/_wasDragging; expose wasDragging() method for click-after-drag suppression
- OfficeRenderer._render: track _lastDpr to detect monitor DPR changes; use setTransform instead of scale to avoid accumulation
- TileMap.render: use clientWidth/clientHeight for viewport culling (CSS space, not buffer pixels)
- TaskTab: wrap JSON.parse in try/catch to prevent crash on malformed result_data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:40:08 +09:00
3e4f2e0934 chore(agent-office): remove legacy dashboard components replaced by v2 UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:38:18 +09:00
31fc2dfb0d refactor(agent-office): rewrite CSS for full-screen canvas layout with mobile bottom sheet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:38:15 +09:00
403046c4d0 refactor(agent-office): rewrite AgentOffice with full-screen canvas and side panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:38:11 +09:00
b03f438935 refactor(agent-office): rewrite useOfficeCanvas hook for new renderer API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:38:07 +09:00
22a37cf6d9 refactor(agent-office): extend useAgentManager with lotto agent and refresh triggers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:38:04 +09:00
6bd6cbd635 feat(agent-office): add SidePanel container with 4-tab layout 2026-04-27 08:35:00 +09:00
4c930c2cf8 feat(agent-office): add LogTab with auto-scroll and level coloring 2026-04-27 08:34:56 +09:00
efeecadbef feat(agent-office): add TokenTab with usage stats and cache hit rate 2026-04-27 08:34:53 +09:00
a712a2f43b feat(agent-office): add TaskTab component with expandable task history 2026-04-27 08:34:50 +09:00
ce245609f9 feat(agent-office): add CommandTab with quick actions, params, and approval UI 2026-04-27 08:34:48 +09:00
43904d033a feat(agent-office): add TopBar component with theme and zoom controls 2026-04-27 08:34:45 +09:00
379ad41e32 feat(agent-office): add overlay renderer with labels, badges, and speech bubbles 2026-04-27 08:33:36 +09:00
f3de315272 refactor(agent-office): wire real AgentSprite import, remove Phase 1 stub 2026-04-27 08:32:22 +09:00
71fe91cc85 feat(agent-office): add SpriteLoader with procedural fallback for Phase 2 2026-04-27 08:32:19 +09:00
7dd2cc9793 refactor(agent-office): rewrite AgentSprite with BFS movement and idle wandering 2026-04-27 08:32:16 +09:00
f01a432329 feat(agent-office): add 16x32 procedural sprite with 5 states and 4 directions 2026-04-27 08:32:13 +09:00
d4279f2e3b refactor(agent-office): rewrite OfficeRenderer with game loop, zoom/pan, Y-sorting 2026-04-27 08:29:02 +09:00
8207205418 feat(agent-office): add procedural furniture renderer with theme support 2026-04-27 08:28:59 +09:00
95b3f2b37c refactor(agent-office): rewrite TileMap with theme support and viewport culling 2026-04-27 08:28:56 +09:00
eab8ef295b feat(agent-office): add BFS pathfinder for agent movement 2026-04-27 08:28:53 +09:00
f11f9c529e feat(agent-office): expand office map to 32x20 with 5 agents and break room 2026-04-27 08:28:49 +09:00
d24c04f9fa feat(agent-office): add theme data definitions (modern/retro/minimal) 2026-04-27 08:28:46 +09:00
b7ee9fe3fd docs: CLAUDE.md·README.md 최신 상태 반영 2026-04-27 07:35:16 +09:00
b8eb290e4d feat(travel): 좌표 없는 커스텀 지역에 항상 "위치 지정" 버튼 표시
지역 변경 직후뿐 아니라, 앨범의 지역이 좌표 미지정 커스텀
지역이면 헤더에 핀 버튼을 상시 노출. 기존 좌표가 있으면
RegionPinPicker에 초기값으로 전달.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 07:20:00 +09:00
fba101500e feat(travel): 지도 핀 마커 + 위치 지정 모달 (Phase 2)
MiniMap에 Point geometry 핀 마커 렌더링, 앨범 지역 변경 후
"위치 지정" 버튼으로 RegionPinPicker 모달을 열어 좌표 저장.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 07:12:17 +09:00
9b8daeffa4 feat(travel): 앨범 지역 편집 UI — 텍스트 입력 + 자동완성
- AlbumDetail 헤더의 지역 라벨 클릭 → 인라인 편집 모드
- 기존 지역 목록 자동완성 제안 + 새 지역명 직접 입력 가능
- Enter/클릭으로 저장, Esc/✕로 취소
- PUT /api/travel/albums/{album}/region 호출 → 앨범 목록 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 12:52:10 +09:00
59bb05ba22 fix(travel): 앨범 커버 지정이 반영되지 않던 문제 수정
- useTravelData: 앨범 목록을 GET /api/travel/albums API로 빌드 (커버 정보 포함)
- 커버 지정 성공 시 refreshAlbums → 앨범 카드 즉시 갱신
- onCoverChange 콜백 체인: Travel → AlbumDetail → HeroLightbox

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 12:32:46 +09:00
093ca6635a feat(travel): 사진 그리드 안정화 + 앨범 커버 지정 버튼 + 동기화 결과 개선
- MasonryGrid: CSS columns → CSS Grid로 전환 (스크롤 시 정렬 위치 변동 방지)
- HeroLightbox: "커버로 지정" 버튼 추가 (PUT /api/travel/albums/{album}/cover 호출)
- Travel: 동기화 토스트에 신규 폴더 발견 수 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 12:13:14 +09:00
047e15cad3 fix(travel): AlbumDetail 스크롤 안 되는 문제 수정 — SwipeableView 높이 체인 + PAGE_SIZE 40
SwipeableView가 fixed overlay 안에서 flex 높이를 채우지 못해 스크롤 불가 + IntersectionObserver 미동작.
scoped CSS로 높이 체인 복원, PAGE_SIZE 20→40으로 증가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 04:42:59 +09:00
d6ace70bff feat(travel): 사진 동기화 버튼 추가 — POST /api/travel/sync 호출 + 결과 토스트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 01:17:10 +09:00
27dca3df69 refactor(travel): Travel.jsx 리팩토링 — 컴포넌트 분리 + 앨범 카드 기반 UI
모놀리식 Travel.jsx(1024줄)를 정리하여 useTravelData, MiniMap,
AlbumCard, AlbumDetail 등 추출된 컴포넌트를 조합하는 깔끔한
메인 컨테이너로 교체. Travel.css에서 photo-mosaic, photo-card,
lightbox, filmstrip 등 개별 컴포넌트 CSS로 이동된 스타일 제거.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reviewed-on: #2
2026-04-11 13:35:35 +09:00
7fc2d3aaf7 feat(music-lab): Suno API 전체 기능 확장 — Phase 1~3 UI 2026-04-09 07:34:21 +09:00
b215a93c89 fix(music-lab): RemixTab default_param_flag 로직 수정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:19:48 +09:00
1f00866694 feat(music-lab): Phase 3 UI — RemixTab + 뮤직비디오 생성
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:14:18 +09:00
0849c70644 feat(music-lab): Phase 2 UI — StemModal, SyncedLyricsPlayer, Style Boost, WAV 변환
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:05:07 +09:00
7a591bb0f1 feat(music-lab): Phase 1 UI — 보컬 성별, 제외 스타일, weight 슬라이더, 더보기 메뉴, CoverArtModal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 08:53:47 +09:00
312677e624 refactor(music-lab): 컴포넌트 분할 — AudioPlayer, LyricsTab, CreditsBadge 추출
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 08:48:35 +09:00
6786f8c883 feat(realestate): 청약 가점 현황 카드 + 매칭 가점 비교
- 내 프로필 탭: 가점 현황 카드 (무주택/부양가족/통장 프로그레스 바)
- 매칭 결과 탭: 상단에 내 가점 뱃지, 각 카드에 가점 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 00:27:57 +09:00
45b74e672a feat(realestate): 공고 카드 매칭 점수 + 매칭 결과 탭 강화
- 공고 카드에 매칭 점수 뱃지 표시 (70+녹색, 40+주황, 기본회색)
- 상세 패널 헤더에 매칭 점수 + 자격 유형 태그 표시
- 매칭 결과 카드에 D-day + 접수일정 + 매칭 사유 표시 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 23:51:09 +09:00
bf5c7ba54e feat(realestate): 즐겨찾기 + D-day 오차 수정 + 가격 표시 + 필드명 수정
- D-day 계산 로컬 타임존 통일 (UTC 파싱 → 로컬 Date 파싱, 1일 오차 해결)
- 즐겨찾기 토글 (카드 ☆/★ + 상세 패널 버튼 + 즐겨찾기 필터)
- 대시보드에 즐겨찾기 섹션 + 가격 표시
- 모델 필드명 수정: supply_price→top_amount, exclusive_area→supply_area
- 카드에 가격 범위 표시 (억/만원 자동 포맷)
- 매칭 결과 필드명 수정: score→match_score, status→ann_status, matched_at→created_at

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 23:39:02 +09:00
8af2824c12 fix: 필수 표시를 텍스트 * 로 변경 — span 요소가 레이아웃 깨트리는 문제 수정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:44:58 +09:00
ff0ee3757c fix: 프로필 preferred_regions/types 배열 변환 + 필수 입력 표시
- 쉼표 구분 문자열 → List[str] 변환 (백엔드 422 에러 수정)
- API 응답 배열 → 표시용 문자열 변환
- 매칭 필수 필드에 * 표시 (무주택, 세대주, 납입기간, 가족수, 선호지역)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:43:15 +09:00
0eb55fe731 realestate: 프론트 청약 페이지를 realestate-lab API로 전면 리디자인
- Subscription.jsx: /api/subscription/* → /api/realestate/* 전환
- 4탭 구성: 대시보드, 공고 목록, 매칭 결과, 내 프로필
- 대시보드: 수집 상태/실행, 진행중 공고, 신규 매칭 통계
- 공고 목록: 자동 수집 공고 카드 그리드 + 필터 + 상세 패널
- 매칭 결과: 프로필 기반 추천 점수순 목록
- 내 프로필: 자격 조건 + 선호 조건 폼
- routes.jsx: /realestate/property 라우트 제거 (RealEstate.jsx 미사용)
- 구 API 경로(/api/subscription/*, /api/realestate/complexes) 완전 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:35:30 +09:00
5dadd4bf2c fix(blog-marketing): 본문 복사 시 HTML 서식 유지
writeText 대신 clipboard.write로 text/html MIME 타입 복사하여
네이버 블로그 에디터에 붙여넣기 시 서식이 유지되도록 개선.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 02:32:54 +09:00
5cf60e7ee6 feat(blog-marketing): 브랜드커넥트 링크 UI + 버그 수정
- 삭제 버튼 한글 깨짐 수정 (삭�� → 삭제)
- 리뷰 점수 표시 /50 → /60 (6기준 60점 체계 반영)
- 브랜드커넥트 링크 관리 UI 추가 (추가/삭제/목록)
- 마케터 실행 버튼 추가 (draft → marketed 전환)
- Marketed 필터 추가 (PostsTab)
- api.js에 링크 CRUD + 마케터 API 함수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 02:09:54 +09:00
74f043bf29 Blog Lab 페이지 추가 (블로그 마케팅 수익화)
4탭 구성: Dashboard, Research, Write, Posts
- BlogMarketing.jsx/css: 키워드 분석, AI 글 생성, 품질 리뷰, 발행 관리
- api.js: blog-marketing API 함수 15개 추가
- routes.jsx + Icons.jsx: Blog Lab 네비게이션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 19:59:28 +09:00
e8e45391ae Music Lyrics: 가사 저장/수정/삭제 기능 추가
- AI 생성 가사 즉시 DB 저장 (세션 휘발 → 영구 보관)
- 저장된 가사 목록 자동 로드 (탭 진입 시)
- 인라인 수정: 제목 + 가사 텍스트 편집 후 저장/취소
- 개별 삭제 버튼
- api.js: getSavedLyrics, saveLyrics, updateLyrics, deleteLyrics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 19:11:47 +09:00
c9e29bdad9 Music: AI 작사(Lyrics) 전용 탭 추가
- Create ↔ Library 사이에 Lyrics 탭 신설
- 프롬프트 입력 (200자) → Suno AI 가사 생성
- 결과 카드: 제목, 가사 텍스트, 프롬프트 표시
- 클립보드 복사 / "Create에서 사용" 버튼 (가사 자동 세팅 후 Create 탭 전환)
- 로딩 shimmer, 에러 배너, 빈 상태 UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 19:02:05 +09:00
c4f67e7d34 Music 서비스 전면 개편: Lab→독립 메뉴 승격 + Suno API 고도화
- 사이드바에 Music 독립 메뉴 추가 (/lab/music → /music)
- Lab 허브에서 Sonic Forge 카드 제거
- LibraryCard: 제목 최대 표시, 파일명 축소, duration 실제값 표시
- 모델 선택 UI (V4/V4_5/V5), 크레딧 잔액 표시
- 곡 연장(Extend), 보컬 분리(Vocal Split) 버튼 추가
- api.js: getMusicModels, getMusicCredits, extendMusicTrack, removeVocals
- 라이브러리 로딩 스켈레톤, Provider 에러 배너
- 모바일 반응형 개선 (모델바, 크레딧, 프로바이더, 카드 액션)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:52:46 +09:00
a727bbf153 MusicStudio: Suno/MusicGen 듀얼 프로바이더 UI 추가
- Provider 선택 바 (Suno 🎙️ / MusicGen 🤖)
- Suno 전용: 보컬/인스트루멘탈 토글, 가사 입력, AI 가사 생성
- 라이브러리·결과 카드에 provider 뱃지 표시
- TrackResult에 가사 접기/펼치기 추가
- api.js: getMusicProviders, generateMusicLyrics 함수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:26:49 +09:00
299ce636ff Functions.jsx 컴포넌트 분할: 1,583→460줄 (3훅+8컴포넌트+유틸)
- lottoUtils.jsx: 공통 유틸·상수 추출 (Ball, NumberRow, 통계 헬퍼 등)
- hooks/useLottoData.js: 핵심 데이터 로드 (최신회차, 통계, 시뮬레이션, 리포트)
- hooks/usePurchases.js: 구매 기록 CRUD
- hooks/useManualRecommend.js: 수동 추천 + 히스토리
- components/: MetricBlock, FrequencyChart, PerformanceBanner,
  ConfidenceRing, CombinedRecommendPanel, ReportPanel,
  PersonalAnalysisPanel, PurchasePanel 분리
- getReport import 누락 버그 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 07:53:25 +09:00
2b463682d5 StockTrade 탭 컴포넌트 분리 (Phase 5+6): 1,932→210줄
5개 탭/드로어 컴포넌트를 components/ 디렉토리로 추출:
- PortfolioTab: 포트폴리오 관리, 예수금, 자산추이 차트
- AiTradeTab: AI 모의투자 잔고, 수동주문, KIS 모달
- ReportTab: 차트, 리스크 분석, 수익률 랭킹, AI 코치
- AdvisorTab: 프롬프트 빌더, 클립보드 복사
- SellHistoryDrawer: 실현손익 드로어, 필터, 폼

StockTrade.jsx는 210줄 오케스트레이터로 축소
(hooks 호출 + lazy load + 헤더 + 탭 바 + 탭 렌더)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 07:37:30 +09:00
1b16b40251 StockTrade 컴포넌트 훅 분리 (Phase 4): 2,788→1,932줄
8개 커스텀 훅으로 state/handler 로직 추출:
- usePortfolio: 포트폴리오 CRUD, 예수금, 브로커 그룹
- useSellHistory: 매도 내역 CRUD, 드로어/폼 상태
- useAiCoach: AI 코치 분석 + 캐시
- useAssetHistory: 자산 추이 차트 데이터
- useMarketContext: VIX/F&G/국채/WTI 시장 데이터
- useAiBalance: AI 모의투자 잔고, 수동 주문
- useReportData: 리포트 정렬, 차트, 집중도 분석
- useAdvisor: 어드바이저 프롬프트 빌더

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 07:31:10 +09:00
314702cb66 AI Coach: 클라이언트 API 키 제거, 백엔드 프록시로 전환
- Anthropic API 직접 호출 → /api/stock/ai-coach 백엔드 프록시로 변경
- API 키 입력 UI 제거 (서버에서 관리)
- aiApiKey 상태 변수 및 localStorage 저장 로직 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:12:37 +09:00
8fcfb6b000 로또 종합 추론 번호 추천 기능 추가
5가지 통계 기법(빈도Z-score·조합지문·갭분석·공동출현·다양성)을
기법별 가중치(30/25/20/15/10%)로 투표 집계하여 최적 6개 번호 도출.
- 기법별 추천 번호 시각화 (최종 번호 하이라이트)
- 투표 참여 기법 수 점 표시 (최대 5개)
- 조합 품질 점수 5차원 바 차트
- 추천 이력 히스토리 누적 저장

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:40:35 +09:00
22573909ec AI 어드바이저: getAiAnalysis 제거, 유망 섹터 추천 프롬프트 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 04:47:24 +09:00
9bce2bfb6e fix: buildAdvisorPrompt TDZ 오류 수정 — portfolioHoldings 정의 이후로 이동
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 04:42:11 +09:00
f7175ad80c AI 어드바이저 탭을 프롬프트 생성/복사 방식으로 전환
Gemini API 직접 호출 대신 포트폴리오 데이터 기반 전문가 프롬프트를
자동 생성하고 클립보드에 복사하는 방식으로 변경.
- 보유 종목, 평균매입가, 현재가, 손익, 예수금, 시장 지표 포함
- Gemini/ChatGPT 바로가기 링크 제공
- 프롬프트 미리보기 영역 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 04:39:13 +09:00
d1ecf13400 stock AI 어드바이저 추가 및 UX 개선
- Gemini Pro 기반 AI 어드바이저 탭 추가 (TAB_ADVISOR)
  - 보유 종목 현재가 + 뉴스 → 종목별 매도/매수/분할매도 지침
  - 5분 캐시, 강제 새로고침 버튼
  - 경량 마크다운 렌더러 (AdvisorMarkdown)
- 실현손익 수수료 → 수수료 & 세금으로 레이블 변경
- 총 자산 추이 그래프: 0 데이터 제외 (장 미개장일 필터)
- Todo 완료 패널 하단 이동 + 날짜 필터 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 03:54:50 +09:00
2c4b1e2e3a TODO UX 개선 2026-03-22 12:19:02 +09:00
76447fa262 음악 제작 랩 추가 2026-03-21 10:21:11 +09:00
46e122a229 lotto 기능 고도화 2026-03-20 01:55:53 +09:00
248835fa54 stock 실현손익 보여줄 수 있게 화면 구성 추가 2026-03-19 23:36:33 +09:00
b8d6dac70a 부동산 정보 api 오류 해결 2026-03-16 08:32:42 +09:00
df54437f47 Lab 설정 변경 및 유틸 추가 2026-03-16 03:00:04 +09:00
dac06fc4eb README.md 업데이트 2026-03-16 02:30:13 +09:00
1af16dde47 부동산 정보 페이지 추가 2026-03-16 02:10:45 +09:00
c6ac849a25 주식 히스토리 API 및 블로그 작성 API 추가 2026-03-11 08:08:39 +09:00
bbc9bf36f9 home 화면 todo list 보이게 추가 2026-03-06 02:43:55 +09:00
b9aeb2ff3e 주식 각종 지표 업데이트 2026-03-05 08:14:49 +09:00
fa696b0c90 stock 지표 수정 및 자산 분석 탭 항목 추가 2026-03-05 03:12:25 +09:00
c28bd9368c 시장 주요 지표 참고 추가 2026-03-05 02:45:45 +09:00
ccc9f7c634 dashboard 형태의 UI 수정 및 고도화 2026-03-04 08:29:39 +09:00
618d5f8e6f UI 디자인 대대적으로 대시보드 형태의 전문적인 느낌으로 재구성 2026-03-04 01:39:26 +09:00
840b0a5300 주식 보유 카드 UI 수정 & 평가 금액 추가 2026-02-26 01:54:09 +09:00
3e9112c4c7 계좌 페이지 분류 2026-02-26 01:32:49 +09:00
c4abdbed3e portfolio api 오류 수정 2026-02-26 00:38:07 +09:00
191 changed files with 48364 additions and 4518 deletions

34
.claude/settings.json Normal file
View File

@@ -0,0 +1,34 @@
{
"permissions": {
"allow": [
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git branch:*)",
"Bash(git stash list:*)",
"Bash(git remote -v)",
"Bash(npm run:*)",
"Bash(npm test:*)",
"Bash(npm install:*)",
"Bash(npm ci:*)",
"Bash(npm list:*)",
"Bash(npm view:*)",
"Bash(npm outdated:*)",
"Bash(npx eslint:*)",
"Bash(npx vite:*)",
"Bash(node -v)",
"Bash(ls:*)"
],
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/credentials*)",
"Read(**/secrets*)",
"Read(**/*.pem)",
"Read(**/*.key)"
]
}
}

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm run:*)"
]
}
}

4
.gitignore vendored
View File

@@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env.local
# Superpowers visual companion (mockup files)
.superpowers/

372
CLAUDE.md Normal file
View File

@@ -0,0 +1,372 @@
# web-ui — CLAUDE.md
개인 웹페이지 프론트엔드 프로젝트에 대한 컨텍스트 문서입니다.
## 프로젝트 개요
- **스택**: React 18 + Vite + react-router-dom v6
- **목적**: 개인 블로그, 로또 실험, 주식 뉴스/트레이딩, 여행 기록을 한 곳에 모은 개인 웹 UI
- **배포 대상**: Synology NAS (`gahusb.synology.me`) Docker 컨테이너 내 nginx
## 페이지 구조
| 경로 | 컴포넌트 | 설명 |
|------|----------|------|
| `/` | `Home` | 메인 허브 |
| `/blog` | `Blog` | 마크다운 기반 블로그 |
| `/lotto` | `Lotto` | 로또 추천/통계 |
| `/stock` | `Stock` | 주식 뉴스/지수 |
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
| `/lab/sword-stream` | `SwordStream` | Three.js 파티클 인터랙션 |
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
| `/todo` | `Todo` | 태스크 보드 |
| `/blog-lab` | `BlogMarketing` | 블로그 마케팅 수익화 대시보드 |
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
---
## API 설정
### 핵심 원칙
- **항상 상대 경로 사용**: 프로덕션에서 프론트와 백엔드는 nginx 리버스 프록시로 동일 도메인에서 서비스됨
- **절대 URL 사용 금지**: `https://` 절대 URL을 fetch에 직접 사용하면 Mixed Content 오류 발생
- `VITE_API_BASE` 환경변수는 사용하지 않음
### API 헬퍼 (`src/api.js`)
```js
// 모든 API 호출은 이 헬퍼를 통해 사용
import { apiGet, apiPost, apiPut, apiDelete } from './api';
// 예시
apiGet('/api/lotto/latest')
apiPost('/api/portfolio', { ... })
```
제공 함수: `apiGet`, `apiPost`, `apiPut`, `apiDelete`
### 개발 서버 프록시 (`vite.config.js`)
```js
proxy: {
'/api': { target: 'https://gahusb.synology.me', changeOrigin: true, secure: true },
'/media': { target: 'https://gahusb.synology.me', changeOrigin: true, secure: true },
// /ext/* — Yahoo Finance, CNN Fear&Greed 등 외부 API 프록시
}
```
- `/api/*` → NAS 백엔드 (nginx가 서비스별 라우팅: lotto, personal, stock-lab, music-lab 등)
- `/media/*` → NAS 미디어 파일 (여행 사진 `/media/travel/`, 음악 `/media/music/`)
- 개발 서버 포트: **3007**
### API 엔드포인트 목록
| 분류 | 메서드 | 경로 |
|------|--------|------|
| 로또 기본 | GET | `/api/lotto/latest`, `/api/lotto/stats`, `/api/lotto/recommend` |
| 로또 기본 | GET | `/api/lotto/best`, `/api/lotto/analysis` |
| 로또 기본 | POST | `/api/admin/simulate` |
| 로또 고도화 | GET | `/api/lotto/stats/performance` |
| 로또 고도화 | GET | `/api/lotto/report/latest`, `/api/lotto/report/:drw_no`, `/api/lotto/report/history?limit=N` |
| 로또 고도화 | GET | `/api/lotto/analysis/personal` |
| 로또 구매 | GET | `/api/lotto/purchase?draw_no=N&days=N`, `/api/lotto/purchase/stats` |
| 로또 구매 | POST/PUT/DELETE | `/api/lotto/purchase`, `/api/lotto/purchase/:id` |
| 히스토리 | GET | `/api/history` |
| 히스토리 | DELETE | `/api/history/:id` |
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
| 트레이딩 | GET | `/api/trade/balance` |
| 트레이딩 | POST | `/api/trade/order` |
| 스크리너 | GET | `/api/stock/screener/nodes` |
| 스크리너 | GET/PUT | `/api/stock/screener/settings` |
| 스크리너 | POST | `/api/stock/screener/run` — body: `{ mode, asof?, weights?, ... }` |
| 스크리너 | POST | `/api/stock/screener/snapshot/refresh` |
| 스크리너 | GET | `/api/stock/screener/runs?limit=N` |
| 스크리너 | GET | `/api/stock/screener/runs/:id` |
| 포트폴리오 | GET/POST | `/api/portfolio` |
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
| 예수금 | DELETE | `/api/portfolio/cash/:broker` |
| 자산 스냅샷 | POST | `/api/portfolio/snapshot` — body: `{ total_assets }` 또는 body 없이 서버 계산 |
| 자산 스냅샷 | GET | `/api/portfolio/snapshot/history?days=N` — response: `{ history: [{date, total_assets}] }` |
| 실현손익 | GET | `/api/portfolio/sell-history?broker=X&days=N` — response: `{ records: [...] }` |
| 실현손익 | POST/PUT | `/api/portfolio/sell-history`, `/api/portfolio/sell-history/:id` |
| 실현손익 | DELETE | `/api/portfolio/sell-history/:id` |
| TODO | GET/POST | `/api/todos` — personal 서비스 |
| TODO | PUT/DELETE | `/api/todos/:id`, `/api/todos/done` — personal 서비스 |
| 블로그 | GET/POST | `/api/blog/posts` — personal 서비스 |
| 블로그 | PUT/DELETE | `/api/blog/posts/:id` — personal 서비스 |
| AI 음악 | POST | `/api/music/generate` — body: `{ title, genre, moods, instruments, duration_sec, bpm, key, scale, prompt }``{ task_id }` |
| 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` |
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
| 부동산 | PUT | `/api/realestate/profile` — body: `{ preferred_districts: { "S": [...], "A": [...], "B": [...], "C": [...], "D": [...] }, min_match_score: int, notify_enabled: bool, ... }` |
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
| 포트폴리오 | CRUD | `/api/profile/careers`, `/api/profile/projects`, `/api/profile/skills`, `/api/profile/introductions` — personal 서비스 |
---
## NAS 배포 설정
### 배포 경로
| 환경 | 경로 |
|------|------|
| Windows | `Z:\docker\webpage\frontend\` (NAS 네트워크 드라이브 마운트) |
| macOS (SMB) | `/Volumes/gahusb.synology.me/docker/webpage/frontend/` |
| macOS (SSH) | `/volume1/docker/webpage/frontend/` |
### 배포 명령어
```bash
# 빌드 + 배포 (권장)
npm run release:nas
# 빌드만
npm run build
# 배포만 (dist 폴더가 이미 있을 때)
npm run deploy:nas
```
### Windows 배포
NAS가 `Z:` 드라이브로 마운트되어 있어야 함. `robocopy``/MIR` 동기화하며 로그는 `robocopy.log`에 저장됨.
### macOS 배포 — SSH 방식 (권장)
```bash
# 환경변수 설정 후 배포
NAS_SSH_TARGET=user@gahusb.synology.me NAS_SSH_PORT=22 npm run release:nas
```
`NAS_SSH_TARGET`이 설정되면 `rsync`로 SSH 배포. SMB 마운트 방식보다 안정적.
### macOS 배포 — SMB 마운트 방식
SMB 마운트 후 `ditto`로 복사. `NAS_CLEAN=1` 설정 시 배포 전 기존 파일 전체 삭제.
```bash
NAS_CLEAN=1 npm run release:nas
```
### 배포 스크립트 파일
`scripts/deploy-nas.cjs` — Node.js CJS 모듈, 플랫폼 자동 감지
---
## 개발 환경
```bash
npm install
npm run dev # localhost:3007 에서 개발 서버 실행
npm run build # dist/ 로 프로덕션 빌드
npm run lint # ESLint 검사
npm run preview # 빌드 결과물 미리보기
```
## 주요 파일 위치
| 파일 | 역할 |
|------|------|
| `src/api.js` | API 헬퍼 함수 모음 |
| `src/routes.jsx` | 라우트 및 네비게이션 링크 정의 |
| `src/Router.jsx` | BrowserRouter 설정 |
| `vite.config.js` | 개발 서버 및 프록시 설정 |
| `scripts/deploy-nas.cjs` | NAS 배포 스크립트 |
| `src/content/blog/` | 블로그 마크다운 파일 |
| `public/` | 정적 파일 (로고, API 스펙 등) |
---
## Sonic Forge — AI 음악 생성 스튜디오 (`/lab/music`)
### 파일 구조
| 파일 | 역할 |
|------|------|
| `src/pages/music/MusicStudio.jsx` | 메인 컴포넌트 |
| `src/pages/music/MusicStudio.css` | 스타일 (Bebas Neue · Syne · Courier Prime) |
### 주요 컴포넌트
- **SonicRadar** — 헤더 우측 비주얼. SVG 링·크로스헤어·스윕 라인 + 48개 CSS 방사형 바. `isGenerating` / `accentColor` prop으로 상태 전환
- **WaveformCanvas** — 스테이지 우측 캔버스 오실로스코프 (헤더와 별도)
- **AudioPlayer** — 실제 `<audio>` 엘리먼트 기반. `audio_url` 없으면 타이머 폴백
- **Library** — 저장된 트랙 카드 그리드 + 삭제/재생
- **GenerationProgress** — 진행률 바 + 단계 메시지
### 생성 플로우
```
handleGenerate()
→ POST /api/music/generate (payload에 title 포함)
→ task_id 반환 시: setInterval 3초 폴링 (getMusicStatus)
succeeded → setTrack(status.track 우선, 없으면 로컬 조립) + loadLibrary()
failed → genError 표시
→ API 실패 시: 6단계 시뮬레이션 폴백 (오프라인 모드)
```
### 백엔드 연동 규칙
- `audio_url`은 반드시 **상대경로** `/media/music/{task_id}.mp3` 형식 (절대 URL 금지)
- `status` 응답 shape: `{ status, progress, message, audio_url?, error?, track? }`
- `track` 객체: `{ id, title, genre, moods[], instruments[], duration_sec, bpm, key, scale, audio_url, created_at }`
- 백엔드가 `succeeded` 시 library 자동 등록 → 프론트는 "Save" 버튼 없음, `loadLibrary()` 자동 호출
- generate payload에 `title` 포함 → 백엔드에서 payload title 우선 사용 권장
### CSS 설계 특이사항
- 설명 토글: `.ms-desc-wrap` + `grid-template-rows: 0fr → 1fr` 트랜지션으로 높이 애니메이션
- 완전히 닫힐 때 노출 방지: `.ms-desc-wrap { overflow: hidden }` + `.ms-desc-wrap > * { min-height: 0 }`
- 장르 선택 시 `--ms-accent` / `--radar-accent` / `--g-color` CSS 변수로 전체 컬러 테마 동기화
---
## Lotto 고도화 (`/lotto`)
`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 | 설명 |
|------|-----|------|
| PerformanceBanner | `/api/lotto/stats/performance` | 수익률·당첨 통계 상단 띠 |
| ReportPanel | `/api/lotto/report/*` | 주간 리포트 + 전략 카드 + ConfidenceRing |
| PersonalAnalysisPanel | `/api/lotto/analysis/personal` | 개인 번호 성향 분석 |
| PurchasePanel | `/api/lotto/purchase/*` | 구매 내역 CRUD |
---
## Travel 갤러리 (`/travel`)
테마: "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에서 직접 서빙
---
## Windows AI 서버 (`C:\Users\jaeoh\Desktop\workspace\music_ai`)
NAS의 music-lab 컨테이너 대신 Windows PC(RTX 5070 Ti)에서 MusicGen을 로컬 추론하는 별도 서버.
### 구성 파일
| 파일 | 역할 |
|------|------|
| `server.py` | FastAPI 서버 (generate/status/audio 엔드포인트) |
| `model.py` | MusicGen 래퍼 + 프롬프트 빌더 (genre/mood/instruments→텍스트) |
| `.env` | MODEL_NAME, OUTPUT_DIR, SERVER_PORT 등 |
| `setup.bat` | venv 생성 + PyTorch CUDA 12.4 + audiocraft 설치 |
| `start.bat` | 서버 시작 |
### 엔드포인트
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/health` | 서버·GPU 상태 확인 |
| POST | `/generate` | 음악 생성 → `task_id` 즉시 반환 |
| GET | `/status/{task_id}` | 생성 진행 폴링 |
| GET | `/audio/{task_id}.mp3` | 완성 오디오 파일 |
### 모델
- 기본: `facebook/musicgen-stereo-large` (16GB VRAM, 스테레오 고품질)
- RTX 5070 Ti(16GB)로 실행 가능
### NAS 연동 흐름
```
web-ui → POST /api/music/generate (NAS music-lab)
→ music-lab이 Windows PC :8765/generate 호출
→ Windows PC가 MusicGen 추론 → WAV → MP3 변환
→ music-lab이 /status 폴링 → audio_url 다운로드
→ /media/music/{task_id}.mp3 저장 → DB 등록
→ 프론트 폴링 성공 → Library 자동 갱신
```
Windows 방화벽에서 포트 8765 인바운드 허용 필요.

234
README.md
View File

@@ -1,27 +1,223 @@
# Web UI
블로그, 로또 추천 실험, 여행 기록을 한 곳에서 모아 보는 개인 웹 UI입니다.
개인 대시보드 — 블로그, 로또, 주식, 부동산, 여행, AI 음악, AI 에이전트, 할 일 등을 한 곳에서 관리하는 개인 웹 UI입니다.
## 블로그
## 기술 스택
- 마크다운 기반 글 작성 및 자동 목록화 (`src/content/blog`)
- 태그 기반 카테고리 분류와 카테고리별 목록 뷰
- 목록/본문 분리 UI, 페이지네이션 지원
- 인라인 스타일(링크/강조/코드/이미지) 렌더링 지원
| 분류 | 사용 기술 |
|------|-----------|
| 프레임워크 | React 18 + Vite |
| 라우팅 | react-router-dom v6 |
| 지도 | react-leaflet + Leaflet |
| 차트 | Recharts |
| 3D | Three.js |
| 제스처 | react-swipeable |
| 스타일 | 커스텀 CSS (CSS 변수 기반 사이버펑크 다크 테마) |
| 배포 | Synology NAS (Docker + nginx 리버스 프록시) |
## Lotto Lab
---
- 최신 로또 결과 조회
- 추천 번호 생성 (가중치/최근 회차/회피 수 등 파라미터 반영)
- 프리셋 파라미터로 빠른 추천 생성
- 추천 히스토리 목록 확인 및 삭제
- 번호 복사 기능
- API 스펙 다운로드 링크 제공 (`public/lotto-api.md`)
## 페이지 구성 (13개 라우트)
## 여행 기록 (Travel Archive)
### Home (`/`)
- 지도 기반 지역 선택 (GeoJSON)
- 선택한 지역의 사진 목록 로딩 및 캐시
- 스크롤 기반 사진 추가 로딩 (chunked lazy load)
- 썸네일/모달 뷰, 키보드/스와이프 네비게이션
- 앨범/파일 메타 정보 표시
개인 아카이브 허브.
- 전체 페이지 네비게이션 카드 그리드
- 최근 업데이트 및 할 일 목록 미리보기
- 사이트 소개 및 최신 활동 요약
---
### Blog (`/blog`)
마크다운 기반 개인 블로그.
- `src/content/blog/` 폴더의 마크다운 파일 자동 목록화
- 태그 기반 카테고리 분류 및 필터링
- 목록 / 본문 분리 UI, 페이지네이션 지원
- 인라인 스타일(링크, 강조, 코드, 이미지) 렌더링
---
### Lotto (`/lotto`) — 14 컴포넌트
로또 번호 추천 및 통계 실험실.
- **3탭 구조**: 이번 주 브리핑 / 분석·통계 / 구매·성과
- AI 큐레이터 브리핑 (5세트 + 내러티브 + 토큰·비용 집계)
- 가중치·최근 회차·회피 수 파라미터 기반 번호 추천
- 몬테카를로 시뮬레이션 최적 번호 표시
- 전략 진화 (EMA+Softmax) 기반 메타 추천
- 주간 리포트 + ConfidenceRing 시각화
- 구매 이력 CRUD + 성과 통계 (수익률·당첨 현황)
- 프리셋으로 빠른 추천 생성, 번호 원클릭 복사
---
### Stock (`/stock`)
주식 시장 모니터링 대시보드.
- KOSPI, KOSDAQ, 나스닥, S&P500 등 주요 지수 현황
- Fear & Greed 지수 및 VIX 변동성 지표 시각화
- 미국 10년물 금리, WTI/Brent 유가 등 매크로 지표
- 국내 / 해외 주식 뉴스 탭 분류 및 필터링
### Stock Trade (`/stock/trade`) — 7 컴포넌트
포트폴리오 관리 및 트레이딩 데스크.
- **포트폴리오 탭**: 보유 종목 수익률, 자산 구성 차트 (파이/바 차트)
- **AI 탭**: AI 시장 분석 요약 및 투자 인사이트
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이
- **어드바이저 탭**: 투자 조언 및 리밸런싱 제안
- 종목 추가/편집/삭제 CRUD, 현금 잔고(예수금) 관리
- 매도 히스토리 드로어 (실현손익 추적)
---
### Realestate (`/realestate`) — 2 섹션
부동산 청약 통합 관리.
#### 청약 대시보드 (`/realestate`)
- 관심 청약 카드 목록 + 상세 패널 (요건/일정/자금 섹션)
- 전체 청약 일정 타임라인 (청약 → 계약 → 중도금 → 잔금)
- 가점 계산 엔진 (무주택 32점 + 부양가족 35점 + 통장 17점 = 84점 만점)
- 청약 유형 분류: 줍줍 / 특공 / 일반
#### 부동산 정보 (`/realestate/property`)
- 관심 아파트 단지 카드 그리드 + Leaflet 지도 통합 뷰
- D-day 카운트다운, 평당가 비교 바 차트 (Recharts)
- 모달 기반 단지 추가/편집, 네이버 부동산 바로가기 연동
---
### Travel (`/travel`) — 8 컴포넌트
여행 사진 갤러리 (Dark Room 테마).
- **MiniMap**: GeoJSON 기반 접이식 세계 지도 — Polygon(기존 지역) + CircleMarker(핀)
- **AlbumCard**: 앨범 썸네일 카드 그리드 (커버 이미지 + 지역 라벨 + 사진 수)
- **AlbumDetail**: 앨범 상세 오버레이 — 사진/영상 탭 + 지역 인라인 편집
- **MasonryGrid**: CSS columns Masonry 레이아웃 + IntersectionObserver 무한 스크롤
- **HeroLightbox**: 전체화면 사진 뷰어 — 스와이프/키보드 네비 + 앨범 커버 지정
- **RegionPinPicker**: 커스텀 지역 좌표 지정 모달 (Leaflet 클릭 → 핀 저장)
- 40장 단위 청크 로딩, PullToRefresh 지원
---
### 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 효과 테스트 공간.
- **SwordStream**: Three.js 1,500개 파티클 3D 애니메이션 (호버/오빗 모드)
- **DayCalc**: 날짜 계산 유틸리티
---
### Todo (`/todo`)
태스크 관리 보드.
- 칸반 레이아웃: 할 일 → 진행 중 → 완료
- 드래그 앤 드롭으로 상태 변경
- 태스크 추가/삭제, 완료 항목 일괄 정리
---
## 공통 컴포넌트 (`src/components/`)
| 컴포넌트 | 설명 |
|----------|------|
| `Navbar` | 상단 네비게이션 바 |
| `BottomNav` | 모바일 하단 네비게이션 |
| `PageHeader` | 페이지 헤더 + 브레드크럼 |
| `SwipeableView` | 스와이프 탭 컨테이너 |
| `PullToRefresh` | 풀투리프레시 제스처 |
| `MobileSheet` | 모바일 바텀시트 모달 |
| `FAB` | 플로팅 액션 버튼 |
| `FearGreedGauge` | 공포·탐욕 게이지 |
| `Loading` | 로딩 스피너 |
| `Icons` | SVG 아이콘 라이브러리 |
---
## 개발 환경
```bash
npm install
npm run dev # localhost:3007
npm run build # dist/ 빌드
npm run lint # ESLint
npm run preview # 빌드 결과 미리보기
```
## NAS 배포
```bash
# 빌드 + NAS 배포 (Windows, Z: 드라이브 마운트 필요)
npm run release:nas
# SSH 배포 (macOS)
NAS_SSH_TARGET=user@gahusb.synology.me NAS_SSH_PORT=22 npm run release:nas
```
## API 설정
모든 API 호출은 상대 경로(`/api/...`)를 사용합니다. 개발 서버에서는 `vite.config.js`의 프록시 설정으로 NAS 백엔드(`gahusb.synology.me`)로 자동 전달됩니다.
```js
import { apiGet, apiPost, apiPut, apiDelete } from './api';
apiGet('/api/stock/indices');
apiPost('/api/travel/sync');
```
## 프로젝트 통계
| 항목 | 값 |
|------|-----|
| 페이지 라우트 | 13개 |
| JSX 컴포넌트 | 62+ |
| 공통 컴포넌트 | 10개 |
| API 헬퍼 함수 | 65+ |
| 외부 라이브러리 | React, Router, Leaflet, Recharts, Three.js, react-swipeable |

126
STATUS.md Normal file
View File

@@ -0,0 +1,126 @@
# web-ui — 구현 현황 & 로드맵
> 최종 갱신: 2026-05-07
> 자세한 페이지·API 표는 [CLAUDE.md](./CLAUDE.md) 참조.
---
## 1. 구현 완료
### 1-1. 메인 페이지
| 경로 | 상태 | 비고 |
|------|------|------|
| `/` Home | ✅ | 메인 허브 |
| `/blog` Blog | ✅ | 마크다운 기반 |
| `/portfolio` Portfolio | ✅ | 프로필·경력·프로젝트·자기소개 |
| `/todo` Todo | ✅ | 태스크 보드 |
### 1-2. 로또 (`/lotto`)
| 영역 | 상태 |
|------|------|
| 3탭 구조 (브리핑 / 분석·통계 / 구매·성과) | ✅ |
| AI 큐레이터 브리핑 탭 | ✅ |
| 성과 배너 + ReportPanel + ConfidenceRing | ✅ |
| 개인 분석 패널 | ✅ |
| 구매 내역 CRUD + 성과 통계 | ✅ |
### 1-3. 주식 (`/stock`, `/stock/trade`)
| 영역 | 상태 |
|------|------|
| 뉴스·지수 | ✅ |
| 트레이딩 + 잔고 | ✅ |
| 포트폴리오 (수동 입력 종목 + 예수금 + 자산 추이) | ✅ |
| 자산 스냅샷 + 7/30/90일 차트 | ✅ |
| 실현손익(매도이력) Drawer | ✅ |
| 포트폴리오 카드 모바일 금액 줄바꿈 대응 | ✅ (2026-05-06) |
### 1-4. 청약 (`/realestate`, `/realestate/property`)
| 영역 | 상태 |
|------|------|
| 자치구 5티어 (S/A/B/C/D) 드래그&드롭 + 슬라이더 + 토글 | ✅ |
| 카드/매칭 결과에 district 뱃지 + 5티어 뱃지 | ✅ |
| AnnouncementDetail 매칭 분석 섹션 | ✅ |
| 5축 점수 breakdown 시각화 + 알림 대상 카운트 | ✅ |
| 청약 일정 캘린더 뷰 | ✅ |
| 프로필 완성도 힌트 배너 + 소득 기준 힌트 | ✅ |
### 1-5. 여행 (`/travel`)
| 영역 | 상태 |
|------|------|
| Dark Room 테마 갤러리 | ✅ |
| 앨범 카드 + Masonry + Lightbox + MiniMap | ✅ |
| 지역 변경 + 핀 좌표 지정 | ✅ |
| 영상(VideoTab) | 🚧 준비 중 |
### 1-6. 음악 스튜디오 (`/lab/music` — Sonic Forge)
| 영역 | 상태 |
|------|------|
| Create 탭 (장르/무드/악기/BPM/Key) + 트랙 제목 직접 입력 | ✅ |
| Library 탭 + 트랙 카드 + 삭제/재생 | ✅ |
| YouTube 탭 (서브탭 4개: VideoProjects / Trends / Revenue / Compile) | ✅ (2026-05-01~05-06) |
| 다중 트랙 컴파일 (FFmpeg concat → MP4) | ✅ |
| 시장 트렌드 리포트 (장르/추천수/이력) | ✅ |
### 1-7. 기타 Lab
| 경로 | 상태 |
|------|------|
| `/lab/sword-stream` Three.js 파티클 | ✅ |
| `/lab/day-calc` 날짜 계산기 | ✅ |
| `/agent-office` 에이전트 가상 오피스 (WebSocket) | ✅ |
| `/blog-lab` 블로그 마케팅 수익화 대시보드 | ✅ |
### 1-8. 인프라 / DX
| 항목 | 상태 |
|------|------|
| Vite 개발 서버 프록시 (`/api`, `/media`, `/ext`) | ✅ |
| Windows robocopy + macOS SSH/SMB 배포 (`scripts/deploy-nas.cjs`) | ✅ |
| Mac SSH 배포 + tar\|ssh 전환 (Synology rsync 우회) | ✅ |
| 반응형 웹 디자인 패스 | ✅ |
---
## 2. 진행 중 / 향후 계획
### 2-1. Travel 영상 탭 완성
- 현재 "준비 중" 플레이스홀더 → 실제 영상 업로드/재생 UI 구현
- 백엔드 `travel-proxy`에 영상 메타·썸네일 API 필요
### 2-2. 로또 프리미엄 구독 UI (백엔드 Phase 3 연동)
- 회원 가입/로그인 UI (JWT)
- 구독 플랜 선택 + Toss/Stripe 결제 플로우
- 구독자 전용 리포트·알림 영역
- 백엔드 로드맵: `web-backend/docs/lotto-premium-roadmap.md`
### 2-3. Music YouTube 탭 후속
- VideoProjects 실제 렌더링 진행률 시각화 강화
- Compile 탭에 트랙 트림/페이드 옵션
- Revenue 대시보드 차트 강화
### 2-4. 청약 후속
- 알림 dry-run 미리보기 UI (어떤 공고가 매칭됐을지 사전 확인)
- 모바일 5티어 편집 모드 (현재 PC 전용)
### 2-5. 포트폴리오/주식 후속
- 종목별 평균 매입가 분할 입력 UI
- 매도 시뮬레이터 (수익률 시나리오 비교)
### 2-6. 일반
- 다크/라이트 테마 토글 (현재 다크 단일)
- PWA 설치 + 홈화면 단축 (모바일 사용 빈도 증가)
---
## 3. 참고 문서
- 페이지·라우트·API 전체 표: [CLAUDE.md](./CLAUDE.md)
- 워크스페이스 통합 가이드: `../CLAUDE.md`
- 백엔드 상태: `../web-backend/STATUS.md`
- 백엔드 Spec/Plan 디렉토리: `../web-backend/docs/superpowers/`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,358 @@
# Lotto Curator Evolution — Design Spec
- 일자: 2026-05-11
- 범위: `web-ui` (브리핑 탭 재구성), `web-backend/lotto` (스키마·잡), `web-backend/agent-office` (큐레이터·텔레그램)
- 컨셉 한 줄: **매주 같은 시간에 큐레이터가 한 번 더 똑똑해진다**
## 1. 동기와 문제
현재 `/lotto`는 3탭(브리핑·분석·구매)으로 구성되어 정보가 풍부하지만, 사용자가 5천~1만원 어치를 즐기며 구매하기에 다음 페인이 있다.
- 분석·통계·브리핑이 모두 *결정용 화면*처럼 노출되어 정보 과다.
- 큐레이터가 매주 5세트를 추천하지만, 5세트의 *역할*과 *왜 이 분배인지*가 와닿지 않는다.
- 큐레이터·시스템에 시간축이 없다. 매주 동일 알고리즘을 새로 도는 느낌.
- 1만원어치 구매 시 5세트로는 부족하다. 추가 게임에 대한 설계가 없다.
## 2. 컨셉
다음 두 축으로 강화한다.
- **서사적 진화**: 큐레이터가 매주 *지난 주를 회고*하고 이번 주 전략으로 이어간다. 자기 추천 결과 + 사용자 실제 구매 결과를 둘 다 회고 데이터로 사용한다.
- **포트폴리오 명료성**: 5게임이 단순 5장이 아니라 안정/균형/공격 분배가 그 주 데이터에 따라 동적으로 바뀌고, 그 이유가 한 줄로 와닿는다. 5~20세트로 위계적으로 확장된다.
## 3. 주간 사이클
```
토 20:35 추첨
일 03:00 추첨결과 sync (기존)
채점 잡 (신규) → weekly_review INSERT
lotto_purchase auto_graded UPDATE
월 09:00 큐레이션 트리거 (lotto_agent.on_schedule)
├─ build_retrospective(target_draw)
├─ collect_candidates(N=30)
├─ build_context (+retrospective)
├─ Claude 호출 (회고+계층 규칙)
└─ briefings INSERT (4계층 picks)
월 09:05 텔레그램 헤드라인 푸시
월~토 사용자: 사이트 결정 카드 → 모드 선택(5/10/15/20) → 1탭 구매 기록
토 20:35 추첨 → 다음 사이클
```
cron 시간(일 03:00 / 월 09:00)은 운영하며 조정 가능한 기본값.
## 4. 결정 카드 (브리핑 탭 메인)
브리핑 탭을 단일 `DecisionCard`로 재구성한다. 정보 위계는 위→아래로:
1. **헤더** — 회차 + 한 줄 헤드라인 + 신뢰도(0~100, 큐레이터 자기 평가)
2. **회고 박스** (▸ 보라색 라벨) — 지난 주 너 + 큐레이터 한 줄 회고. *시간축*의 핵심.
3. **헤드라인 + 3줄** — 이번 주 전망 + 근거 3줄(기존 narrative 유지).
4. **분배 칩** — 선택 모드까지의 안정/균형/공격 합산 + "왜 이 분배인지" 한 줄.
5. **모드 토글** — 4단계 칩(코어 5 / +보너스 5 / +확장 5 / +풀 5).
6. **계층 섹션 × 4** — 각 계층마다 타이틀 + 사유 한 줄 + 5장 PickCard. 코어는 항상 펼침, 그 외는 모드에 따라.
7. **하단 액션** — "이대로 N세트 구매했음" 한 클릭 → 자동 기록.
### 4계층 위계
| 계층 | 누적 게임 | 비용 | 큐레이터의 의도 |
|---|---|---|---|
| 코어(필수) | 5 | 5천 | 안정 2 / 균형 2 / 공격 1, 그 주 주축 |
| + 보너스 | 10 | 1만 | 코어 분배의 공백 보완 |
| + 확장 | 15 | 1.5만 | 코어·보너스에 없던 시각(합계 극단·콜드 누적·4주 미등장) |
| + 풀 | 20 | 2만 | 한 번도 누르지 않은 패턴(연속·동끝·5수 균등) |
각 5세트는 *큐레이터가 의도한 한 묶음*이며, 늘어날수록 *서사가 더해지는 구조*. 마지막 모드 선택은 브라우저 `localStorage``lotto.tier_mode` 키로 저장하여 다음 주 진입 시 디폴트로 사용한다(서버 저장 X — 사용자 디바이스 단위 기억).
### 분석 탭은 "Deep Dive" 자료실로 강등
- 라벨 변경: `📊 분석·통계``📚 자료실 / Deep Dive`
- 첫 진입 시 모든 패널 접힘
- 기존 패널 모두 보존 (CombinedRecommendPanel, ReportPanel, 시뮬레이션, 통계, 빈도, PersonalAnalysisPanel, 수동 추천, 히스토리)
- PerformanceBanner는 결정 카드 헤더와 역할 중복 없도록 자료실에만 둠
## 5. 데이터 모델
### 신규 테이블 — `weekly_review`
```sql
CREATE TABLE weekly_review (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draw_no INTEGER NOT NULL UNIQUE,
-- 큐레이터 자기 평가 (briefings.picks vs 추첨)
curator_avg_match REAL,
curator_best_tier TEXT, -- 안정 | 균형 | 공격
curator_best_match INTEGER,
curator_5plus_prizes INTEGER, -- 3개↑ 일치 카운트(5등 이상)
-- 사용자 구매 평가 (lotto_purchase vs 추첨)
user_avg_match REAL,
user_best_match INTEGER,
user_5plus_prizes INTEGER,
-- 패턴 갭 (서사 재료)
user_pattern_summary TEXT,
draw_pattern_summary TEXT,
pattern_delta TEXT, -- "너 저번호 편향 +1.2 / 합계 -18"
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### `lotto_purchase` 컬럼 추가
```sql
ALTER TABLE lotto_purchase ADD COLUMN numbers TEXT; -- JSON [3,11,17,25,33,41]
ALTER TABLE lotto_purchase ADD COLUMN match_count INTEGER;
ALTER TABLE lotto_purchase ADD COLUMN auto_graded INTEGER DEFAULT 0;
ALTER TABLE lotto_purchase ADD COLUMN curator_tier TEXT; -- core | bonus | extended | pool
ALTER TABLE lotto_purchase ADD COLUMN curator_role TEXT; -- 안정 | 균형 | 공격
```
### `briefings.picks` 구조 변경
JSON 컬럼을 4계층 구조로 마이그레이션:
```json
{
"core": [/* 5 */],
"bonus": [/* 5 */],
"extended": [/* 5 */],
"pool": [/* 5 */]
}
```
기존 단일 배열 데이터는 `core` 키에만 매핑하고 나머지 키는 빈 배열로 채우는 1회 마이그레이션 스크립트.
## 6. 큐레이터 변경
### 출력 스키마 (`agent-office/curator/schema.py`)
```python
class CuratorOutput(BaseModel):
core_picks: List[Pick] = Field(min_length=5, max_length=5)
bonus_picks: List[Pick] = Field(min_length=5, max_length=5)
extended_picks: List[Pick] = Field(min_length=5, max_length=5)
pool_picks: List[Pick] = Field(min_length=5, max_length=5)
tier_rationale: TierRationale # bonus / extended / pool 각 30자 이내
narrative: Narrative # retrospective(60자 이내) 필드 추가
confidence: int # 0~100
```
### SYSTEM_PROMPT 추가 규칙
```
회고 규칙:
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내).
- 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
- 이번 주 코어 분배는 회고에 근거해 조정. 사유는 narrative.headline 에 한 줄로.
계층별 큐레이션 규칙:
- core_picks (5): 안정 2 / 균형 2 / 공격 1. 그 주 주축.
- bonus_picks (5): 코어 분배의 공백을 메움. 코어와 상보적.
- extended_picks (5): 코어·보너스에 없는 시각(합계 극단 / 콜드 누적 / 4주 미등장).
- pool_picks (5): 이번 주 한 번도 누르지 않은 패턴(연속·동끝·5수 균등).
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 사유.
- 후보에 없는 번호 조합은 절대 사용 금지(기존 규칙 유지).
```
### 회고 컨텍스트 — `agent-office/curator/retrospective.py` (신규)
```python
def build_retrospective(target_draw_no: int) -> dict | None:
last = lotto_get_review(target_draw_no - 1)
prev3 = lotto_get_reviews(target_draw_no - 4, target_draw_no - 2)
if not last:
return None
return {
"last_draw": {
"draw_no": last["draw_no"],
"curator_avg": last["curator_avg_match"],
"curator_best_tier": last["curator_best_tier"],
"user_avg": last["user_avg_match"],
"user_5plus": last["user_5plus_prizes"],
"pattern_delta": last["pattern_delta"],
},
"trend_4w": {
"curator_avg_4w": mean(curator_avg_match for r in [last, *prev3]),
"user_avg_4w": mean(user_avg_match for r in [last, *prev3] if user_avg_match is not None),
"user_persistent_bias": _detect_bias([last, *prev3]), # 3주↑ 유지된 패턴 편향(예: "저번호 편향")
}
}
```
### 후보 풀 N=30
`collect_candidates(n=30)` — 20세트 선별 + 다양성 여유. 기존 4개 소스(simulation/heatmap/statistics/meta) 추출량을 비례 확대.
## 7. 자동 채점 잡 — `lotto/app/jobs/grade_weekly_review.py`
```
실행: 매주 일요일 03:00 KST (cron)
입력: 가장 최근 sync된 추첨 회차
처리:
1) briefings 에서 해당 회차의 4계층 picks 로드 (없으면 curator_* NULL)
2) lotto_purchase 에서 해당 회차의 사용자 구매 로드 (없으면 user_* NULL)
3) 각 세트별 일치 수 계산 → 큐레이터/사용자 집계
4) 패턴 요약(저번호·홀짝·합계 평균) → user/draw_pattern_summary
5) 패턴 갭 한 줄(가장 큰 격차 1~2개) → pattern_delta
6) weekly_review UPSERT (draw_no 유니크)
7) lotto_purchase 채점:
- 일치 3개 → prize=5000, auto_graded=1
- 일치 4개 → prize=NULL, note 에 "4등 가능성 — 동행복권 확인" 플래그
- 일치 5+ → prize=NULL, note 에 "🚨 큰 당첨 가능성 — 즉시 확인" 플래그
+ agent-office HTTP webhook(`POST /api/agent-office/notify/lotto-prize`)
호출하여 텔레그램 별도 알림 트리거
- numbers NULL 인 행은 스킵
```
## 8. 텔레그램 알림 — `agent-office/notifiers/telegram_lotto.py` (신규)
큐레이션 성공 후 `lotto_agent` 가 호출. 발송 실패는 try/except 로 흡수(briefing 저장과 분리).
4등 이상 당첨 알림은 lotto-backend 채점 잡이 `POST /api/agent-office/notify/lotto-prize` webhook 으로 트리거(agent-office 측 라우터 신규 추가).
```
🎟 1154회 · 큐레이션 떴음
"이번 주는 안정 +1, 콜드 누적 보강."
신뢰도 72 · 분배 안정 3·균형 1·공격 1
▸ 회고: 너 2.0 / 나 1.8
너 저번호 편향 → 보너스 고번호 보강
👉 결정 카드 보러가기 (https://gahusb.synology.me/lotto)
```
회고 단락은 retrospective 가 있을 때만(첫 주 생략).
## 9. 프론트 변경
### 파일 변경 맵
| 파일 | 종류 | 내용 |
|------|------|------|
| `pages/lotto/Functions.jsx` | 수정 | 분석탭 라벨 변경 |
| `pages/lotto/tabs/BriefingTab.jsx` | 수정 | DecisionCard 단일로 재구성 |
| `pages/lotto/components/decision/DecisionCard.jsx` | 신규 | 결정 카드 메인 |
| `pages/lotto/components/decision/RetrospectiveBox.jsx` | 신규 | 회고 박스 |
| `pages/lotto/components/decision/TierModeToggle.jsx` | 신규 | 4단계 칩 토글 |
| `pages/lotto/components/decision/TierSection.jsx` | 신규 | 한 계층 영역(타이틀+사유+5장) |
| `pages/lotto/components/decision/PickCard.jsx` | 신규 | 한 세트 카드(역할+번호+사유) |
| `pages/lotto/components/decision/BulkPurchaseButton.jsx` | 신규 | 원클릭 구매 |
| `pages/lotto/components/briefing/*` | 삭제·이동 | DecisionCard 하위로 흡수, CuratorUsageFooter 는 자료실 이동 |
| `pages/lotto/components/PurchasePanel.jsx` | 수정 | auto_graded 표시 + 4등 이상 플래그 |
| `pages/lotto/components/PurchaseTrendChart.jsx` | 신규 | 4주 추세 라인(너 vs 큐레이터 평균 일치) |
| `pages/lotto/hooks/useBriefing.js` | 수정 | 4계층 + retrospective 수용 |
| `pages/lotto/hooks/useReview.js` | 신규 | weekly_review 로드 |
| `pages/lotto/hooks/usePurchases.js` | 수정 | bulkPurchase 추가 |
| `api.js` | 수정 | getLatestReview, getReviewHistory, bulkPurchase 헬퍼 |
### 컴포넌트 격리 원칙
- `DecisionCard``briefing` + `review` 두 객체만 props 로 받음(내부 hook 호출 X).
- `TierSection``tier`, `picks`, `rationale` 만 받아 4번 재사용.
- `BulkPurchaseButton``draw_no`, `tier_mode`, `sets`, `amount` 4개로 작동.
## 10. 백엔드 변경
### `web-backend/lotto/`
| 파일 | 종류 | 내용 |
|------|------|------|
| `app/db/migrations/00X_weekly_review.sql` | 신규 | 테이블 생성 |
| `app/db/migrations/00X_purchase_grading.sql` | 신규 | lotto_purchase 컬럼 추가 |
| `app/db/migrations/00X_briefings_tiers.sql` | 신규 | briefings.picks 4계층 마이그레이션 |
| `app/jobs/grade_weekly_review.py` | 신규 | 채점 잡 |
| `app/curator_helpers.py` | 수정 | collect_candidates(N=30) 기본값, build_context 에 retrospective 합치기 |
| `app/routers/briefing.py` | 수정 | BriefingRequest 4계층 + narrative.retrospective 수용 |
| `app/routers/review.py` | 신규 | GET /api/lotto/review/latest, GET /api/lotto/review/history?limit=N |
| `app/routers/purchase.py` | 수정 | POST /api/lotto/purchase/bulk |
| `app/cron.py` (또는 compose 스케줄러) | 수정 | 채점 잡 일 03:00 등록 |
### `web-backend/agent-office/`
| 파일 | 종류 | 내용 |
|------|------|------|
| `app/curator/retrospective.py` | 신규 | build_retrospective |
| `app/curator/schema.py` | 수정 | 4계층 + tier_rationale + narrative.retrospective |
| `app/curator/prompt.py` | 수정 | 회고·계층 규칙 추가 |
| `app/curator/pipeline.py` | 수정 | retrospective 빌드 호출, 4계층 직렬화 |
| `app/agents/lotto.py` | 수정 | on_schedule 월 09:00, 성공 시 텔레그램 호출 |
| `app/notifiers/telegram_lotto.py` | 신규 | 알림 포맷·발송(큐레이션 완료, 4등 이상 당첨 알림 둘 다) |
| `app/routers/notify.py` | 신규 | `POST /api/agent-office/notify/lotto-prize` — lotto-backend 채점 잡이 호출 |
| `app/service_proxy.py` | 수정 | review 헬퍼 추가 |
## 11. API 추가·변경
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/lotto/review/latest` | 최신 weekly_review 1건 |
| GET | `/api/lotto/review/history?limit=N` | 최근 N건 (4주 추세 차트용) |
| POST | `/api/lotto/purchase/bulk` | 결정 카드 원클릭 — body: `{ draw_no, tier_mode, sets, amount }` |
| POST | `/api/agent-office/notify/lotto-prize` | 4등 이상 당첨 시 lotto-backend 가 트리거 — body: `{ draw_no, match_count, numbers, purchase_id }` |
기존 엔드포인트는 그대로 유지(스키마 호환).
## 12. 에러 처리 / 격리
| 단계 | 실패 | 처리 |
|------|------|------|
| 추첨결과 sync | 동행복권 API down | 기존 정책(재시도). 채점 잡은 자동 지연만. |
| 채점 — 큐레이터 picks 없음 | 첫 주, 큐레이션 실패 회차 | curator_* NULL 로 INSERT |
| 채점 — 사용자 구매 없음 | 그 주 미구매 | user_* NULL |
| 채점 — numbers NULL 행 | 마이그레이션 이전 데이터 | 스킵, auto_graded=0 유지 |
| build_retrospective — review 없음 | 첫 주 | None 반환 → 프롬프트 분기 자연 처리 |
| Claude 스키마 실패 | 4계층 미준수 등 | 기존 1회 retry, 2회 실패 시 텔레그램 에러 알림 |
| 텔레그램 발송 실패 | 봇/네트워크 | try/except, 로그만. briefing 저장은 영향 없음 |
| bulk purchase — briefing 없음 | 큐레이션 실패 회차 | 400 + 토스트 |
| bulk purchase — 중복 호출 | 더블클릭 | (draw_no, tier_mode) 유니크 → idempotent |
| 자동채점 — 4등 이상 | 큰 당첨 | prize NULL + 메모 플래그 + 텔레그램 별도 알림 |
## 13. 테스트
### 백엔드 (`lotto/`)
- `grade_weekly_review`: (a) 정상 (b) user 구매 없음 (c) numbers NULL 스킵 (d) 일치 3개 → prize 5000 (e) 일치 4개 → 메모 플래그
- 마이그레이션: 빈 DB → 더미 → 잡 실행 → 행 정확
- briefings 마이그레이션: 구 단일 picks → core 매핑, 나머지 빈 배열
- `POST /purchase/bulk`: 정상 / 잘못된 tier_mode / briefing 없음 / 중복 호출
- `GET /review/latest`: 데이터 있음 / 빈 DB → 404
### 큐레이터 (`agent-office/curator/`)
- `build_retrospective`: review 1건 / 4건 / 0건
- `validate_response`: 정상 / 계층 누락 / 후보 외 번호 / tier_rationale 누락
- `curate_weekly` (Claude API mock): retrospective 있음·없음 / 1차 실패 → 2차 성공 / 2회 실패
- `telegram_lotto.format`: retrospective 있음·없음
### 프론트
- `DecisionCard` 수동: retrospective 있음·없음 / 모드 토글 5/10/15/20 / confidence 색
- `TierModeToggle` 단위: onChange 콜백 정확
- `BulkPurchaseButton` 수동 E2E: 클릭 → POST → 토스트 → 구매탭 갱신
- 자료실 탭 수동: 첫 진입 모두 접힘
- 모바일: DecisionCard 좁은 화면에서 깨짐 없음
## 14. 운영 점검 (배포 후 1주차)
수동으로 확인:
1. 일 03:00 채점 잡 1회 실행(`weekly_review` 1행 추가)
2. 월 09:00 큐레이션 실행(`briefings` 1행, 4계층 5×4=20개)
3. 텔레그램 알림 도착(회고 단락 정확 포함/생략)
4. 결정 카드 렌더링 정상(모바일 + PC)
5. 원클릭 구매 정확 N건 INSERT
6. cron 시간(03:00 / 09:00) 운영 패턴에 맞게 조정
## 15. Out of Scope
- 4등 이상 당첨금 자동 입력(회차별 변동, 사용자 PUT 으로 갱신)
- 큐레이터 호출 재무 비용 모니터링 강화(기존 `curator_usage` 그대로)
- 분석 탭 패널 자체의 리팩토링(라벨·디폴트 접힘만 변경)
- 1만원 외 임의 분량(7세트 등) 토글(4계층 5단위로 고정)

View File

@@ -0,0 +1,822 @@
# Stock Screener Board — 설계 문서 (MVP 슬라이스 1)
- **상태**: 설계 (Draft)
- **작성일**: 2026-05-12
- **대상 프로젝트**: `web-ui` (프론트엔드) + `web-backend/stock-lab` (백엔드) + `web-backend/agent-office` (스케줄러/텔레그램)
- **저자**: 개인 웹 플랫폼 CEO + Claude (brainstorming)
---
## 1. 배경 & 목표
현재 `/stock`은 뉴스·지수·공포탐욕, `/stock/trade`는 포트폴리오·매매·AI 코치까지 다룹니다. **시장 전체에서 강세주를 발굴하는 기능은 없습니다.**
이 작업은 KRX 전체 종목을 매일 분석해 강세주 후보를 점수화·순위화하고, 평일 장 마감 후 텔레그램으로 자동 전송하는 **노드 기반 분석 보드**를 만듭니다. 노드 인터페이스를 일관되게 정의해 후속 슬라이스에서 노드 캔버스 UI·AI 뉴스 노드·백테스트로 자연스럽게 확장 가능한 구조를 둡니다.
### 비전 (장기)
n8n 같은 노드 캔버스에서 시그널 노드를 연결·점수화하고, 결과를 표·텔레그램으로 받는 개인용 스크리닝/분석 워크벤치.
### 본 슬라이스 (MVP)
| 요소 | 범위 |
|------|------|
| 데이터 | pykrx로 매일 KRX 전종목 일봉 + 외국인/기관 수급 → SQLite 캐시 |
| 분석 노드 | 점수 7개 + 위생 게이트 1개 = 총 8개 |
| 결합 | 가중합 (게이트 통과군 내 백분위 정규화 기반) |
| 출력 | Top N(기본 20) 결과 표 + 진입가/손절/익절 + 텔레그램 |
| 실행 | 평일 16:30 KST 자동 + 사용자 수동 미리보기 |
| UI | `/stock/screener` 별도 페이지, 좌(설정)-중(표)-우(히스토리) |
| 자동 잡 | `agent-office`가 트리거, 텔레그램 전송 책임 |
### 비목표 (후속 슬라이스에 명시 예약)
1. AI 뉴스 호재/악재 노드
2. 노드 캔버스 UI (react-flow)
3. 주간 자가학습 (가중치 자동 조정 제안)
4. DART 공시·재무제표 노드
5. 분봉 기반 노드 (한투 API)
6. 진짜 미너비니 VCP (베이스 카운트·피벗 포인트)
7. 멀티 프리셋 ("공격형"/"안정형")
8. 백테스트 화면
9. KRX 호가단위 적용
10. 메트릭/대시보드 (Prometheus 등)
---
## 2. 전체 아키텍처
```
[agent-office 평일 16:30 KST] [사용자: Stock 스크리너 페이지]
│ │
▼ ▼
POST /api/stock/screener/snapshot/refresh POST /api/stock/screener/run
POST /api/stock/screener/run {mode:"auto"} {mode:"preview"|"manual_save"}
│ │
└──────────► Screener.run() ◄──────────────────┘
ScreenContext.load(asof)
(KRX 마스터·일봉·수급 SQLite 캐시)
HygieneGate.filter() ← Survivors ~500-800종
[ScoreNode.compute() × 7 활성 노드]
combine + rank Top N
position_sizer (entry/stop/target)
┌─────────────┴───────────────┐
▼ ▼
screener_runs + screener_results 응답 JSON (results, telegram_payload)
(mode='auto'·'manual_save') │
agent-office가 telegram_payload 전송
(mode='auto')
```
데이터 신선도 가정: pykrx의 외국인/기관 수급은 KRX 마감 후 30-60분 뒤 갱신. **16:30 KST 트리거는 안전 마진**.
---
## 3. 백엔드 컴포넌트 구조 (stock-lab)
### 3.1 디렉토리
```
web-backend/stock-lab/app/
├─ main.py # router.include_router(screener_router) 1줄 추가
├─ db.py
├─ price_fetcher.py
├─ scraper.py
├─ ai_summarizer.py
├─ holidays.json
├─ test_*.py # 기존
├─ test_screener_*.py # 신규 (각 노드/엔진/라우터)
└─ screener/ # ← NEW
├─ __init__.py
├─ router.py # FastAPI: /api/stock/screener/*
├─ schemas.py # Pydantic 요청/응답
├─ engine.py # Screener / ScreenContext / ScreenerResult / combine()
├─ snapshot.py # pykrx 일봉·수급 갱신
├─ position_sizer.py # ATR 기반 진입/손절/익절
├─ registry.py # NODE_REGISTRY, GATE_REGISTRY
├─ telegram.py # agent-office payload 빌더 (전송 책임은 agent-office)
├─ _test_fixtures.py # 합성 ScreenContext 헬퍼
└─ nodes/
├─ __init__.py
├─ base.py # ScoreNode, GateNode 추상
├─ hygiene.py
├─ foreign_buy.py
├─ volume_surge.py
├─ momentum.py
├─ high52w.py
├─ rs_rating.py
├─ ma_alignment.py
└─ vcp_lite.py
```
### 3.2 핵심 추상
```python
# nodes/base.py
class ScoreNode(ABC):
name: ClassVar[str] # "foreign_buy"
label: ClassVar[str] # "외국인 누적 순매수"
default_params: ClassVar[dict]
param_schema: ClassVar[dict] # 프론트 폼 자동 생성용 JSON Schema
@abstractmethod
def compute(self, ctx: "ScreenContext", params: dict) -> "pd.Series":
"""index=ticker, dtype=float, range 0..100."""
class GateNode(ABC):
name: ClassVar[str]
label: ClassVar[str]
default_params: ClassVar[dict]
param_schema: ClassVar[dict]
@abstractmethod
def filter(self, ctx: "ScreenContext", params: dict) -> "pd.Index":
"""returns surviving tickers."""
# engine.py
@dataclass(frozen=True)
class ScreenContext:
prices: pd.DataFrame # long form: date·ticker·open·high·low·close·volume·value
flow: pd.DataFrame # date·ticker·foreign_net·institution_net
master: pd.DataFrame # ticker·name·market·market_cap·is_managed·listed_date·is_preferred·is_spac
kospi: pd.Series # date → close (시장 비교용)
asof: datetime.date
@classmethod
def load(cls, asof: datetime.date) -> "ScreenContext": ...
def restrict(self, tickers) -> "ScreenContext": ...
class Screener:
def __init__(self, gate: GateNode, score_nodes: list[ScoreNode], weights: dict[str, float],
node_params: dict[str, dict], gate_params: dict, top_n: int,
sizer_params: dict):
...
def run(self, ctx: ScreenContext) -> "ScreenerResult":
survivors = self.gate.filter(ctx, self.gate_params)
scoped = ctx.restrict(survivors)
active = [n for n in self.score_nodes if self.weights.get(n.name, 0) > 0]
scores = {n.name: n.compute(scoped, self.node_params.get(n.name, {})) for n in active}
total = combine(scores, self.weights)
ranked = total.sort_values(ascending=False).head(self.top_n)
rows = position_sizer.expand(ranked, scoped, self.sizer_params)
return ScreenerResult(rows=rows, scores=scores, weights=self.weights,
survivors_count=len(survivors), warnings=[...])
```
### 3.3 registry
```python
# registry.py
from .nodes import (foreign_buy, volume_surge, momentum, high52w,
rs_rating, ma_alignment, vcp_lite, hygiene)
NODE_REGISTRY: dict[str, type[ScoreNode]] = {
"foreign_buy": foreign_buy.ForeignBuy,
"volume_surge": volume_surge.VolumeSurge,
"momentum": momentum.Momentum20,
"high52w": high52w.High52WProximity,
"rs_rating": rs_rating.RsRating,
"ma_alignment": ma_alignment.MaAlignment,
"vcp_lite": vcp_lite.VcpLite,
}
GATE_REGISTRY: dict[str, type[GateNode]] = {
"hygiene": hygiene.HygieneGate,
}
```
---
## 4. 데이터 모델 (stock.db 신규 7테이블)
### 4.1 KRX 캐시 (3테이블)
```sql
CREATE TABLE IF NOT EXISTS krx_master (
ticker TEXT PRIMARY KEY,
name TEXT NOT NULL,
market TEXT NOT NULL, -- 'KOSPI'|'KOSDAQ'
market_cap INTEGER, -- 원, nullable (pykrx 누락 케이스)
is_managed INTEGER NOT NULL DEFAULT 0,
is_preferred INTEGER NOT NULL DEFAULT 0,
is_spac INTEGER NOT NULL DEFAULT 0,
listed_date TEXT, -- 'YYYY-MM-DD'
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS krx_daily_prices (
ticker TEXT NOT NULL,
date TEXT NOT NULL,
open INTEGER, high INTEGER, low INTEGER, close INTEGER,
volume INTEGER,
value INTEGER, -- 거래대금(원)
PRIMARY KEY (ticker, date)
);
CREATE INDEX IF NOT EXISTS idx_prices_date ON krx_daily_prices(date);
CREATE TABLE IF NOT EXISTS krx_flow (
ticker TEXT NOT NULL,
date TEXT NOT NULL,
foreign_net INTEGER, -- 원
institution_net INTEGER,
PRIMARY KEY (ticker, date)
);
CREATE INDEX IF NOT EXISTS idx_flow_date ON krx_flow(date);
```
**용량**: KRX 2,700종목 × 252거래일 × 5년 ≈ 340만 행. SQLite 충분 (수십 MB).
**갱신**: 마스터는 매일 전체 재기록, 일봉·수급은 당일 행 upsert.
**초기 백필 (최초 배포 시 1회)**: 백분위 정규화·52주 신고가·RS Rating(1년 수익률)·MA200 계산을 위해 **최소 1년(252거래일), 권장 2년**의 일봉·수급을 시드 데이터로 백필. `snapshot.py``backfill(start_date, end_date)` 함수를 두고 첫 배포·이전 캐시 손실 시 수동 호출. 자동 잡은 일일 증분만.
### 4.2 사용자 설정 (싱글톤 1테이블)
```sql
CREATE TABLE IF NOT EXISTS screener_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
weights_json TEXT NOT NULL, -- {"foreign_buy":1.0, ...}
node_params_json TEXT NOT NULL, -- {"foreign_buy":{"window_days":5}, ...}
gate_params_json TEXT NOT NULL, -- {"min_market_cap_won":50_000_000_000, ...}
top_n INTEGER NOT NULL DEFAULT 20,
rr_ratio REAL NOT NULL DEFAULT 2.0,
atr_window INTEGER NOT NULL DEFAULT 14,
atr_stop_mult REAL NOT NULL DEFAULT 2.0,
updated_at TEXT NOT NULL
);
```
`ensure_schema()` 시 초기 row 삽입 (디폴트 가중치 §6 참조).
### 4.3 실행 스냅샷 (2테이블)
```sql
CREATE TABLE IF NOT EXISTS screener_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asof TEXT NOT NULL,
mode TEXT NOT NULL, -- 'auto' | 'manual_save'
status TEXT NOT NULL, -- 'success' | 'failed' | 'skipped_holiday'
error TEXT,
started_at TEXT NOT NULL,
finished_at TEXT,
weights_json TEXT NOT NULL,
node_params_json TEXT NOT NULL,
gate_params_json TEXT NOT NULL,
top_n INTEGER NOT NULL,
survivors_count INTEGER,
telegram_sent INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_runs_asof ON screener_runs(asof DESC);
CREATE TABLE IF NOT EXISTS screener_results (
run_id INTEGER NOT NULL,
rank INTEGER NOT NULL,
ticker TEXT NOT NULL,
name TEXT NOT NULL,
total_score REAL NOT NULL,
scores_json TEXT NOT NULL,
close INTEGER,
market_cap INTEGER,
entry_price INTEGER,
stop_price INTEGER,
target_price INTEGER,
atr14 REAL,
PRIMARY KEY (run_id, ticker),
FOREIGN KEY (run_id) REFERENCES screener_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_results_run_rank ON screener_results(run_id, rank);
```
**`mode='preview'`는 저장하지 않습니다.** `auto`·`manual_save`만 행을 만듭니다.
보관 기간 정책 없음 (디스크 부담 미미). 후속에서 cleanup 잡 필요시 추가.
### 4.4 마이그레이션 방식
stock-lab의 기존 `db.py` 패턴(`CREATE TABLE IF NOT EXISTS`)을 그대로 따릅니다. `screener/snapshot.py`·`screener/engine.py` import 시점에 1회 `ensure_screener_schema()` 호출. 별도 alembic 도입은 본 작업 스코프 밖.
---
## 5. 노드 8개 알고리즘
모든 점수 노드는 0~100 정수로 정규화. 표준 정규화는 **게이트 통과군 내 백분위(percentile)**, 룰 기반이 더 자연스러운 노드(이평선·52주 근접도)는 룰을 사용.
### 5.1 위생 게이트 — `HygieneGate` (점수 ❌)
```text
params:
min_market_cap_won = 50_000_000_000 # 500억 이상
min_avg_value_won = 500_000_000 # 20일 평균 거래대금 5억 이상
min_listed_days = 60 # 신규 상장 60일 미만 제외
skip_managed = true
skip_preferred = true
skip_spac = true
skip_halted_days = 3 # 최근 3일 거래정지(close 또는 volume=0)
통과 조건: 위 AND market_cap NOT NULL AND close NOT NULL
출력: 통과 종목 Index (보통 500~800종)
```
### 5.2 외국인 누적 순매수 — `ForeignBuy`
```text
params: window_days = 5
raw = sum(foreign_net[-5:]) / market_cap # 시총 대비 비율
score = percentile_rank(raw, 통과군) × 100
debug: foreign_net_sum, market_cap, raw_ratio_pct
```
### 5.3 거래량 급증 — `VolumeSurge`
```text
params: baseline_days = 20, eval_days = 3
baseline = mean(volume[-23:-3])
recent = mean(volume[-3:])
raw = log1p(recent / baseline) # 극값 평탄화
score = percentile_rank(raw, 통과군) × 100
debug: baseline, recent, ratio
```
### 5.4 20일 모멘텀 — `Momentum20`
```text
params: window_days = 20
raw = close[today] / close[today - 20] - 1
score = percentile_rank(raw, 통과군) × 100
debug: return_20d_pct
```
### 5.5 52주 신고가 근접도 — `High52WProximity` (룰 기반)
```text
params: window_days = 252
high_52w = max(high[-252:])
proximity = close / high_52w # 0..1
score = clip((proximity - 0.7) / 0.3, 0, 1) × 100
# 70% 미만 = 0, 100% 도달 = 100, 선형
debug: high_52w, proximity_pct
```
### 5.6 RS Rating — `RsRating`
```text
params: weights = {3m:2, 6m:1, 9m:1, 12m:1} # IBD 표준 가중
for k in [63, 126, 189, 252] 거래일:
r_stock = close[t]/close[t-k] - 1
r_kospi = kospi[t]/kospi[t-k] - 1
excess_k = r_stock - r_kospi
raw = Σ w_k × excess_k
score = percentile_rank(raw, 통과군) × 100 # IBD RS Rating 정의
debug: excess_1y, excess_3m, raw
```
### 5.7 이평선 정배열 — `MaAlignment` (룰 기반)
```text
params: ma_periods = [50, 150, 200]
5개 조건의 만족 개수 / 5 × 100:
① close > MA50
② MA50 > MA150
③ MA150 > MA200
④ close > MA200
⑤ close ≥ min(close[-252:]) × 1.25 # Stage 2 진입
debug: 각 조건 boolean
```
### 5.8 VCP-lite (변동성 수축률) — `VcpLite`
```text
params: short_window = 40, long_window = 252 # 8주 / 52주
daily_range_pct = (high - low) / close
short_vol = mean(daily_range_pct[-40:])
long_vol = mean(daily_range_pct[-252:])
raw = 1 - (short_vol / long_vol) # 양수면 수축
score = percentile_rank(raw, 통과군) × 100
debug: short_vol, long_vol, contraction_ratio
주: 진짜 미너비니 VCP(베이스 카운트·피벗 포인트)는 후속 슬라이스
```
### 5.9 결합 (`engine.combine`)
```python
total = Σ(w[n] * scores[n]) / Σ(w[n]) # active 노드만
# 가중치 0 → 노드 실행 스킵. 모든 가중치 0이면 422 에러.
```
### 5.10 디폴트 가중치
| 노드 | w | 근거 |
|------|----|------|
| foreign_buy | 1.0 | 한국 시장 강한 시그널 |
| volume_surge | 1.0 | 표준 |
| momentum | 1.0 | 표준 |
| high52w | **1.2** | 미너비니 SEPA 핵심 |
| rs_rating | **1.2** | 미너비니 + IBD 핵심 |
| ma_alignment | 1.0 | Stage 2 확인용 |
| vcp_lite | 0.8 | 단순 버전이라 보수적 가중 |
### 5.11 포지션 사이징 — `position_sizer.py`
```text
params (settings):
atr_window = 14
atr_stop_mult = 2.0 # 2 × ATR 손절
rr_ratio = 2.0 # 익절 = 진입가 + 2R
atr14 = ATR_Wilder(high, low, close, 14) # Wilder's smoothing (RMA), Pandas .ewm(alpha=1/14)
entry = round_won(close × 1.005) # 다음날 시초 0.5% 위
stop = round_won(close - 2.0 × atr14)
target = round_won(entry + 2.0 × (entry - stop))
r_pct = (entry - stop) / entry × 100 # 손실 위험 %
# round_won(x) = int(round(x)) — 1원 단위 반올림 (Python builtin)
```
ATR은 **Wilder's smoothing** (RMA). 일반 SMA보다 트레이딩 표준. MVP는 1원 단위 라운딩. KRX 호가단위(1·5·10·50·100·500·1000원)는 후속.
### 5.12 정규화 시 주의점
- 게이트 통과군이 100종목 미만이면 백분위 의미 ↓. 응답 `warnings`에 경고.
- 데이터 부족(상장 60일 미만 등)으로 NaN 발생 시 자동 0점 처리 (게이트가 이미 걸러줄 것).
---
## 6. API 명세 (prefix `/api/stock/screener/*`)
### 6.1 엔드포인트 표
| 메서드 | 경로 | 호출 주체 | 책임 |
|--------|------|----------|------|
| GET | `/nodes` | 프론트 | 노드 메타데이터 (label, default_params, param_schema) |
| GET | `/settings` | 프론트 | 현재 설정 조회 |
| PUT | `/settings` | 프론트 | 설정 업서트 (id=1 싱글톤) |
| POST | `/run` | 프론트 · agent-office | 분석 1회 실행. mode 매트릭스로 분기 |
| POST | `/snapshot/refresh` | agent-office | KRX 캐시 강제 갱신 |
| GET | `/runs?limit=30` | 프론트 | 최근 실행 메타 리스트 |
| GET | `/runs/{id}` | 프론트 | 특정 실행 결과 전체 |
### 6.2 `/run` 시맨틱
```jsonc
// REQUEST
POST /api/stock/screener/run
{
"mode": "preview" | "manual_save" | "auto",
"asof": "2026-05-12", // 생략 시 직전 거래일
"weights": { ... }, // optional override
"node_params": { ... }, // optional override
"gate_params": { ... }, // optional override
"top_n": 20 // optional override
}
// RESPONSE
{
"asof": "2026-05-12",
"mode": "preview",
"status": "success",
"run_id": null, // manual_save·auto만
"survivors_count": 612,
"weights": { ... }, // 실제 사용된 값
"top_n": 20,
"results": [
{
"rank": 1,
"ticker": "005930",
"name": "삼성전자",
"total_score": 84.3,
"scores": {
"foreign_buy": 92, "volume_surge": 78, "momentum": 73,
"high52w": 88, "rs_rating": 95, "ma_alignment": 80, "vcp_lite": 70
},
"close": 74500,
"market_cap": 444800000000000,
"entry_price": 74872,
"stop_price": 71200,
"target_price": 82216,
"atr14": 1835.5,
"r_pct": 4.9
}
],
"telegram_payload": null, // auto · manual_save만
"warnings": []
}
```
### 6.3 mode 매트릭스
| mode | settings_override | DB 저장 | telegram_payload 반환 | telegram 실전송 |
|------|------------------|---------|----------------------|----------------|
| `preview` | 허용 (DB 미반영) | ❌ | ✅ (미리보기 표시용) | ❌ |
| `manual_save` | 허용 (DB 미반영) | ✅ | ✅ | ❌ |
| `auto` | 무시 (DB settings만) | ✅ | ✅ | ✅ (호출자=agent-office) |
`telegram_payload``status='success'`일 때 항상 빌드해 반환 (페이로드 1회 생성 비용 매우 작음). **실전송은 mode='auto' 시 호출자(agent-office) 책임**. `status='failed'`·`'skipped_holiday'`이면 `null`.
### 6.4 `asof` 처리
- 요청에 `asof` 없으면: stock-lab이 `holidays.json` 참조해 **직전 거래일**로 자동 설정
- 요청한 `asof`가 공휴일·주말이거나 캐시에 없으면: 503 + message "no snapshot for {asof}"
- `agent-office` 자동 잡이 공휴일에 호출하는 경우 stock-lab은 status='skipped_holiday'로 success 응답 (텔레그램 전송 안 함)
### 6.5 에러 응답
응답 body의 `status` 필드와 HTTP status 코드의 매핑:
| HTTP | body.status | 발생 |
|------|-------------|------|
| 200 | `success` | 정상 분석 완료 |
| 200 | `skipped_holiday` | 공휴일·주말 asof로 자동 잡이 호출됨 |
| 422 | `failed` | 가중치 합 0, 게이트 통과 0, 잘못된 asof 형식 |
| 503 | `failed` | 캐시 미존재 (snapshot 미실행) |
| 500 | `failed` | 예기치 못한 예외 (응답 body는 일반 메시지) |
---
## 7. 프론트엔드 구조 (web-ui)
### 7.1 라우팅 & 내비게이션
- `src/routes.jsx`: `/stock/screener` 등록, 라벨 "스크리너"
- `src/Router.jsx`: 라우트 추가
- Stock·StockTrade 페이지 상단에 "스크리너" 링크
- 홈(`/`) 허브 카드에 항목 추가
### 7.2 디렉토리
```
src/pages/stock/screener/
├─ Screener.jsx # 페이지 루트
├─ Screener.css
├─ components/
│ ├─ NodePanel.jsx # 점수 노드 7개 카드
│ ├─ NodeCard.jsx # param_schema 기반 자동 폼
│ ├─ GatePanel.jsx # 위생 게이트 1개
│ ├─ GlobalControls.jsx # Top N, ATR, RR, "지금 실행", "스냅샷 저장"
│ ├─ ResultTable.jsx
│ ├─ ScoreChips.jsx # 각 노드 점수 칩
│ ├─ RunHistoryList.jsx
│ └─ TelegramPreview.jsx
└─ hooks/
├─ useScreenerMeta.js
├─ useScreenerSettings.js
├─ useScreenerRun.js
└─ useScreenerHistory.js
```
### 7.3 `src/api.js` 신규 헬퍼
```js
export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes');
export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings');
export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body);
export const runScreener = (body) => apiPost('/api/stock/screener/run', body);
export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh');
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
```
### 7.4 레이아웃
```
PC (≥1024px)
┌─────────────────────────────────────────────────────────────┐
│ 헤더 — 분석 기준일 · 직전 자동 잡 시각 · "스냅샷 저장" │
├──────────────┬──────────────────────────────┬───────────────┤
│ NodePanel │ ResultTable │ RunHistoryList │
│ + GlobalControls │ TelegramPreview │ │
│ [지금 실행] │ │ │
└──────────────┴──────────────────────────────┴───────────────┘
모바일 (<768px) — 세로 적층
[헤더] → [NodePanel 접기] → [GlobalControls+실행] → [ResultTable 가로 스크롤]
→ [TelegramPreview 접기] → [RunHistoryList]
```
### 7.5 상태 관리 패턴
- `useScreenerMeta`: 마운트 시 1회, 정적
- `useScreenerSettings`: GET → 사용자 슬라이더 조작 시 로컬 dirty state. **명시적 "설정 저장" 버튼**에서만 PUT
- "지금 실행" → `runScreener({mode:'preview', ...override})`. **DB는 건드리지 않음**
- "스냅샷 저장" → 같은 override를 `mode:'manual_save'`로 재호출
- 히스토리 클릭 → `getScreenerRun(id)`로 결과 표 교체
---
## 8. 텔레그램 메시지 포맷
자동 잡과 manual_save 모두 동일. **Top 20 중 본문 1-10**까지 표시, 11-20은 페이지 링크. MarkdownV2.
```
🎯 *KRX 강세주 스크리너* — 2026-05-12 (자동)
통과 612종 / Top 20 / 본문 1-10
1. *삼성전자* `005930` ⭐ 84.3
👤외 ⚡거 🚀모 🆙고 💪RS 📈MA
진입 74,872 손절 71,200 익절 82,216 (R 4.9%)
2. *NAVER* `035420` ⭐ 81.7
👤외 ⚡거 🆙고 💪RS 📈MA
진입 215,400 손절 207,800 익절 230,600 (R 3.5%)
⋯ (3-10)
🔗 전체 결과·11~20위:
https://gahusb.synology.me/stock/screener?run_id=42
```
### 노드 아이콘 (점수 ≥70인 노드만 표시)
| 노드 | 아이콘 |
|------|--------|
| foreign_buy | 👤외 |
| volume_surge | ⚡거 |
| momentum | 🚀모 |
| high52w | 🆙고 |
| rs_rating | 💪RS |
| ma_alignment | 📈MA |
| vcp_lite | 🌀VCP |
빌더(`screener/telegram.py`)는 payload만 반환:
```jsonc
{
"chat_target": "default",
"parse_mode": "MarkdownV2",
"text": "..." // 위 메시지
}
```
agent-office가 받아서 자체 텔레그램 채널로 발신. stock-lab은 텔레그램 SDK 의존성 없음.
---
## 9. agent-office 통합
agent-office 측에 새 잡(또는 stock_agent 액션) 추가:
```text
Trigger: 평일 16:30 KST (Asia/Seoul)
Steps:
1. POST /api/stock/screener/snapshot/refresh
실패해도 다음 단계 진행 (이전 캐시로 분석)
2. POST /api/stock/screener/run { "mode": "auto" }
3. 응답에서 status 확인:
- status == 'skipped_holiday': 종료, 텔레그램 미발신
- status == 'success': telegram_payload 추출 → 발신
- status == 'failed': agent-office 자체 알림(기존 패턴)으로 운영자에게
4. 텔레그램 발신은 agent-office의 기존 채널 사용
```
**공휴일 판정은 stock-lab 책임** (`holidays.json`이 stock-lab에 있으므로). agent-office는 매 평일 16:30에 호출하고 응답 status로 분기. agent-office에 공휴일 데이터를 복제할 필요 없음.
stock-lab은 agent-office의 인증을 신뢰 (내부 Docker 네트워크). MVP에서 헤더 토큰 검증 없음. 후속에서 필요해지면 시크릿 헤더 추가.
---
## 10. 에러 처리
| 발생 지점 | 정책 |
|----------|------|
| pykrx 종목 단위 실패 | retry ×3 → 실패해도 다음 종목 계속. 전체 실패율 >20%면 snapshot 작업 자체 실패 |
| 캐시 미존재 (`asof` 데이터 없음) | 503 + message "snapshot not available for {asof}" |
| 노드 1개 compute 실패 | 해당 노드 점수 0 처리, 다른 노드 정상. 응답 `warnings`에 사유 |
| 게이트 통과 종목 0 | 422 + message "no survivors after hygiene gate" |
| 모든 가중치 0 | 422 + message "no active score nodes" |
| 텔레그램 전송 실패 | `/run` 응답 status는 success. agent-office 측 로그·재시도 |
| 예기치 못한 예외 | 500. 스택트레이스는 stock-lab stdout 로그에만. 응답은 일반 메시지 |
`/run``warnings` 필드는 치명적이지 않은 이상을 모음. 프론트는 결과 표 위에 노란 배너로 노출.
---
## 11. 테스트 전략
stock-lab의 평탄 pytest 컨벤션을 따름. `app/test_screener_*.py`로 통합.
### 11.1 단위 테스트 (노드별)
```
app/test_screener_nodes_foreign_buy.py
app/test_screener_nodes_volume_surge.py
app/test_screener_nodes_momentum.py
app/test_screener_nodes_high52w.py
app/test_screener_nodes_rs_rating.py
app/test_screener_nodes_ma_alignment.py
app/test_screener_nodes_vcp_lite.py
app/test_screener_nodes_hygiene.py
app/test_screener_position_sizer.py
```
**공통 케이스**:
1. 알려진 입력 → 알려진 출력 (회귀 방지)
2. 데이터 부족(상장 30일짜리) → 게이트 탈락 또는 NaN 안전
3. 모든 종목 동일 값 → 백분위 정규화가 50점으로 평탄화
4. 극값 1개 → 다른 종목 점수가 무너지지 않음 (특히 volume_surge의 log1p)
### 11.2 통합 테스트
```
app/test_screener_engine.py # combine, Screener.run, ScreenContext.restrict
app/test_screener_router.py # /run mode 매트릭스, /settings round-trip, /nodes, /runs
app/test_screener_telegram.py # 메시지 텍스트 생성
```
### 11.3 픽스쳐
`app/screener/_test_fixtures.py`:
- 5종목 × 60거래일 합성 DataFrame 빌더
- 시나리오: "강세주 1종", "위생 게이트 탈락 1종(시총 부족)", "데이터 부족 1종", "약세주 2종"
- `StubScreenContext`: DB 거치지 않고 메모리 DataFrame 주입
### 11.4 수동 검증 (verification-before-completion)
- 실 KRX 데이터로 1회 돌려 Top 20이 합리적인 강세주 후보인지 사용자가 눈으로 확인
- 자동 잡 1회 실행 후 텔레그램에 메시지 도착 확인
- 모바일 화면에서 결과 표 가로 스크롤 OK 확인
---
## 12. 운영
- 로그: stock-lab stdout (Docker logs)
- 알림: agent-office가 `/run` failed 응답을 받으면 텔레그램 자체 알림
- 백업: stock.db는 NAS Synology 자체 백업 정책에 의존
- 메트릭 대시보드: MVP 범위 밖 (후속 슬라이스)
---
## 13. 양쪽 동시 수정 체크리스트 (workspace CLAUDE.md 규약)
- [ ] 백엔드: `web-backend/stock-lab/app/screener/` 패키지 신규
- [ ] 백엔드: `app/main.py`에 router include
- [ ] 백엔드: stock.db에 신규 테이블 7개 `ensure_*_schema()` 함수
- [ ] 백엔드: `requirements.txt``pykrx` 추가
- [ ] 프론트: `src/api.js`에 7개 헬퍼 추가
- [ ] 프론트: `src/routes.jsx` + `src/Router.jsx``/stock/screener` 등록
- [ ] 프론트: `src/pages/stock/screener/` 디렉토리 신규
- [ ] 프론트: `web-ui/CLAUDE.md` API 테이블에 7개 엔드포인트 추가
- [ ] agent-office: 평일 16:30 KST `stock_agent screener` 잡 추가
- [ ] 배포: `scripts/deploy.bat` 또는 개별
---
## 14. 후속 슬라이스 예약
| # | 슬라이스 | 의존 |
|---|---------|------|
| 2 | AI 뉴스 호재/악재 노드 | agent-office LLM 사용량 설계 |
| 3 | 노드 캔버스 UI (react-flow) | MVP 노드 인터페이스 안정화 후 |
| 4 | 주간 자가학습 (가중치 자동 조정 제안) | screener_runs 누적 4주 이상 |
| 5 | DART 공시·재무제표 노드 | DART 수집 파이프라인 별도 spec |
| 6 | 분봉 기반 노드 | 한투 API 분봉 캐싱 |
| 7 | 진짜 미너비니 VCP | 베이스 카운트·피벗 포인트 정의 |
| 8 | 멀티 프리셋 | settings 테이블 확장 |
| 9 | 백테스트 화면 | screener_runs + krx_daily_prices join |
| 10 | KRX 호가단위 적용 | 포지션 사이저 후처리 |
---
## 부록 A — 노드 메타데이터 응답 예시 (`GET /nodes`)
```jsonc
{
"score_nodes": [
{
"name": "foreign_buy",
"label": "외국인 누적 순매수",
"default_params": { "window_days": 5 },
"param_schema": {
"type": "object",
"properties": {
"window_days": { "type": "integer", "minimum": 1, "maximum": 60, "default": 5 }
}
}
}
// … 7개
],
"gate_nodes": [
{
"name": "hygiene",
"label": "위생 게이트",
"default_params": {
"min_market_cap_won": 50000000000,
"min_avg_value_won": 500000000,
"min_listed_days": 60,
"skip_managed": true,
"skip_preferred": true,
"skip_spac": true,
"skip_halted_days": 3
},
"param_schema": { ... }
}
]
}
```
이 응답으로 프론트는 `NodeCard`를 자동 생성합니다. 새 노드 추가 시 백엔드 클래스 1개 + registry 등록 1줄만으로 UI에 자동 노출.

View File

@@ -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>

423
package-lock.json generated
View File

@@ -13,6 +13,8 @@
"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"
},
"devDependencies": {
@@ -1045,6 +1047,42 @@
"react-dom": "^18.0.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -1411,6 +1449,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1456,6 +1506,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1474,14 +1587,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.2.79",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz",
"integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1498,6 +1611,12 @@
"@types/react": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
@@ -1692,6 +1811,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1745,9 +1873,130 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1766,6 +2015,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1780,6 +2035,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-toolkit": {
"version": "1.45.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.0.tgz",
"integrity": "sha512-RArCX+Zea16+R1jg4mH223Z8p/ivbJjIkU3oC6ld2bdUfmDxiCkFYSi9zLOR2anucWJUeH4Djnzgd0im0nD3dw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -2029,6 +2294,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2241,6 +2512,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2268,6 +2549,15 @@
"node": ">=0.8.19"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2713,6 +3003,13 @@
"react": "^18.2.0"
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
@@ -2727,6 +3024,29 @@
"react-dom": "^18.0.0"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -2769,6 +3089,66 @@
"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",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -2928,6 +3308,12 @@
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2999,6 +3385,37 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@@ -18,6 +18,8 @@
"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"
},
"devDependencies": {

View File

@@ -1,58 +1,122 @@
const { execSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
// Load .env.local from project root if present (persists NAS_SSH_TARGET etc.)
const envLocalPath = path.join(__dirname, "..", ".env.local");
if (fs.existsSync(envLocalPath)) {
for (const line of fs.readFileSync(envLocalPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx < 0) continue;
const k = trimmed.slice(0, idx).trim();
const v = trimmed.slice(idx + 1).trim();
if (!(k in process.env)) process.env[k] = v;
}
}
const isWin = process.platform === "win32";
const isMac = process.platform === "darwin";
const src = "dist";
const dstWin = "Z:\\docker\\webpage\\frontend\\";
const dstMac = "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
// Windows 배포 경로 — Z: 매핑이 NAS 루트(/volume1/)인 경우 docker\webpage\frontend,
// /volume1/docker/만 매핑된 경우 webpage\frontend, /volume1/docker/webpage 매핑이면 frontend.
// NAS_FRONTEND_DEST_WIN env로 override (예: "Z:\\webpage\\frontend\\")
const dstWin = process.env.NAS_FRONTEND_DEST_WIN || "Z:\\docker\\webpage\\frontend\\";
const dstMac = process.env.NAS_FRONTEND_DEST_MAC || "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
const dst = isWin ? dstWin : dstMac;
if (!fs.existsSync(src)) {
console.error("dist not found. Run build first.");
process.exit(1);
}
if (!fs.existsSync(dst)) {
console.error("NAS path not found. Check mount: " + dst);
process.exit(1);
}
if (isWin) {
// dstWin을 PowerShell 문자열로 안전하게 escape
const dstPs = dstWin.replace(/\\/g, "\\\\");
const cmd =
'powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"Z:\\\\docker\\\\webpage\\\\frontend\\\\\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS drive not found. Check Z: mapping.\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"';
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"${dstPs}\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS 경로를 찾을 수 없음: $dst — Z: 매핑 또는 NAS_FRONTEND_DEST_WIN env 확인\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"`;
execSync(cmd, { stdio: "inherit" });
} else if (isMac) {
const sshTarget = process.env.NAS_SSH_TARGET;
const sshPath =
process.env.NAS_SSH_PATH || "/volume1/docker/webpage/frontend/";
const sshPort = process.env.NAS_SSH_PORT;
// SSH 경로: NAS_SSH_TARGET이 설정된 경우 항상 우선
if (sshTarget) {
const sshCmd = sshPort ? `ssh -p ${sshPort}` : "ssh";
// 제어문자·줄바꿈 제거 (잘못된 export/copy-paste 대비)
const cleanTarget = sshTarget.replace(/[\r\n\t]/g, "").trim();
const cleanPath = sshPath.replace(/[\r\n\t]/g, "").trim();
const cleanPort = sshPort ? sshPort.replace(/\D/g, "").trim() : "";
if (!cleanTarget) {
console.error("NAS_SSH_TARGET 값이 비어있습니다. .env.local 또는 환경변수를 확인하세요.");
printSshHint();
process.exit(1);
}
if (cleanPort && !/^\d{1,5}$/.test(cleanPort)) {
console.error(`NAS_SSH_PORT 값이 잘못됐습니다: "${sshPort}" → 숫자만 입력하세요.`);
process.exit(1);
}
// macOS Keychain은 서브프로세스(rsync)에서 SSH 키를 자동 로드하지 못함 → -i 명시
const keyFile = (process.env.NAS_SSH_KEY || path.join(os.homedir(), ".ssh", "id_rsa"))
.replace(/[\r\n]/g, "").trim();
if (!fs.existsSync(keyFile)) {
console.error(`SSH 키 파일을 찾을 수 없습니다: ${keyFile}`);
console.error("NAS_SSH_KEY 환경변수를 올바른 키 경로로 설정하거나, ~/.ssh/id_rsa 가 있는지 확인하세요.");
process.exit(1);
}
const portOpt = cleanPort ? `-p ${cleanPort}` : "";
// Synology는 rsync --server 모드를 별도 인증으로 막음 → tar | ssh 방식 사용
const sshBase = `ssh ${portOpt} -i ${keyFile} -o StrictHostKeyChecking=accept-new -o PreferredAuthentications=publickey`
.replace(/\s+/g, " ").trim();
console.log(`Deploying via tar|ssh → ${cleanTarget}:${cleanPath}`);
// 1단계: 원격 디렉토리 초기화
execSync(
`rsync -r --delete --delete-delay -e \"${sshCmd}\" ${src}/ ${sshTarget}:${sshPath}`,
`${sshBase} ${cleanTarget} "rm -rf '${cleanPath}'/* 2>/dev/null; mkdir -p '${cleanPath}'"`,
{ stdio: "inherit" }
);
// 2단계: 빌드 산출물 tar로 전송 → 원격에서 압축 해제
execSync(
`cd ${src} && tar czf - . | ${sshBase} ${cleanTarget} "cd '${cleanPath}' && tar xzf -"`,
{ stdio: "inherit" }
);
console.log("Deploy complete.");
process.exit(0);
}
// rsync on macOS + SMB/NAS can be flaky; use ditto after a safe clean.
// SMB 마운트 경로 fallback
if (!fs.existsSync(dst)) {
console.error("NAS path not found: " + dst);
printSshHint();
process.exit(1);
}
if (!dst.includes("docker/webpage/frontend")) {
console.error("Safety check failed: unexpected dst path: " + dst);
process.exit(1);
}
try {
const testPath = `${dst}.deploy-write-test`;
fs.writeFileSync(testPath, "ok");
fs.unlinkSync(testPath);
} catch (err) {
console.error("NAS write test failed (EIO / permission error).");
console.error(
"NAS write test failed. Files may be locked or permissions are read-only."
"macOS SMB → Synology 쓰기 실패는 흔한 이슈입니다. SSH 배포를 사용하세요.\n"
);
console.error(
"Try stopping services using the folder, remounting the share with write access,",
"or set NAS_SSH_TARGET to deploy over SSH instead."
);
throw err;
printSshHint();
process.exit(1);
}
const sleep = (ms) =>
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
const retry = (fn, attempts = 6) => {
@@ -96,3 +160,15 @@ if (isWin) {
const cmd = `${baseArgs.join(" ")} ${src}/ ${dst}`;
execSync(cmd, { stdio: "inherit" });
}
function printSshHint() {
console.error("──────────────────────────────────────────────────");
console.error("SSH 배포 설정 방법:");
console.error(" 프로젝트 루트에 .env.local 파일을 만들고 아래 내용을 입력하세요:");
console.error("");
console.error(" NAS_SSH_TARGET=<NAS_유저명>@gahusb.synology.me");
console.error(" NAS_SSH_PORT=<SSH_포트> # 기본 22, DSM에서 확인");
console.error("");
console.error(" 이후 npm run release:nas 를 다시 실행하면 rsync over SSH로 배포됩니다.");
console.error("──────────────────────────────────────────────────");
}

View File

@@ -1,77 +1,506 @@
:root {
--bg: #0f0d12;
--surface: rgba(26, 23, 32, 0.88);
--text: #f4efe9;
--muted: #b6b1a9;
--line: rgba(255, 255, 255, 0.12);
--accent: #f7a8a5;
--accent-strong: #fdd4b1;
--font-display: "DM Serif Display", "Noto Serif KR", serif;
--font-body: "Manrope", "Noto Sans KR", sans-serif;
}
/* ═══════════════════════════════════════════════════════════════════════
App.css — Dashboard Layout & Design System
Cyberpunk / Futuristic Dashboard UI
═══════════════════════════════════════════════════════════════════════ */
/* ── Layout: App Shell ───────────────────────────────────────────────── */
.app-shell {
min-height: 100vh;
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
position: relative;
}
/* ── Layout: Content Area ────────────────────────────────────────────── */
.app-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
position: relative;
margin-left: var(--sidebar-w);
}
/* ── Layout: Top Bar (mobile only) ──────────────────────────────────── */
.app-topbar {
display: none;
height: var(--topbar-h);
align-items: center;
padding: 0 16px;
background: rgba(7, 11, 25, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 50;
flex-shrink: 0;
}
@media (max-width: 768px) {
.app-topbar {
display: flex;
}
}
/* ── Layout: Main Content ────────────────────────────────────────────── */
.site-main {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px 80px;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 28px 32px;
background: transparent;
position: relative;
}
@media (max-width: 768px) {
.site-main {
padding: 20px 16px 60px;
padding: 16px;
padding-bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
}
}
/* ── Loading State ───────────────────────────────────────────────────── */
.suspend-loading {
display: grid;
place-items: center;
min-height: 50vh;
color: var(--text-dim);
font-size: 13px;
letter-spacing: 0.1em;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(16px);
}
/* ═══════════════════════════════════════════════════════════════════════
Animations
═══════════════════════════════════════════════════════════════════════ */
@keyframes fadeIn {
0% {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.site-main>* {
animation: fadeUp 0.6s ease both;
@keyframes glowPulse {
0%, 100% {
box-shadow: var(--glow-cyan);
}
50% {
box-shadow: var(--glow-purple);
}
}
@keyframes scanLine {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
@keyframes neonFlicker {
0%, 95%, 100% { opacity: 1; }
96% { opacity: 0.85; }
97% { opacity: 1; }
98% { opacity: 0.9; }
}
@keyframes borderGlow {
0% { border-color: var(--neon-cyan-dim); }
50% { border-color: var(--neon-purple-dim); }
100% { border-color: var(--neon-cyan-dim); }
}
.page-enter {
animation: fadeIn 0.4s var(--ease-out) both;
}
/* ═══════════════════════════════════════════════════════════════════════
Button System
═══════════════════════════════════════════════════════════════════════ */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid var(--line);
padding: 10px 18px;
border-radius: 999px;
text-decoration: none;
background: var(--surface-card);
color: var(--text);
font-size: 14px;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.06);
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.05em;
padding: 9px 20px;
border-radius: 999px;
cursor: pointer;
user-select: none;
white-space: nowrap;
text-decoration: none;
transition:
border-color 0.2s var(--ease-out),
color 0.2s var(--ease-out),
background 0.2s var(--ease-out),
box-shadow 0.2s var(--ease-out),
filter 0.2s var(--ease-out),
transform 0.15s var(--ease-spring);
position: relative;
overflow: hidden;
}
.button:hover {
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
border-color: var(--line-bright);
color: var(--neon-cyan);
box-shadow: 0 0 12px rgba(0, 212, 255, 0.15);
}
.button:active {
transform: scale(0.97);
}
/* Primary */
.button.primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #1a1414;
background: var(--grad-accent);
color: #fff;
border: none;
font-weight: 600;
box-shadow: 0 4px 20px rgba(0, 212, 255, 0.2);
}
.button.primary:hover {
box-shadow: var(--glow-cyan);
filter: brightness(1.1);
color: #fff;
}
/* Ghost */
.button.ghost {
background: transparent;
border-color: transparent;
}
.button.ghost {
background: transparent;
}
.button.ghost:hover {
background: rgba(255, 255, 255, 0.05);
border-color: var(--line);
color: var(--text-bright);
box-shadow: none;
}
/* Small */
.button.small {
padding: 6px 14px;
font-size: 12px;
}
/* Danger */
.button.danger {
border-color: rgba(239, 68, 68, 0.4);
color: rgba(248, 113, 113, 1);
background: rgba(239, 68, 68, 0.08);
}
.button.danger:hover {
border-color: rgba(239, 68, 68, 0.7);
background: rgba(239, 68, 68, 0.15);
box-shadow: 0 0 12px rgba(239, 68, 68, 0.15);
color: rgba(252, 165, 165, 1);
}
/* Disabled */
.button:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
/* ═══════════════════════════════════════════════════════════════════════
Dashboard Card / Panel System
═══════════════════════════════════════════════════════════════════════ */
.dash-card {
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 20px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: var(--shadow-card);
position: relative;
overflow: hidden;
transition:
border-color 0.25s var(--ease-out),
box-shadow 0.25s var(--ease-out);
}
/* Top accent line */
.dash-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--grad-accent);
opacity: 0.3;
pointer-events: none;
}
.dash-card:hover {
border-color: rgba(0, 212, 255, 0.15);
box-shadow:
0 8px 40px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(0, 212, 255, 0.05);
}
/* Elevated variant */
.dash-card.raised {
background: var(--surface-raised);
border-color: rgba(255, 255, 255, 0.1);
}
/* Glow variant */
.dash-card.glow {
animation: glowPulse 4s ease-in-out infinite;
}
/* ── Legacy card alias ───────────────────────────────────────────────── */
.card {
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-card);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
position: relative;
overflow: hidden;
}
.card:hover {
border-color: rgba(0, 212, 255, 0.15);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(0, 212, 255, 0.05);
}
/* ═══════════════════════════════════════════════════════════════════════
Typography Utilities
═══════════════════════════════════════════════════════════════════════ */
/* Eyebrow / Section label */
.eyebrow {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.26em;
color: var(--neon-cyan);
margin: 0 0 8px;
font-family: var(--font-display);
font-weight: 500;
}
/* Panel title */
.panel-title {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: -0.01em;
margin: 0 0 4px;
}
/* Section heading */
.section-heading {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: var(--text-bright);
letter-spacing: -0.03em;
line-height: 1.2;
}
/* ═══════════════════════════════════════════════════════════════════════
Badge / Chip System
═══════════════════════════════════════════════════════════════════════ */
.badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.badge.cyan {
background: var(--neon-cyan-muted);
color: var(--neon-cyan);
border: 1px solid rgba(0, 212, 255, 0.2);
}
.badge.purple {
background: var(--neon-purple-muted);
color: var(--neon-purple);
border: 1px solid rgba(139, 92, 246, 0.2);
}
.badge.green {
background: rgba(52, 211, 153, 0.12);
color: #34d399;
border: 1px solid rgba(52, 211, 153, 0.2);
}
.badge.red {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 12px;
border-radius: 999px;
font-size: 11px;
letter-spacing: 0.1em;
border: 1px solid var(--line);
color: var(--text-dim);
background: var(--surface-card);
transition: border-color 0.15s ease, color 0.15s ease;
}
.chip:hover {
border-color: var(--line-bright);
color: var(--neon-cyan);
}
/* ═══════════════════════════════════════════════════════════════════════
Data Display Utilities
═══════════════════════════════════════════════════════════════════════ */
/* Metric / stat number */
.metric-value {
font-family: var(--font-display);
font-size: 28px;
font-weight: 700;
color: var(--text-bright);
letter-spacing: -0.04em;
line-height: 1;
}
.metric-label {
font-size: 12px;
color: var(--text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-top: 6px;
}
/* Positive / negative indicators */
.pos {
color: #34d399;
}
.neg {
color: #f87171;
}
/* ── Separator / Divider ─────────────────────────────────────────────── */
.divider {
height: 1px;
background: var(--line);
margin: 16px 0;
}
/* ── Gradient text utility ───────────────────────────────────────────── */
.gradient-text {
background: var(--grad-accent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ═══════════════════════════════════════════════════════════════════════
Grid Utilities
═══════════════════════════════════════════════════════════════════════ */
.dash-grid {
display: grid;
gap: 16px;
}
.dash-grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.dash-grid-3 {
grid-template-columns: repeat(3, 1fr);
}
.dash-grid-4 {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 1024px) {
.dash-grid-4 { grid-template-columns: repeat(2, 1fr); }
.dash-grid-3 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
.dash-grid-2,
.dash-grid-3,
.dash-grid-4 {
grid-template-columns: 1fr;
}
}
/* ═══════════════════════════════════════════════════════════════════════
Responsive Mobile
═══════════════════════════════════════════════════════════════════════ */
@media (max-width: 768px) {
body {
overflow: auto;
}
.app-shell {
flex-direction: column;
height: auto;
min-height: 100vh;
}
.app-content {
margin-left: 0;
height: auto;
overflow: visible;
}
.site-main {
overflow: visible;
flex: none;
}
}
/* ── Accessibility: Reduced Motion ──────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -1,18 +1,27 @@
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 />
<main className="site-main">
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
<Outlet />
</React.Suspense>
</main>
<div className="app-content">
<main className="site-main">
<PageHeader />
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
<Outlet />
</React.Suspense>
</main>
</div>
{isMobile && <BottomNav />}
</div>
);
}

View File

@@ -1,25 +1,7 @@
// src/api.js
const API_BASE = import.meta.env.VITE_API_BASE || "";
const toApiUrl = (path) => {
if (!API_BASE) return path;
try {
const base = new URL(API_BASE, window.location.origin);
// Ensure base pathname ends with '/' if it's not the root or if likely intended as a directory
if (!base.pathname.endsWith('/')) {
base.pathname += '/';
}
// Remove leading slash from path to avoid double slashes when joining
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
return new URL(cleanPath, base).toString();
} catch (error) {
console.error("Invalid VITE_API_BASE configuration:", error);
return path;
}
};
// 프론트와 API가 동일 도메인(nginx 프록시)이므로 항상 상대 경로 사용.
// 절대 URL(VITE_API_BASE)은 Mixed Content를 유발하므로 사용하지 않음.
const toApiUrl = (path) => path;
export async function apiGet(path) {
const res = await fetch(toApiUrl(path), {
@@ -154,3 +136,571 @@ export function updatePortfolio(id, fields) {
export function deletePortfolio(id) {
return apiDelete(`/api/portfolio/${id}`);
}
// ── 자산 스냅샷 API ──────────────────────────────────────────────────────────
// 장 마감 시점 총 자산을 기록하고, 기간별 추이를 조회합니다.
// GET /api/portfolio/snapshot/history?days=N
// response: { history: [{ date: "2026-03-07", total_assets: 12345678 }, ...] }
export function getAssetHistory(days = 30) {
const qs = days ? `?days=${days}` : '';
return apiGet(`/api/portfolio/snapshot/history${qs}`);
}
// POST /api/portfolio/snapshot (body 없이 호출 — 서버가 현재 total_assets 계산해서 저장)
// 또는 body: { total_assets: number } 로 직접 지정 가능
export function saveAssetSnapshot(total_assets) {
return apiPost('/api/portfolio/snapshot', total_assets != null ? { total_assets } : undefined);
}
// ── 예수금 API ───────────────────────────────────────────────────────────────
export function upsertCash(broker, cash) {
return apiPut('/api/portfolio/cash', { broker, cash });
}
export function deleteCash(broker) {
return apiDelete(`/api/portfolio/cash/${encodeURIComponent(broker)}`);
}
// ── 시장 심리 지표 API ────────────────────────────────────────────────────────
// CNN Fear & Greed Index (개발: vite proxy /ext/feargreed, 프로덕션: nginx proxy 필요)
export async function getFearAndGreed() {
const res = await fetch('/ext/feargreed', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// Yahoo Finance chart API 공통 파서
async function fetchYahooPrice(extPath) {
const res = await fetch(extPath, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const meta = data?.chart?.result?.[0]?.meta;
const price = meta?.regularMarketPrice;
const prevClose = meta?.previousClose ?? meta?.chartPreviousClose;
if (price == null) throw new Error('데이터 없음');
const rounded = Math.round(price * 100) / 100;
const change = prevClose != null ? Math.round((price - prevClose) * 100) / 100 : null;
const changePercent = prevClose ? Math.round(((price - prevClose) / prevClose) * 10000) / 100 : null;
return { value: rounded, change, changePercent };
}
// VIX 지수 (Yahoo Finance 공개 API)
export function getVix() { return fetchYahooPrice('/ext/vix'); }
// 미국 10년물 국채 금리 (^TNX)
export function getTreasury10Y() { return fetchYahooPrice('/ext/treasury'); }
// WTI 원유 선물 (CL=F)
export function getWTI() { return fetchYahooPrice('/ext/wti'); }
// Brent 원유 선물 (BZ=F)
export function getBrent() { return fetchYahooPrice('/ext/brent'); }
// ── TODO API ─────────────────────────────────────────────────────────────────
export function getTodos() {
return apiGet('/api/todos');
}
export function addTodo(data) {
return apiPost('/api/todos', data);
}
export function updateTodo(id, data) {
return apiPut(`/api/todos/${id}`, data);
}
export function deleteTodo(id) {
return apiDelete(`/api/todos/${id}`);
}
export function clearTodos() {
return apiDelete('/api/todos/done');
}
// ── 실현손익 내역 API ─────────────────────────────────────────────────────────
// GET /api/portfolio/sell-history?broker=X&days=N → { records: [...] }
// POST /api/portfolio/sell-history → 저장된 레코드 반환
// DELETE /api/portfolio/sell-history/:id → { ok: true }
export function getSellHistory({ broker, days } = {}) {
const qs = new URLSearchParams();
if (broker && broker !== 'ALL') qs.set('broker', broker);
if (days) qs.set('days', String(days));
const q = qs.toString();
return apiGet(`/api/portfolio/sell-history${q ? '?' + q : ''}`);
}
export function addSellHistory(record) {
return apiPost('/api/portfolio/sell-history', record);
}
export function updateSellHistory(id, record) {
return apiPut(`/api/portfolio/sell-history/${id}`, record);
}
export function deleteSellHistory(id) {
return apiDelete(`/api/portfolio/sell-history/${id}`);
}
// ── AI 음악 생성 API ──────────────────────────────────────────────────────────
// GET /api/music/providers → { providers: [{ id, name, description, features }] }
export function getMusicProviders() {
return apiGet('/api/music/providers');
}
// POST /api/music/generate
// body: { provider, genre, moods, instruments, duration_sec, bpm, key, scale, prompt, lyrics, instrumental }
// → { task_id: string, provider: string }
export function generateMusic(payload) {
return apiPost('/api/music/generate', payload);
}
// GET /api/music/status/:task_id
// → { status, progress, message, audio_url?, error?, provider?, track? }
export function getMusicStatus(taskId) {
return apiGet(`/api/music/status/${encodeURIComponent(taskId)}`);
}
// POST /api/music/lyrics body: { prompt }
// → { id, status, text } (Suno 가사 생성)
export function generateMusicLyrics(prompt) {
return apiPost('/api/music/lyrics', { prompt });
}
// GET /api/music/library
// → { tracks: [{ id, title, genre, ..., provider, lyrics, image_url, suno_id }] }
export function getMusicLibrary() {
return apiGet('/api/music/library');
}
// POST /api/music/library body: track object
// → saved track with id
export function saveMusicTrack(data) {
return apiPost('/api/music/library', data);
}
// DELETE /api/music/library/:id
// → { ok: true }
export function deleteMusicTrack(id) {
return apiDelete(`/api/music/library/${id}`);
}
// GET /api/music/models → { models: [{ id, name, max_duration, description }] }
export function getMusicModels() {
return apiGet('/api/music/models');
}
// GET /api/music/credits → { remaining, total, ... }
export function getMusicCredits() {
return apiGet('/api/music/credits');
}
// POST /api/music/extend body: { suno_id, continue_at, prompt, style, title, model }
// → { task_id, provider }
export function extendMusicTrack(payload) {
return apiPost('/api/music/extend', payload);
}
// POST /api/music/vocal-removal body: { suno_id, title }
// → { task_id, provider }
export function removeVocals(payload) {
return apiPost('/api/music/vocal-removal', payload);
}
// ── 저장된 가사 CRUD ─────────────────────────────────────────────────────────
// GET /api/music/lyrics/library → { lyrics: [{ id, title, text, prompt, created_at, updated_at }] }
export function getSavedLyrics() {
return apiGet('/api/music/lyrics/library');
}
// POST /api/music/lyrics/library body: { title, text, prompt }
export function saveLyrics(data) {
return apiPost('/api/music/lyrics/library', data);
}
// PUT /api/music/lyrics/library/:id body: { title?, text?, prompt? }
export function updateLyrics(id, data) {
return apiPut(`/api/music/lyrics/library/${id}`, data);
}
// DELETE /api/music/lyrics/library/:id
export function deleteLyrics(id) {
return apiDelete(`/api/music/lyrics/library/${id}`);
}
// ── Phase 1: 커버 이미지 ────────────────────────────────────────────────────
// POST /api/music/cover-image body: { suno_task_id, track_id }
export function generateCoverImage(payload) {
return apiPost('/api/music/cover-image', payload);
}
// ── Phase 2 API ─────────────────────────────────────────────────────────────
// POST /api/music/wav body: { suno_task_id, suno_id, track_id }
export function convertToWav(payload) {
return apiPost('/api/music/wav', payload);
}
// POST /api/music/stem-split body: { suno_task_id, suno_id, track_id }
export function splitStems(payload) {
return apiPost('/api/music/stem-split', payload);
}
// GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
export function getTimestampedLyrics(taskId, sunoId) {
return apiGet(`/api/music/timestamped-lyrics?task_id=${encodeURIComponent(taskId)}&suno_id=${encodeURIComponent(sunoId)}`);
}
// POST /api/music/style-boost body: { content }
export function generateStyleBoost(content) {
return apiPost('/api/music/style-boost', { content });
}
// ── Phase 3 API ─────────────────────────────────────────────────────────────
// POST /api/music/upload-cover
export function uploadAndCover(payload) {
return apiPost('/api/music/upload-cover', payload);
}
// POST /api/music/upload-extend
export function uploadAndExtend(payload) {
return apiPost('/api/music/upload-extend', payload);
}
// POST /api/music/add-vocals
export function addVocals(payload) {
return apiPost('/api/music/add-vocals', payload);
}
// POST /api/music/add-instrumental
export function addInstrumental(payload) {
return apiPost('/api/music/add-instrumental', payload);
}
// POST /api/music/video
export function generateVideo(payload) {
return apiPost('/api/music/video', payload);
}
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
// GET /api/lotto/stats/performance
export function getPerformanceStats() {
return apiGet('/api/lotto/stats/performance');
}
// GET /api/lotto/report/latest
export function getLatestReport() {
return apiGet('/api/lotto/report/latest');
}
// GET /api/lotto/report/:drw_no
export function getReport(drwNo) {
return apiGet(`/api/lotto/report/${drwNo}`);
}
// GET /api/lotto/report/history?limit=N
export function getReportHistory(limit = 10) {
return apiGet(`/api/lotto/report/history?limit=${limit}`);
}
// GET /api/lotto/analysis/personal
export function getPersonalAnalysis() {
return apiGet('/api/lotto/analysis/personal');
}
// ── 종합 추론 추천 ──────────────────────────────────────────────────────────
// GET /api/lotto/recommend/combined
export function getCombinedRecommend() {
return apiGet('/api/lotto/recommend/combined');
}
// GET /api/lotto/recommend/combined/history
export function getCombinedHistory(limit = 30) {
return apiGet(`/api/lotto/recommend/combined/history?limit=${limit}`);
}
// GET /api/lotto/purchase?draw_no=N&days=N
export function getPurchases({ draw_no, days } = {}) {
const qs = new URLSearchParams();
if (draw_no) qs.set('draw_no', String(draw_no));
if (days) qs.set('days', String(days));
const q = qs.toString();
return apiGet(`/api/lotto/purchase${q ? '?' + q : ''}`);
}
// GET /api/lotto/purchase/stats
export function getPurchaseStats() {
return apiGet('/api/lotto/purchase/stats');
}
// POST /api/lotto/purchase
export function addPurchase(data) {
return apiPost('/api/lotto/purchase', data);
}
// PUT /api/lotto/purchase/:id
export function updatePurchase(id, data) {
return apiPut(`/api/lotto/purchase/${id}`, data);
}
// DELETE /api/lotto/purchase/:id
export function deletePurchase(id) {
return apiDelete(`/api/lotto/purchase/${id}`);
}
// ── 블로그 API ────────────────────────────────────────────────────────────────
// GET /api/blog/posts → { posts: [{id, title, tags, body, date, excerpt}] }
// POST /api/blog/posts → 새 글 생성
// PUT /api/blog/posts/:id → 글 수정
// DELETE /api/blog/posts/:id → 글 삭제
export function getBlogPostsApi() {
return apiGet('/api/blog/posts');
}
export function createBlogPost(data) {
return apiPost('/api/blog/posts', data);
}
export function updateBlogPost(id, data) {
return apiPut(`/api/blog/posts/${id}`, data);
}
export function deleteBlogPost(id) {
return apiDelete(`/api/blog/posts/${id}`);
}
// ── 블로그 마케팅 API ────────────────────────────────────────────────────────
export function getBlogMarketingStatus() {
return apiGet('/api/blog-marketing/status');
}
export function startResearch(keyword) {
return apiPost('/api/blog-marketing/research', { keyword });
}
export function getResearchHistory(limit = 30) {
return apiGet(`/api/blog-marketing/research/history?limit=${limit}`);
}
export function getResearchDetail(id) {
return apiGet(`/api/blog-marketing/research/${id}`);
}
export function deleteResearch(id) {
return apiDelete(`/api/blog-marketing/research/${id}`);
}
export function getBlogMarketingTask(taskId) {
return apiGet(`/api/blog-marketing/task/${encodeURIComponent(taskId)}`);
}
export function startGenerate(keywordId) {
return apiPost('/api/blog-marketing/generate', { keyword_id: keywordId });
}
export function startReview(postId) {
return apiPost(`/api/blog-marketing/review/${postId}`);
}
export function startRegenerate(postId) {
return apiPost(`/api/blog-marketing/regenerate/${postId}`);
}
export function getBlogMarketingPosts(status, limit = 50) {
const qs = new URLSearchParams();
if (status) qs.set('status', status);
if (limit) qs.set('limit', String(limit));
const q = qs.toString();
return apiGet(`/api/blog-marketing/posts${q ? '?' + q : ''}`);
}
export function getBlogMarketingPost(id) {
return apiGet(`/api/blog-marketing/posts/${id}`);
}
export function updateBlogMarketingPost(id, data) {
return apiPut(`/api/blog-marketing/posts/${id}`, data);
}
export function deleteBlogMarketingPost(id) {
return apiDelete(`/api/blog-marketing/posts/${id}`);
}
export function publishBlogMarketingPost(id, naverUrl) {
return apiPost(`/api/blog-marketing/posts/${id}/publish`, { naver_url: naverUrl || '' });
}
export function getBlogMarketingCommissions(postId) {
const qs = postId ? `?post_id=${postId}` : '';
return apiGet(`/api/blog-marketing/commissions${qs}`);
}
export function addBlogMarketingCommission(data) {
return apiPost('/api/blog-marketing/commissions', data);
}
export function updateBlogMarketingCommission(id, data) {
return apiPut(`/api/blog-marketing/commissions/${id}`, data);
}
export function deleteBlogMarketingCommission(id) {
return apiDelete(`/api/blog-marketing/commissions/${id}`);
}
export function getBlogMarketingDashboard() {
return apiGet('/api/blog-marketing/dashboard');
}
// 마케터 단계
export function startMarket(postId) {
return apiPost(`/api/blog-marketing/market/${postId}`);
}
// 브랜드커넥트 링크 CRUD
export function getBrandLinks(params = {}) {
const qs = new URLSearchParams();
if (params.post_id) qs.set('post_id', String(params.post_id));
if (params.keyword_id) qs.set('keyword_id', String(params.keyword_id));
const q = qs.toString();
return apiGet(`/api/blog-marketing/links${q ? '?' + q : ''}`);
}
export function createBrandLink(data) {
return apiPost('/api/blog-marketing/links', data);
}
export function updateBrandLink(id, data) {
return apiPut(`/api/blog-marketing/links/${id}`, data);
}
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();
}
// ── Music Lab — Video Projects ────────────────────
export const createVideoProject = (data) => apiPost('/api/music/video-project', data);
export const getVideoProjects = () => apiGet('/api/music/video-projects');
export const renderVideoProject = (id) => apiPost(`/api/music/video-project/${id}/render`);
export const exportVideoProject = (id) => apiGet(`/api/music/video-project/${id}/export`);
export const deleteVideoProject = (id) => apiDelete(`/api/music/video-project/${id}`);
// ── Music Lab — Revenue ───────────────────────────
export const getRevenueDashboard = () => apiGet('/api/music/revenue/dashboard');
export const getRevenueRecords = () => apiGet('/api/music/revenue');
export const addRevenueRecord = (data) => apiPost('/api/music/revenue', data);
export const updateRevenueRecord = (id, data) => apiPut(`/api/music/revenue/${id}`, data);
export const deleteRevenueRecord = (id) => apiDelete(`/api/music/revenue/${id}`);
// ── Music Lab — Market Trends ─────────────────────
export const getLatestTrendReport = () => apiGet('/api/music/market/report/latest');
export const getTrendReports = () => apiGet('/api/music/market/report');
export const getMarketSuggestions = () => apiGet('/api/music/market/suggest');
export const triggerYoutubeResearch = () => apiPost('/api/agent-office/youtube/research', {});
// ── Music Lab — Compile ──────────────────────────────────
export const createCompileJob = (data) => apiPost('/api/music/compile', data);
export const getCompileJobs = () => apiGet('/api/music/compiles');
export const getCompileJob = (id) => apiGet(`/api/music/compile/${id}`);
export const deleteCompileJob = (id) => apiDelete(`/api/music/compile/${id}`);
export const exportCompileJob = (id) => apiGet(`/api/music/compile/${id}/export`);
// --- Music Pipeline ---
export const listPipelines = (status='all') => apiGet(`/api/music/pipeline?status=${status}`);
export const getPipeline = (id) => apiGet(`/api/music/pipeline/${id}`);
export const createPipeline = (payload) => {
// 옛 호출 호환: createPipeline(13) → { track_id: 13 }
if (typeof payload === 'number') payload = { track_id: payload };
return apiPost('/api/music/pipeline', payload);
};
export const startPipeline = (id) => apiPost(`/api/music/pipeline/${id}/start`);
export const cancelPipeline = (id) => apiPost(`/api/music/pipeline/${id}/cancel`);
export const publishPipeline = (id) => apiPost(`/api/music/pipeline/${id}/publish`);
// --- Music Setup ---
export const getMusicSetup = () => apiGet('/api/music/setup');
export const updateMusicSetup = (payload) => apiPut('/api/music/setup', payload);
// --- YouTube OAuth ---
export const getYoutubeAuthUrl = () => apiGet('/api/music/youtube/auth-url');
export const getYoutubeStatus = () => apiGet('/api/music/youtube/status');
export const disconnectYoutube = () => apiPost('/api/music/youtube/disconnect');
// === Batch generation ===
export const startBatchGen = (payload) => apiPost('/api/music/generate-batch', payload);
export const getBatchJob = (id) => apiGet(`/api/music/generate-batch/${id}`);
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
export const listGenres = () => apiGet('/api/music/genres');
// === 주간 회고 (weekly_review) ===
// apiGet은 비-2xx 응답에서 `HTTP <status> ...` 메시지로 Error를 throw 하므로
// 404 케이스는 메시지를 파싱하여 null로 변환한다.
export const getLatestReview = () => apiGet('/api/lotto/review/latest').catch(e => {
if (e?.status === 404 || /^HTTP 404\b/.test(e?.message || '')) return null;
throw e;
});
export const getReviewHistory = (limit = 4) =>
apiGet(`/api/lotto/review/history?limit=${limit}`).then(d => d.reviews || []);
// === 큐레이터 4계층 원클릭 구매 ===
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
apiPost('/api/lotto/purchase/bulk', { draw_no, tier_mode, sets, amount });
// ---- Stock Screener ----
export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes');
export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings');
export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body);
export const runScreener = (body) => apiPost('/api/stock/screener/run', body);
export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh');
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);

View File

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,106 @@
import React from 'react';
export const getFgColor = (score) => {
if (score <= 25) return '#ef4444';
if (score <= 45) return '#f97316';
if (score <= 55) return '#eab308';
if (score <= 75) return '#84cc16';
return '#22c55e';
};
export const getFgLabel = (score) => {
if (score <= 25) return '극단적 공포';
if (score <= 45) return '공포';
if (score <= 55) return '중립';
if (score <= 75) return '탐욕';
return '극단적 탐욕';
};
const FG_LEVELS = [
{
range: '0 25',
label: '극단적 공포',
color: '#ef4444',
desc: '투자자들이 극도로 불안해하는 상태. 역사적으로 매수 기회가 되기도 하나, 하락세가 이어질 수 있습니다.',
},
{
range: '26 45',
label: '공포',
color: '#f97316',
desc: '시장 심리가 위축된 상태. 불확실성이 높고, 매도 압력이 강합니다.',
},
{
range: '46 55',
label: '중립',
color: '#eab308',
desc: '공포와 탐욕이 균형을 이루는 상태. 뚜렷한 방향성 없이 관망세가 지속됩니다.',
},
{
range: '56 75',
label: '탐욕',
color: '#84cc16',
desc: '투자자들이 낙관적이고 시장에 적극 참여하는 상태. 과열 신호를 주의해야 합니다.',
},
{
range: '76 100',
label: '극단적 탐욕',
color: '#22c55e',
desc: '시장이 과열된 상태. 조정 가능성이 높아지므로 리스크 관리가 필요합니다.',
},
];
/**
* Fear & Greed 게이지 컴포넌트
* @param {{ score: number, date?: string, showLevels?: boolean }} props
*/
const FearGreedGauge = ({ score, date, showLevels = false }) => {
const color = getFgColor(score);
const label = getFgLabel(score);
return (
<div className="fg-wrap">
<div className="fg-panel">
<div className="fg-score-display">
<span className="fg-score-number" style={{ color }}>{score}</span>
<span className="fg-score-label" style={{ color }}>{label}</span>
{date && <span className="fg-score-date">{date}</span>}
</div>
<div className="fg-gauge">
<div className="fg-gauge__track">
<div
className="fg-gauge__needle"
style={{ left: `${Math.min(100, Math.max(0, score))}%` }}
/>
</div>
<div className="fg-gauge__labels">
<span>극단적 공포</span>
<span>공포</span>
<span>중립</span>
<span>탐욕</span>
<span>극단적 탐욕</span>
</div>
</div>
</div>
{showLevels && (
<div className="fg-levels">
{FG_LEVELS.map((lv) => (
<div
key={lv.label}
className={`fg-level${getFgLabel(score) === lv.label ? ' is-current' : ''}`}
>
<div className="fg-level__head">
<span className="fg-level__dot" style={{ background: lv.color }} />
<span className="fg-level__label" style={{ color: lv.color }}>{lv.label}</span>
<span className="fg-level__range">{lv.range}</span>
</div>
<p className="fg-level__desc">{lv.desc}</p>
</div>
))}
</div>
)}
</div>
);
};
export default FearGreedGauge;

127
src/components/Icons.jsx Normal file
View File

@@ -0,0 +1,127 @@
const S = {
fill: 'none',
stroke: 'currentColor',
strokeWidth: '1.6',
strokeLinecap: 'round',
strokeLinejoin: 'round',
};
const svg = (children) => (
<svg width="18" height="18" viewBox="0 0 24 24" {...S}>
{children}
</svg>
);
export const IconHome = () =>
svg(
<>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9,22 9,12 15,12 15,22" />
</>
);
export const IconBlog = () =>
svg(
<>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</>
);
export const IconLotto = () =>
svg(
<>
<circle cx="12" cy="12" r="10" />
<circle cx="8.5" cy="9.5" r="1.4" fill="currentColor" strokeWidth="0" />
<circle cx="15.5" cy="9.5" r="1.4" fill="currentColor" strokeWidth="0" />
<circle cx="8.5" cy="14.5" r="1.4" fill="currentColor" strokeWidth="0" />
<circle cx="15.5" cy="14.5" r="1.4" fill="currentColor" strokeWidth="0" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" strokeWidth="0" />
</>
);
export const IconStock = () =>
svg(
<>
<polyline points="22,7 13.5,15.5 8.5,10.5 2,17" />
<polyline points="16,7 22,7 22,13" />
</>
);
export const IconTravel = () =>
svg(<polygon points="3,11 22,2 13,21 11,13 3,11" />);
export const IconMusic = () =>
svg(
<>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</>
);
export const IconLab = () =>
svg(
<>
<line x1="9" y1="3" x2="15" y2="3" />
<path d="M10 3v6.5L5.5 17.5A2 2 0 0 0 7.3 20h9.4a2 2 0 0 0 1.8-2.5L14 9.5V3" />
<line x1="6.5" y1="15" x2="17.5" y2="15" />
</>
);
export const IconTodo = () =>
svg(
<>
<rect x="3" y="5" width="6" height="6" rx="1" />
<polyline points="9,8 11,10 15,6" />
<rect x="3" y="13" width="6" height="6" rx="1" />
<line x1="13" y1="16" x2="21" y2="16" />
<line x1="13" y1="8" x2="21" y2="8" />
<line x1="17" y1="12" x2="21" y2="12" />
</>
);
export const IconSubscription = () =>
svg(
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<polyline points="9,15 11,17 15,13" />
<line x1="9" y1="10" x2="15" y2="10" />
</>
);
export const IconBlogMarketing = () =>
svg(
<>
<path d="M4 4h16v16H4z" />
<path d="M8 8h8" />
<path d="M8 12h5" />
<circle cx="17" cy="15" r="2.5" fill="currentColor" strokeWidth="0" />
<path d="M15.5 13l3 4" />
</>
);
export const IconPortfolio = () =>
svg(
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</>
);
export const IconBuilding = () =>
svg(
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 21V9" />
<rect x="6" y="6" width="3" height="3" />
<rect x="11" y="6" width="3" height="3" />
<rect x="16" y="6" width="2" height="3" />
<rect x="11" y="11" width="3" height="3" />
<rect x="16" y="11" width="2" height="3" />
<rect x="11" y="16" width="3" height="3" />
</>
);

View File

@@ -8,49 +8,58 @@
}
.loading-spinner__circle {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.1);
width: 28px;
height: 28px;
border: 2px solid rgba(255, 255, 255, 0.08);
border-radius: 50%;
border-top-color: var(--accent, #f7a8a5);
animation: spin 0.8s linear infinite;
animation: loading-spin 0.75s linear infinite;
}
.loading-spinner__text {
font-size: 13px;
color: var(--muted, #b6b1a9);
font-size: 12px;
color: var(--muted, #9b9490);
margin: 0;
letter-spacing: 0.04em;
}
@keyframes spin {
@keyframes loading-spin {
to {
transform: rotate(360deg);
}
}
/* ── Skeleton ─────────────────────────────────────────────────────── */
.loading-skeleton {
display: grid;
gap: 12px;
padding: 16px;
gap: 14px;
padding: 4px 0;
width: 100%;
}
.loading-skeleton__line {
height: 16px;
border-radius: 4px;
height: 14px;
border-radius: 7px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.05) 25%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.05) 75%
90deg,
rgba(255, 255, 255, 0.04) 0%,
rgba(255, 255, 255, 0.09) 40%,
rgba(255, 255, 255, 0.04) 80%
);
background-size: 200% 100%;
animation: pulse 1.5s ease-in-out infinite;
background-size: 300% 100%;
animation: loading-shimmer 1.8s ease-in-out infinite;
}
@keyframes pulse {
.loading-skeleton__line:nth-child(1) { width: 65%; }
.loading-skeleton__line:nth-child(2) { width: 85%; animation-delay: 0.1s; }
.loading-skeleton__line:nth-child(3) { width: 50%; animation-delay: 0.2s; }
.loading-skeleton__line:nth-child(4) { width: 75%; animation-delay: 0.15s; }
.loading-skeleton__line:nth-child(5) { width: 60%; animation-delay: 0.25s; }
@keyframes loading-shimmer {
0% {
background-position: 200% 0;
background-position: 100% 0;
}
100% {
background-position: -200% 0;

147
src/components/LogoLoop.css Normal file
View File

@@ -0,0 +1,147 @@
.logoloop {
position: relative;
overflow: hidden;
}
.logoloop:not(.logoloop--vertical) {
overflow-x: hidden;
}
.logoloop--vertical {
overflow-y: hidden;
height: 100%;
display: inline-block;
}
.logoloop__track {
display: flex;
flex-direction: row;
width: max-content;
will-change: transform;
user-select: none;
position: relative;
z-index: 0;
}
.logoloop__track--vertical {
flex-direction: column;
width: 100%;
height: max-content;
}
@media (prefers-reduced-motion: reduce) {
.logoloop__track {
transform: none !important;
}
}
.logoloop__list {
display: flex;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
.logoloop__list--vertical {
flex-direction: column;
}
.logoloop__item {
flex: none;
font-size: var(--logoloop-logoHeight, 36px);
line-height: 1;
}
.logoloop__list:not(.logoloop__list--vertical) .logoloop__item {
margin-right: var(--logoloop-gap, 32px);
}
.logoloop__list--vertical .logoloop__item {
margin-bottom: var(--logoloop-gap, 32px);
}
.logoloop__item--scalable {
overflow: visible;
}
.logoloop__link {
display: inline-flex;
align-items: center;
text-decoration: none;
border-radius: 4px;
transition: opacity 0.2s linear;
color: inherit;
}
.logoloop__link:hover {
opacity: 0.8;
}
.logoloop__link:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.logoloop__node {
display: inline-flex;
align-items: center;
}
.logoloop__node--scale,
.logoloop__img--scale {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.logoloop__item--scalable:hover .logoloop__node--scale,
.logoloop__item--scalable:hover .logoloop__img--scale {
transform: scale(1.18);
}
.logoloop__img {
height: var(--logoloop-logoHeight, 36px);
width: auto;
display: block;
object-fit: contain;
-webkit-user-drag: none;
pointer-events: none;
image-rendering: -webkit-optimize-contrast;
}
.logoloop__fade {
position: absolute;
pointer-events: none;
z-index: 10;
}
.logoloop__fade--left {
top: 0;
bottom: 0;
left: 0;
width: clamp(24px, 8%, 120px);
background: linear-gradient(to right, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
}
.logoloop__fade--right {
top: 0;
bottom: 0;
right: 0;
width: clamp(24px, 8%, 120px);
background: linear-gradient(to left, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
}
.logoloop__fade--top {
left: 0;
right: 0;
top: 0;
height: clamp(24px, 8%, 120px);
background: linear-gradient(to bottom, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
}
.logoloop__fade--bottom {
left: 0;
right: 0;
bottom: 0;
height: clamp(24px, 8%, 120px);
background: linear-gradient(to top, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
}

322
src/components/LogoLoop.jsx Normal file
View File

@@ -0,0 +1,322 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import './LogoLoop.css';
const ANIMATION_CONFIG = {
SMOOTH_TAU: 0.25,
MIN_COPIES: 2,
COPY_HEADROOM: 2,
};
const toCssLength = (value) =>
typeof value === 'number' ? `${value}px` : value ?? undefined;
const cx = (...parts) => parts.filter(Boolean).join(' ');
function useResizeObserver(callback, elements, deps) {
useEffect(() => {
if (!window.ResizeObserver) {
const handler = () => callback();
window.addEventListener('resize', handler);
callback();
return () => window.removeEventListener('resize', handler);
}
const observers = elements.map((ref) => {
if (!ref.current) return null;
const observer = new ResizeObserver(callback);
observer.observe(ref.current);
return observer;
});
callback();
return () => {
observers.forEach((o) => o?.disconnect());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
function useImageLoader(seqRef, onLoad, deps) {
useEffect(() => {
const images = seqRef.current?.querySelectorAll('img') ?? [];
if (images.length === 0) {
onLoad();
return;
}
let remaining = images.length;
const handleLoad = () => {
remaining -= 1;
if (remaining === 0) onLoad();
};
images.forEach((img) => {
if (img.complete) {
handleLoad();
} else {
img.addEventListener('load', handleLoad, { once: true });
img.addEventListener('error', handleLoad, { once: true });
}
});
return () => {
images.forEach((img) => {
img.removeEventListener('load', handleLoad);
img.removeEventListener('error', handleLoad);
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
function useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) {
const rafRef = useRef(null);
const lastTsRef = useRef(null);
const offsetRef = useRef(0);
const velocityRef = useRef(0);
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const prefersReduced =
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const seqSize = isVertical ? seqHeight : seqWidth;
if (seqSize > 0) {
offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize;
track.style.transform = isVertical
? `translate3d(0, ${-offsetRef.current}px, 0)`
: `translate3d(${-offsetRef.current}px, 0, 0)`;
}
if (prefersReduced) {
track.style.transform = 'translate3d(0, 0, 0)';
return () => {
lastTsRef.current = null;
};
}
const animate = (ts) => {
if (lastTsRef.current === null) lastTsRef.current = ts;
const dt = Math.max(0, ts - lastTsRef.current) / 1000;
lastTsRef.current = ts;
const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity;
const easing = 1 - Math.exp(-dt / ANIMATION_CONFIG.SMOOTH_TAU);
velocityRef.current += (target - velocityRef.current) * easing;
if (seqSize > 0) {
let next = offsetRef.current + velocityRef.current * dt;
next = ((next % seqSize) + seqSize) % seqSize;
offsetRef.current = next;
track.style.transform = isVertical
? `translate3d(0, ${-offsetRef.current}px, 0)`
: `translate3d(${-offsetRef.current}px, 0, 0)`;
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
lastTsRef.current = null;
};
}, [trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]);
}
export default function LogoLoop({
logos,
speed = 60,
direction = 'left',
width = '100%',
logoHeight = 36,
gap = 32,
pauseOnHover = true,
hoverSpeed,
fadeOut = true,
fadeOutColor,
scaleOnHover = true,
ariaLabel = 'Skill logos',
className,
style,
}) {
const containerRef = useRef(null);
const trackRef = useRef(null);
const seqRef = useRef(null);
const [seqWidth, setSeqWidth] = useState(0);
const [seqHeight, setSeqHeight] = useState(0);
const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);
const [isHovered, setIsHovered] = useState(false);
const effectiveHoverSpeed = useMemo(() => {
if (hoverSpeed !== undefined) return hoverSpeed;
if (pauseOnHover === true) return 0;
if (pauseOnHover === false) return undefined;
return 0;
}, [hoverSpeed, pauseOnHover]);
const isVertical = direction === 'up' || direction === 'down';
const targetVelocity = useMemo(() => {
const magnitude = Math.abs(speed);
const dirMul = isVertical
? direction === 'up'
? 1
: -1
: direction === 'left'
? 1
: -1;
const speedMul = speed < 0 ? -1 : 1;
return magnitude * dirMul * speedMul;
}, [speed, direction, isVertical]);
const updateDimensions = useCallback(() => {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const seqRect = seqRef.current?.getBoundingClientRect?.();
const sw = seqRect?.width ?? 0;
const sh = seqRect?.height ?? 0;
if (isVertical) {
const parentH = containerRef.current?.parentElement?.clientHeight ?? 0;
if (containerRef.current && parentH > 0) {
const h = Math.ceil(parentH);
if (containerRef.current.style.height !== `${h}px`)
containerRef.current.style.height = `${h}px`;
}
if (sh > 0) {
setSeqHeight(Math.ceil(sh));
const viewport = containerRef.current?.clientHeight ?? parentH ?? sh;
const copies = Math.ceil(viewport / sh) + ANIMATION_CONFIG.COPY_HEADROOM;
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
}
} else if (sw > 0) {
setSeqWidth(Math.ceil(sw));
const copies = Math.ceil(containerWidth / sw) + ANIMATION_CONFIG.COPY_HEADROOM;
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
}
}, [isVertical]);
useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]);
useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]);
useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical);
const cssVars = useMemo(() => ({
'--logoloop-gap': `${gap}px`,
'--logoloop-logoHeight': `${logoHeight}px`,
...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }),
}), [gap, logoHeight, fadeOutColor]);
const containerStyle = useMemo(() => ({
width: isVertical
? toCssLength(width) === '100%'
? undefined
: toCssLength(width)
: toCssLength(width) ?? '100%',
...cssVars,
...style,
}), [width, cssVars, style, isVertical]);
const handleEnter = useCallback(() => {
if (effectiveHoverSpeed !== undefined) setIsHovered(true);
}, [effectiveHoverSpeed]);
const handleLeave = useCallback(() => {
if (effectiveHoverSpeed !== undefined) setIsHovered(false);
}, [effectiveHoverSpeed]);
const renderItem = (item, key) => {
const isNode = 'node' in item;
const inner = isNode ? (
<span
className={cx('logoloop__node', scaleOnHover && 'logoloop__node--scale')}
aria-hidden={!!item.href && !item.ariaLabel}
>
{item.node}
</span>
) : (
<img
className={cx('logoloop__img', scaleOnHover && 'logoloop__img--scale')}
src={item.src}
srcSet={item.srcSet}
sizes={item.sizes}
width={item.width}
height={item.height}
alt={item.alt ?? ''}
title={item.title}
loading="lazy"
decoding="async"
draggable={false}
/>
);
const itemAriaLabel = isNode ? item.ariaLabel ?? item.title : item.alt ?? item.title;
const wrapper = item.href ? (
<a
className="logoloop__link"
href={item.href}
aria-label={itemAriaLabel || 'logo link'}
target="_blank"
rel="noreferrer noopener"
>
{inner}
</a>
) : (
inner
);
return (
<li
className={cx('logoloop__item', scaleOnHover && 'logoloop__item--scalable')}
key={key}
role="listitem"
>
{wrapper}
</li>
);
};
const lists = useMemo(
() =>
Array.from({ length: copyCount }, (_, i) => (
<ul
className={cx('logoloop__list', isVertical && 'logoloop__list--vertical')}
key={`copy-${i}`}
role="list"
aria-hidden={i > 0}
ref={i === 0 ? seqRef : undefined}
>
{logos.map((it, idx) => renderItem(it, `${i}-${idx}`))}
</ul>
)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[copyCount, logos, isVertical, scaleOnHover],
);
return (
<div
ref={containerRef}
className={cx('logoloop', isVertical && 'logoloop--vertical', className)}
style={containerStyle}
role="region"
aria-label={ariaLabel}
>
{fadeOut && (
<>
<div
aria-hidden
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--top' : 'logoloop__fade--left')}
/>
<div
aria-hidden
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--bottom' : 'logoloop__fade--right')}
/>
</>
)}
<div
className={cx('logoloop__track', isVertical && 'logoloop__track--vertical')}
ref={trackRef}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
{lists}
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -1,126 +1,339 @@
.site-nav {
position: sticky;
/* ── 사이드바 본체 ───────────────────────────────────────────────────── */
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 10;
background: rgba(16, 16, 24, 0.82);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.site-nav__inner {
max-width: 1200px;
margin: 0 auto;
padding: 18px 20px;
bottom: 0;
width: var(--sidebar-w);
z-index: 200;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-direction: column;
background: rgba(7, 12, 28, 0.92);
backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5);
border-right: 1px solid rgba(0, 212, 255, 0.08);
box-shadow: 4px 0 40px rgba(0, 0, 0, 0.5), 1px 0 0 rgba(0, 212, 255, 0.05);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
}
.site-nav__brand {
display: flex;
align-items: center;
gap: 14px;
}
/* ── 브랜드 섹션 ─────────────────────────────────────────────────────── */
.site-nav__logo-image {
width: 42px;
height: 42px;
border-radius: 14px;
object-fit: cover;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
}
.site-nav__logo {
width: 42px;
height: 42px;
border-radius: 14px;
display: grid;
place-items: center;
font-family: var(--font-display);
font-size: 20px;
color: #1b1a24;
background: linear-gradient(135deg, #fdd4b1, #f7a8a5);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
}
.site-nav__title {
margin: 0;
font-weight: 600;
letter-spacing: 0.02em;
}
.site-nav__subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--muted);
}
.site-nav__links {
.sidebar__brand {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 20px 16px;
flex-shrink: 0;
}
.site-nav__link {
.sidebar__logo {
width: 38px;
height: 38px;
border-radius: 12px;
object-fit: cover;
flex-shrink: 0;
box-shadow:
0 0 0 1px rgba(0, 212, 255, 0.2),
0 0 12px rgba(0, 212, 255, 0.15),
0 4px 12px rgba(0, 0, 0, 0.4);
}
.sidebar__brand-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sidebar__brand-name {
margin: 0;
font-family: 'Space Grotesk', 'Manrope', sans-serif;
font-weight: 700;
font-size: 15px;
color: var(--text-bright);
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__brand-sub {
margin: 0;
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--neon-cyan);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── 구분선 ──────────────────────────────────────────────────────────── */
.sidebar__divider {
height: 1px;
background: var(--line, rgba(255, 255, 255, 0.1));
margin: 8px 0;
flex-shrink: 0;
}
/* ── 네비게이션 ──────────────────────────────────────────────────────── */
.sidebar__nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
/* 스크롤바 숨김 */
scrollbar-width: none;
}
.sidebar__nav::-webkit-scrollbar {
display: none;
}
.sidebar__section-label {
margin: 0;
padding: 8px 24px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--text-muted);
}
/* ── 네비게이션 아이템 ───────────────────────────────────────────────── */
.sidebar__item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-sm, 12px);
margin: 2px 10px;
text-decoration: none;
color: var(--text-dim);
font-size: 14px;
letter-spacing: 0.02em;
color: var(--text);
padding: 8px 12px;
border-radius: 999px;
font-weight: 500;
font-family: var(--font-body, 'Manrope', sans-serif);
border: 1px solid transparent;
transition: all 0.2s ease;
position: relative;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
overflow: hidden;
}
.site-nav__link:hover {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
.sidebar__item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text, #f0ebe4);
border-color: rgba(255, 255, 255, 0.08);
}
.site-nav__link.is-active {
border-color: rgba(247, 168, 165, 0.6);
background: rgba(247, 168, 165, 0.16);
color: #ffe9e2;
/* 활성 아이템 */
.sidebar__item.is-active {
background: linear-gradient(90deg, rgba(0, 212, 255, 0.12) 0%, rgba(0, 212, 255, 0.04) 100%);
border-color: rgba(0, 212, 255, 0.2);
color: var(--text-bright);
}
@media (max-width: 800px) {
.site-nav__inner {
flex-direction: column;
align-items: flex-start;
}
/* 활성 아이템 좌측 네온 바 */
.sidebar__item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 20%;
bottom: 20%;
width: 2px;
background: var(--neon-cyan);
border-radius: 0 2px 2px 0;
box-shadow: 0 0 8px var(--neon-cyan), 0 0 16px rgba(0, 212, 255, 0.4);
}
/* ── 아이콘 ──────────────────────────────────────────────────────────── */
.sidebar__item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
color: inherit;
transition: color 0.2s ease;
}
.sidebar__item.is-active .sidebar__item-icon {
color: var(--neon-cyan);
}
/* ── 라벨 ────────────────────────────────────────────────────────────── */
.sidebar__item-label {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── 도트 인디케이터 ─────────────────────────────────────────────────── */
.sidebar__item-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--neon-cyan);
box-shadow: 0 0 6px var(--neon-cyan);
flex-shrink: 0;
opacity: 0;
transition: opacity 0.2s ease;
}
.sidebar__item.is-active .sidebar__item-dot {
opacity: 1;
}
/* ── 사이드바 푸터 ───────────────────────────────────────────────────── */
.sidebar__footer {
flex-shrink: 0;
margin-top: auto;
}
.sidebar__footer-content {
padding: 12px 16px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar__status {
display: flex;
align-items: center;
gap: 7px;
}
.sidebar__status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #34d399;
box-shadow: 0 0 6px rgba(52, 211, 153, 0.8), 0 0 12px rgba(52, 211, 153, 0.4);
flex-shrink: 0;
animation: pulse-dot 2.4s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; box-shadow: 0 0 6px rgba(52, 211, 153, 0.8), 0 0 12px rgba(52, 211, 153, 0.4); }
50% { opacity: 0.7; box-shadow: 0 0 3px rgba(52, 211, 153, 0.5), 0 0 6px rgba(52, 211, 153, 0.2); }
}
.sidebar__status-text {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
letter-spacing: 0.02em;
}
.sidebar__version {
margin: 0;
font-size: 10px;
color: var(--text-muted);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
letter-spacing: 0.05em;
opacity: 0.6;
}
/* ── 모바일 토글 버튼 ────────────────────────────────────────────────── */
.sidebar-toggle {
display: none;
position: fixed;
top: 10px;
left: 10px;
z-index: 201;
width: 40px;
height: 40px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(7, 12, 28, 0.88);
backdrop-filter: blur(12px) saturate(1.4);
-webkit-backdrop-filter: blur(12px) saturate(1.4);
cursor: pointer;
padding: 0;
align-items: center;
justify-content: center;
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
.sidebar-toggle:hover {
background: rgba(0, 212, 255, 0.1);
border-color: rgba(0, 212, 255, 0.25);
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.4), 0 0 8px rgba(0, 212, 255, 0.15);
}
.sidebar-toggle__icon {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
width: 18px;
height: 18px;
}
.sidebar-toggle__icon span {
display: block;
width: 16px;
height: 1.5px;
background: var(--text-bright, #ffffff);
border-radius: 2px;
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.28s ease,
width 0.28s ease;
transform-origin: center;
}
.sidebar-toggle__icon.is-open span:nth-child(1) {
transform: translateY(6.5px) rotate(45deg);
}
.sidebar-toggle__icon.is-open span:nth-child(2) {
opacity: 0;
width: 0;
}
.sidebar-toggle__icon.is-open span:nth-child(3) {
transform: translateY(-6.5px) rotate(-45deg);
}
/* ── 오버레이 ────────────────────────────────────────────────────────── */
.sidebar__overlay {
position: fixed;
inset: 0;
z-index: 199;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.sidebar__overlay.is-visible {
opacity: 1;
pointer-events: auto;
}
/* ── 모바일 반응형 ───────────────────────────────────────────────────── */
@media (max-width: 768px) {
.site-nav__inner {
padding: 14px 16px;
gap: 12px;
}
.site-nav__brand {
gap: 10px;
}
.site-nav__logo-image {
width: 36px;
height: 36px;
}
.site-nav__title {
font-size: 14px;
}
.site-nav__subtitle {
font-size: 11px;
}
.site-nav__links {
gap: 8px;
}
.site-nav__link {
font-size: 13px;
padding: 6px 10px;
.sidebar {
display: none;
}
}

View File

@@ -1,35 +1,58 @@
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 isMobile = useIsMobile();
// 모바일에서는 BottomNav가 대체하므로 사이드바 미렌더링
if (isMobile) return null;
return (
<header className="site-nav">
<div className="site-nav__inner">
<div className="site-nav__brand">
<img src={mainLogo} alt="Logo" className="site-nav__logo-image" />
<div>
<p className="site-nav__title">Jaeoh Archive</p>
<p className="site-nav__subtitle">Stories, notes, and snapshots</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>
<nav className="site-nav__links">
{navLinks.map((link) => (
<NavLink
key={link.id}
to={link.path}
className={({ isActive }) =>
`site-nav__link${isActive ? ' is-active' : ''}`
}
>
{link.label}
</NavLink>
))}
</nav>
</div>
</header>
<div className="sidebar__divider" />
<nav className="sidebar__nav">
<p className="sidebar__section-label">NAVIGATION</p>
{navLinks.map((link) => (
<NavLink
key={link.id}
to={link.path}
className={({ isActive }) =>
`sidebar__item${isActive ? ' is-active' : ''}`
}
style={{ '--item-accent': link.accent }}
end={link.path === '/'}
>
<span className="sidebar__item-icon">{link.icon}</span>
<span className="sidebar__item-label">{link.label}</span>
<span className="sidebar__item-dot" />
</NavLink>
))}
</nav>
<div className="sidebar__footer">
<div className="sidebar__divider" />
<div className="sidebar__footer-content">
<div className="sidebar__status">
<span className="sidebar__status-dot" />
<span className="sidebar__status-text">System Online</span>
</div>
<p className="sidebar__version">v2.0.0</p>
</div>
</div>
</aside>
);
};

View File

@@ -0,0 +1,67 @@
/* ── PageHeader ──────────────────────────────────────────────────────── */
.page-header {
padding: 0 0 20px;
margin-bottom: 4px;
}
.page-header__inner {
display: flex;
flex-direction: column;
gap: 4px;
}
.page-header__subtitle {
margin: 0;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.28em;
color: var(--page-accent, var(--neon-cyan));
font-family: var(--font-display, 'Space Grotesk', sans-serif);
display: flex;
align-items: center;
gap: 10px;
}
.page-header__subtitle::before {
content: '';
display: block;
width: 20px;
height: 1.5px;
background: var(--page-accent, var(--neon-cyan));
border-radius: 2px;
box-shadow: 0 0 6px var(--page-accent, var(--neon-cyan));
flex-shrink: 0;
}
.page-header__title {
margin: 0;
font-size: clamp(22px, 3vw, 32px);
font-weight: 800;
font-family: var(--font-display, 'Space Grotesk', sans-serif);
color: var(--text-bright, #fff);
letter-spacing: -0.03em;
line-height: 1.1;
}
.page-header__line {
height: 1px;
background: linear-gradient(
90deg,
var(--page-accent, var(--neon-cyan)) 0%,
transparent 60%
);
margin-top: 14px;
opacity: 0.3;
}
@media (max-width: 768px) {
.page-header {
padding: 0 0 16px;
}
.page-header__title {
font-size: clamp(18px, 5vw, 24px);
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { navLinks } from '../routes.jsx';
import './PageHeader.css';
const PageHeader = () => {
const { pathname } = useLocation();
// Home 페이지에서는 Hero 섹션이 있으므로 숨김
if (pathname === '/') return null;
// stock/trade 같은 하위 경로도 stock로 매칭
const current = navLinks.find((link) => {
if (link.path === '/') return false;
return pathname === link.path || pathname.startsWith(link.path + '/');
});
if (!current) return null;
return (
<header className="page-header" style={{ '--page-accent': current.accent }}>
<div className="page-header__inner">
<p className="page-header__subtitle">{current.subtitle}</p>
<h1 className="page-header__title">{current.label}</h1>
</div>
<div className="page-header__line" />
</header>
);
};
export default PageHeader;

View File

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

View File

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

View File

@@ -0,0 +1,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;
}
}

View File

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

83
src/data/heroConfig.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* 홈 히어로 카드 월별 테마 설정
* 매달 month, theme, desc, nextUpdate 를 수정해 적용하세요.
*/
export const MONTHLY_THEMES = [
{
month: 1,
theme: '새해 목표 설정',
desc: '연초를 맞아 올해 개발·기록 목표를 구체적으로 정리하고 실행 계획을 세웁니다.',
nextUpdate: '매주 일요일',
},
{
month: 2,
theme: '코드 품질 개선',
desc: '리팩토링과 테스트 커버리지 향상에 집중합니다. 작은 개선도 꾸준히 쌓아갑니다.',
nextUpdate: '매주 토요일',
},
{
month: 3,
theme: '웹 UI 고도화',
desc: '대시보드 형태의 UI를 사이버펑크 스타일로 전면 개편하고, 새 기능을 추가합니다.',
nextUpdate: '이번 주말',
},
{
month: 4,
theme: '백엔드 성능 최적화',
desc: 'API 응답 속도와 데이터베이스 쿼리를 분석하고 병목을 개선하는 달입니다.',
nextUpdate: '이번 주말',
},
{
month: 5,
theme: '인프라 자동화',
desc: 'Docker/Kubernetes 파이프라인을 정비하고 배포 자동화를 강화합니다.',
nextUpdate: '격주 일요일',
},
{
month: 6,
theme: '여름 사이드 프로젝트',
desc: '새로운 기술 스택을 탐구하며 소규모 실험 프로젝트를 진행합니다.',
nextUpdate: '매주 금요일',
},
{
month: 7,
theme: '기록과 문서화',
desc: '그동안 미뤄뒀던 개발 노트와 블로그 글 작성에 집중합니다.',
nextUpdate: '매주 화요일',
},
{
month: 8,
theme: '보안 점검',
desc: '서비스 취약점을 점검하고 인증·인가 로직을 강화합니다.',
nextUpdate: '격주 토요일',
},
{
month: 9,
theme: '모니터링 강화',
desc: '로그 수집과 알림 파이프라인을 개선해 운영 가시성을 높입니다.',
nextUpdate: '이번 주말',
},
{
month: 10,
theme: '오픈소스 기여',
desc: '사용 중인 라이브러리에 이슈를 제보하거나 PR을 올려봅니다.',
nextUpdate: '매주 목요일',
},
{
month: 11,
theme: '연말 회고 준비',
desc: '올 한 해의 개발 성과를 정리하고 내년 로드맵 초안을 그립니다.',
nextUpdate: '매주 일요일',
},
{
month: 12,
theme: '느린 기록, 깊은 회고',
desc: '빠르게 달려온 한 해를 천천히 돌아보며 가장 의미 있었던 작업을 기록합니다.',
nextUpdate: '크리스마스 주간',
},
];
export function getCurrentTheme() {
const month = new Date().getMonth() + 1;
return MONTHLY_THEMES.find((t) => t.month === month) ?? MONTHLY_THEMES[0];
}

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

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

View File

@@ -1,35 +1,251 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Manrope:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
* {
/* ── Reset ───────────────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Design Tokens ───────────────────────────────────────────────────── */
:root {
/* ── Background Surfaces ─────────────────────────────────────────── */
--bg: #070b19;
--bg-secondary: #0a0f23;
--bg-tertiary: #0d1530;
/* ── Glass Surfaces ──────────────────────────────────────────────── */
--surface: rgba(10, 18, 45, 0.8);
--surface-raised: rgba(14, 24, 58, 0.9);
--surface-card: rgba(255, 255, 255, 0.03);
/* ── Neon Cyan ───────────────────────────────────────────────────── */
--neon-cyan: #00d4ff;
--neon-cyan-dim: rgba(0, 212, 255, 0.6);
--neon-cyan-muted: rgba(0, 212, 255, 0.12);
/* ── Neon Purple ─────────────────────────────────────────────────── */
--neon-purple: #8b5cf6;
--neon-purple-dim: rgba(139, 92, 246, 0.6);
--neon-purple-muted: rgba(139, 92, 246, 0.12);
/* ── Gradients ───────────────────────────────────────────────────── */
--grad-accent: linear-gradient(135deg, #00d4ff 0%, #8b5cf6 100%);
--grad-accent-subtle: linear-gradient(135deg, rgba(0, 212, 255, 0.12) 0%, rgba(139, 92, 246, 0.12) 100%);
--grad-bg-radial: radial-gradient(ellipse 120% 80% at 20% 0%, rgba(0, 212, 255, 0.06) 0%, transparent 60%),
radial-gradient(ellipse 100% 70% at 80% 10%, rgba(139, 92, 246, 0.05) 0%, transparent 60%),
radial-gradient(ellipse 80% 60% at 50% 80%, rgba(0, 100, 180, 0.04) 0%, transparent 70%);
/* ── Text ────────────────────────────────────────────────────────── */
--text: #ccd6f6;
--text-bright: #e8f0fe;
--text-dim: #8892b0;
--text-muted: #4a5572;
/* ── Borders ─────────────────────────────────────────────────────── */
--line: rgba(255, 255, 255, 0.07);
--line-bright: rgba(0, 212, 255, 0.25);
--line-subtle: rgba(255, 255, 255, 0.04);
/* ── Glow Effects ────────────────────────────────────────────────── */
--glow-cyan: 0 0 20px rgba(0, 212, 255, 0.25), 0 0 60px rgba(0, 212, 255, 0.08);
--glow-purple: 0 0 20px rgba(139, 92, 246, 0.25), 0 0 60px rgba(139, 92, 246, 0.08);
--glow-active: 0 0 30px rgba(0, 212, 255, 0.2), 0 2px 0 rgba(0, 212, 255, 0.4);
/* ── Shadows ─────────────────────────────────────────────────────── */
--shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.4);
--shadow-md: 0 8px 32px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 24px 64px rgba(0, 0, 0, 0.65);
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.45), 0 1px 0 rgba(255, 255, 255, 0.04) inset;
/* ── Border Radii ────────────────────────────────────────────────── */
--radius-xs: 6px;
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-xl: 28px;
/* ── 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;
--font-body: 'Inter', 'Noto Sans KR', system-ui, sans-serif;
/* ── Easing ──────────────────────────────────────────────────────── */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* ── Page Accent Colors ──────────────────────────────────────────── */
--accent-home: #00d4ff;
--accent-blog: #c084fc;
--accent-lotto: #34d399;
--accent-stock: #38bdf8;
--accent-realestate: #f43f5e;
--accent-subscription: #f43f5e;
--accent-todo: #f472b6;
--accent-travel: #fb923c;
--accent-lab: #fbbf24;
/* ── Convenience alias ───────────────────────────────────────────── */
--accent: var(--neon-cyan);
/* ── Legacy / Backward-compat aliases ───────────────────────────── */
--muted: var(--text-dim);
--fg: var(--text-bright);
--surface-hover: var(--surface-raised);
--line-strong: var(--line-bright);
--accent-strong: var(--neon-purple);
--shadow-inset: 0 1px 0 rgba(255, 255, 255, 0.04) inset;
}
/* ── Base Document ───────────────────────────────────────────────────── */
html {
height: 100%;
scroll-behavior: smooth;
text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: radial-gradient(2000px 1200px at 15% 5%, rgba(247, 168, 165, 0.25), transparent 70%),
radial-gradient(1600px 1200px at 85% 0%, rgba(253, 212, 177, 0.18), transparent 70%),
radial-gradient(1500px 800px at 50% 50%, rgba(151, 201, 170, 0.1), transparent 80%),
#0f0d12;
overflow: hidden;
background-color: var(--bg);
background-image: var(--grad-bg-radial);
background-attachment: fixed;
color: var(--text);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (max-width: 768px) {
body {
background-attachment: scroll;
}
/* ── Scrollbar ───────────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 212, 255, 0.22);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 212, 255, 0.45);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.22) transparent;
}
/* ── Selection ───────────────────────────────────────────────────────── */
::selection {
background: rgba(0, 212, 255, 0.2);
color: var(--text-bright);
}
/* ── Focus ───────────────────────────────────────────────────────────── */
:focus-visible {
outline: 1.5px solid rgba(0, 212, 255, 0.8);
outline-offset: 3px;
border-radius: var(--radius-xs);
}
/* ── Typography ──────────────────────────────────────────────────────── */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-display);
font-weight: 600;
color: var(--text-bright);
line-height: 1.25;
letter-spacing: -0.02em;
}
p {
line-height: 1.75;
color: var(--text);
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
color: var(--neon-cyan);
}
/* ── Images ──────────────────────────────────────────────────────────── */
img {
max-width: 100%;
display: block;
}
/* ── Form Elements ───────────────────────────────────────────────────── */
button {
font-family: var(--font-body);
}
input,
textarea,
select {
font-family: var(--font-body);
background: var(--surface-card);
border: 1px solid var(--line);
color: var(--text);
border-radius: var(--radius-sm);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--neon-cyan-dim);
box-shadow: 0 0 0 3px var(--neon-cyan-muted);
outline: none;
}
select option {
background: var(--bg-secondary);
color: var(--text);
}
/* ── Responsive Mobile Override ──────────────────────────────────────── */
@media (max-width: 768px) {
body {
overflow: auto;
background-attachment: scroll;
padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
}
}

View 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);
}
}

View 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 />;
}

View 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"
}
}

View 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
);
}
}

View 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();
}
}

View 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);
}
}

View 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();
}
}

View 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)];
}
}

View 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 };
}
}

View 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();
}
}

View 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
};
}
}

View 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 }));
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
};
}

View 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
};
}

View File

@@ -0,0 +1,154 @@
/* ── Blog Marketing ─────────────────────────────────────────────────────── */
.bm { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
/* 헤더 */
.bm-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.bm-header h1 { font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0; }
.bm-status { display: flex; gap: 8px; margin-left: auto; }
.bm-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; background: rgba(16,185,129,.15); color: #10b981; }
.bm-badge--off { background: rgba(239,68,68,.12); color: #ef4444; }
/* 탭 바 */
.bm-tabs { display: flex; gap: 4px; border-bottom: 1px solid rgba(255,255,255,.08); margin-bottom: 20px; }
.bm-tab { padding: 8px 16px; font-size: 0.85rem; background: none; border: none; color: rgba(255,255,255,.45); cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; }
.bm-tab:hover { color: rgba(255,255,255,.7); }
.bm-tab--active { color: #10b981; border-bottom-color: #10b981; }
/* ── Dashboard 탭 ─────────────────────────────────────────────────────────── */
.bm-dash-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
.bm-dash-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
.bm-dash-card__label { font-size: 0.75rem; color: rgba(255,255,255,.4); margin-bottom: 4px; }
.bm-dash-card__value { font-size: 1.4rem; font-weight: 700; color: var(--text-primary, #e4e4e7); }
.bm-dash-card__value--green { color: #10b981; }
.bm-dash-section { margin-bottom: 24px; }
.bm-dash-section h3 { font-size: 0.9rem; font-weight: 600; color: rgba(255,255,255,.6); margin-bottom: 12px; }
.bm-top-posts { display: flex; flex-direction: column; gap: 8px; }
.bm-top-post { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; background: rgba(255,255,255,.03); border-radius: 8px; }
.bm-top-post__title { font-size: 0.85rem; color: var(--text-primary, #e4e4e7); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bm-top-post__rev { font-size: 0.85rem; font-weight: 600; color: #10b981; margin-left: 12px; white-space: nowrap; }
/* ── Research 탭 ──────────────────────────────────────────────────────────── */
.bm-research-form { display: flex; gap: 8px; margin-bottom: 20px; }
.bm-research-input { flex: 1; padding: 10px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: var(--text-primary, #e4e4e7); font-size: 0.9rem; outline: none; }
.bm-research-input:focus { border-color: #10b981; }
.bm-research-input::placeholder { color: rgba(255,255,255,.25); }
.bm-btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
.bm-btn--primary { background: #10b981; color: #fff; }
.bm-btn--primary:hover { background: #059669; }
.bm-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
.bm-btn--secondary { background: rgba(255,255,255,.08); color: rgba(255,255,255,.7); }
.bm-btn--secondary:hover { background: rgba(255,255,255,.12); }
.bm-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
.bm-btn--danger:hover { background: rgba(239,68,68,.25); }
.bm-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
.bm-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: bm-spin .6s linear infinite; display: inline-block; }
@keyframes bm-spin { to { transform: rotate(360deg); } }
/* 분석 카드 */
.bm-analyses { display: flex; flex-direction: column; gap: 12px; }
.bm-analysis-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
.bm-analysis-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.bm-analysis-card__keyword { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); }
.bm-analysis-card__date { font-size: 0.7rem; color: rgba(255,255,255,.3); }
.bm-analysis-card__scores { display: flex; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; }
.bm-score { text-align: center; }
.bm-score__label { font-size: 0.65rem; color: rgba(255,255,255,.4); display: block; margin-bottom: 2px; }
.bm-score__value { font-size: 1.1rem; font-weight: 700; }
.bm-score__value--high { color: #10b981; }
.bm-score__value--mid { color: #fbbf24; }
.bm-score__value--low { color: #ef4444; }
.bm-analysis-card__summary { font-size: 0.8rem; color: rgba(255,255,255,.5); line-height: 1.5; }
.bm-analysis-card__actions { display: flex; gap: 8px; margin-top: 12px; }
/* ── Write 탭 ─────────────────────────────────────────────────────────────── */
.bm-write-empty { text-align: center; padding: 60px 20px; color: rgba(255,255,255,.3); }
.bm-write-empty p { font-size: 0.85rem; margin-top: 8px; }
.bm-progress { margin-bottom: 20px; }
.bm-progress__bar { height: 4px; background: rgba(255,255,255,.08); border-radius: 2px; overflow: hidden; margin-bottom: 6px; }
.bm-progress__fill { height: 100%; background: #10b981; border-radius: 2px; transition: width .3s; }
.bm-progress__text { font-size: 0.75rem; color: rgba(255,255,255,.4); }
.bm-preview { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 20px; margin-bottom: 16px; }
.bm-preview__title { font-size: 1.1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin-bottom: 12px; }
.bm-preview__body { font-size: 0.85rem; color: rgba(255,255,255,.6); line-height: 1.7; max-height: 400px; overflow-y: auto; }
.bm-preview__body h1, .bm-preview__body h2, .bm-preview__body h3 { color: var(--text-primary, #e4e4e7); margin: 16px 0 8px; }
.bm-preview__body table { width: 100%; border-collapse: collapse; margin: 12px 0; }
.bm-preview__body th, .bm-preview__body td { border: 1px solid rgba(255,255,255,.1); padding: 6px 10px; font-size: 0.8rem; }
.bm-preview__body th { background: rgba(255,255,255,.06); }
.bm-preview__tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
.bm-tag { font-size: 0.7rem; padding: 2px 8px; border-radius: 4px; background: rgba(16,185,129,.12); color: #10b981; }
.bm-review-box { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
.bm-review-box h4 { font-size: 0.85rem; font-weight: 600; color: var(--text-primary, #e4e4e7); margin-bottom: 10px; }
.bm-review-scores { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.bm-review-score { text-align: center; min-width: 60px; }
.bm-review-score__label { font-size: 0.65rem; color: rgba(255,255,255,.4); display: block; }
.bm-review-score__val { font-size: 1rem; font-weight: 700; }
.bm-review-total { font-size: 0.85rem; font-weight: 700; margin-bottom: 6px; }
.bm-review-total--pass { color: #10b981; }
.bm-review-total--fail { color: #ef4444; }
.bm-review-feedback { font-size: 0.8rem; color: rgba(255,255,255,.5); line-height: 1.5; }
.bm-write-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ── Posts 탭 ─────────────────────────────────────────────────────────────── */
.bm-posts-filter { display: flex; gap: 4px; margin-bottom: 16px; }
.bm-filter-btn { padding: 4px 12px; border-radius: 6px; border: none; font-size: 0.75rem; background: rgba(255,255,255,.06); color: rgba(255,255,255,.5); cursor: pointer; transition: all .15s; }
.bm-filter-btn--active { background: rgba(16,185,129,.15); color: #10b981; }
.bm-posts-list { display: flex; flex-direction: column; gap: 10px; }
.bm-post-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 14px 16px; }
.bm-post-card__top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 6px; }
.bm-post-card__title { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #e4e4e7); flex: 1; }
.bm-post-card__status { font-size: 0.65rem; padding: 2px 8px; border-radius: 4px; font-weight: 600; white-space: nowrap; margin-left: 8px; }
.bm-post-card__status--draft { background: rgba(255,255,255,.08); color: rgba(255,255,255,.5); }
.bm-post-card__status--reviewed { background: rgba(96,165,250,.15); color: #60a5fa; }
.bm-post-card__status--published { background: rgba(16,185,129,.15); color: #10b981; }
.bm-post-card__excerpt { font-size: 0.8rem; color: rgba(255,255,255,.4); margin-bottom: 8px; line-height: 1.4; }
.bm-post-card__meta { font-size: 0.7rem; color: rgba(255,255,255,.25); display: flex; gap: 12px; }
.bm-post-card__actions { display: flex; gap: 6px; margin-top: 10px; }
/* 발행 모달 */
.bm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100; display: flex; align-items: center; justify-content: center; }
.bm-modal { background: #1e1e24; border: 1px solid rgba(255,255,255,.1); border-radius: 14px; padding: 24px; width: 90%; max-width: 440px; }
.bm-modal h3 { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin-bottom: 12px; }
.bm-modal__input { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: var(--text-primary, #e4e4e7); font-size: 0.85rem; outline: none; margin-bottom: 14px; }
.bm-modal__input:focus { border-color: #10b981; }
.bm-modal__buttons { display: flex; gap: 8px; justify-content: flex-end; }
/* ── 공통 빈 상태 ─────────────────────────────────────────────────────────── */
.bm-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,.25); font-size: 0.85rem; }
/* ── 모바일 ───────────────────────────────────────────────────────────────── */
@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: 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; }
}

View File

@@ -0,0 +1,706 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import {
getBlogMarketingStatus,
startResearch,
getResearchHistory,
getResearchDetail,
deleteResearch,
getBlogMarketingTask,
startGenerate,
startReview,
startRegenerate,
startMarket,
getBlogMarketingPosts,
getBlogMarketingPost,
deleteBlogMarketingPost,
publishBlogMarketingPost,
getBlogMarketingDashboard,
getBlogMarketingCommissions,
addBlogMarketingCommission,
deleteBlogMarketingCommission,
getBrandLinks,
createBrandLink,
deleteBrandLink,
} from '../../api';
import './BlogMarketing.css';
/* ────────────────────── 유틸 ────────────────────── */
function fmtDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function fmtMoney(n) {
if (n == null) return '-';
return n.toLocaleString('ko-KR') + '원';
}
function copyHtmlToClipboard(html) {
const blob = new Blob([html], { type: 'text/html' });
const plainBlob = new Blob([html.replace(/<[^>]*>/g, '')], { type: 'text/plain' });
navigator.clipboard.write([
new ClipboardItem({ 'text/html': blob, 'text/plain': plainBlob }),
]).then(() => alert('본문이 클립보드에 복사되었습니다! (서식 포함)'));
}
function scoreColor(v, max = 100) {
const r = v / max;
if (r >= 0.6) return 'bm-score__value--high';
if (r >= 0.3) return 'bm-score__value--mid';
return 'bm-score__value--low';
}
/* ────────────────────── 폴링 훅 ────────────────────── */
function usePollTask(onDone) {
const [taskId, setTaskId] = useState(null);
const [task, setTask] = useState(null);
const timer = useRef(null);
useEffect(() => {
if (!taskId) return;
let cancelled = false;
const poll = async () => {
try {
const t = await getBlogMarketingTask(taskId);
if (cancelled) return;
setTask(t);
if (t.status === 'succeeded' || t.status === 'failed') {
setTaskId(null);
onDone?.(t);
} else {
timer.current = setTimeout(poll, 1500);
}
} catch {
if (!cancelled) timer.current = setTimeout(poll, 3000);
}
};
poll();
return () => { cancelled = true; clearTimeout(timer.current); };
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
return { taskId, task, start: setTaskId, clear: () => { setTaskId(null); setTask(null); } };
}
/* ══════════════════════════════════════════════════════════════════════════ */
export default function BlogMarketing() {
const [tab, setTab] = useState('dashboard');
const [status, setStatus] = useState(null);
const loadStatus = useCallback(() => {
return getBlogMarketingStatus().then(setStatus).catch(() => {});
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
const tabs = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'research', label: 'Research' },
{ id: 'write', label: 'Write' },
{ id: 'posts', label: 'Posts' },
];
return (
<PullToRefresh onRefresh={loadStatus}>
<div className="bm">
<header className="bm-header">
<h1>Blog Lab</h1>
{status && (
<div className="bm-status">
<span className={`bm-badge ${status.naver_api ? '' : 'bm-badge--off'}`}>
Naver {status.naver_api ? 'ON' : 'OFF'}
</span>
<span className={`bm-badge ${status.claude_api ? '' : 'bm-badge--off'}`}>
Claude {status.claude_api ? 'ON' : 'OFF'}
</span>
</div>
)}
</header>
<nav className="bm-tabs">
{tabs.map(t => (
<button
key={t.id}
className={`bm-tab ${tab === t.id ? 'bm-tab--active' : ''}`}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</nav>
{tab === 'dashboard' && <DashboardTab />}
{tab === 'research' && <ResearchTab />}
{tab === 'write' && <WriteTab />}
{tab === 'posts' && <PostsTab />}
<FAB onClick={() => setTab('research')} label="키워드 분석" />
</div>
</PullToRefresh>
);
}
/* ══════════════════════ Dashboard 탭 ═════════════════════════════════════ */
function DashboardTab() {
const [data, setData] = useState(null);
useEffect(() => {
getBlogMarketingDashboard().then(setData).catch(() => {});
}, []);
if (!data) return <div className="bm-empty">로딩 ...</div>;
return (
<div>
<div className="bm-dash-cards">
<DashCard label="총 포스트" value={data.total_posts} />
<DashCard label="발행 완료" value={data.published_posts} />
<DashCard label="총 클릭" value={data.total_clicks.toLocaleString()} />
<DashCard label="총 수익" value={fmtMoney(data.total_revenue)} green />
</div>
{data.top_posts?.length > 0 && (
<div className="bm-dash-section">
<h3>Top 5 포스트 (수익 기준)</h3>
<div className="bm-top-posts">
{data.top_posts.map(p => (
<div key={p.id} className="bm-top-post">
<span className="bm-top-post__title">{p.title || '(제목 없음)'}</span>
<span className="bm-top-post__rev">{fmtMoney(p.total_revenue)}</span>
</div>
))}
</div>
</div>
)}
{data.monthly?.length > 0 && (
<div className="bm-dash-section">
<h3>월별 수익</h3>
<div className="bm-top-posts">
{data.monthly.map(m => (
<div key={m.month} className="bm-top-post">
<span className="bm-top-post__title">{m.month}</span>
<span style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginRight: 12 }}>
클릭 {m.clicks} / 구매 {m.purchases}
</span>
<span className="bm-top-post__rev">{fmtMoney(m.revenue)}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
function DashCard({ label, value, green }) {
return (
<div className="bm-dash-card">
<div className="bm-dash-card__label">{label}</div>
<div className={`bm-dash-card__value ${green ? 'bm-dash-card__value--green' : ''}`}>{value}</div>
</div>
);
}
/* ══════════════════════ Research 탭 ══════════════════════════════════════ */
function ResearchTab() {
const [keyword, setKeyword] = useState('');
const [analyses, setAnalyses] = useState([]);
const [expanded, setExpanded] = useState(null);
const loadHistory = useCallback(() => {
getResearchHistory(30).then(r => setAnalyses(r.analyses || [])).catch(() => {});
}, []);
useEffect(() => { loadHistory(); }, [loadHistory]);
const poll = usePollTask((t) => {
if (t.status === 'succeeded') loadHistory();
});
const handleSearch = async () => {
if (!keyword.trim() || poll.taskId) return;
try {
const { task_id } = await startResearch(keyword.trim());
poll.start(task_id);
} catch (e) {
alert(e.message);
}
};
const handleDelete = async (id) => {
if (!confirm('이 분석을 삭제할까요?')) return;
await deleteResearch(id);
setAnalyses(prev => prev.filter(a => a.id !== id));
};
const handleGenerate = async (analysisId) => {
try {
const { task_id } = await startGenerate(analysisId);
alert(`글 생성 시작! (task: ${task_id.slice(0, 8)})\nWrite 탭에서 확인하세요.`);
} catch (e) {
alert(e.message);
}
};
return (
<div>
<div className="bm-research-form">
<input
className="bm-research-input"
placeholder="분석할 키워드를 입력하세요 (예: 무선 이어폰 추천)"
value={keyword}
onChange={e => setKeyword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
disabled={!!poll.taskId}
/>
<button className="bm-btn bm-btn--primary" onClick={handleSearch} disabled={!!poll.taskId}>
{poll.taskId ? <><span className="bm-spinner" /> 분석 ...</> : '분석'}
</button>
</div>
{poll.task && poll.task.status !== 'succeeded' && poll.task.status !== 'failed' && (
<div className="bm-progress">
<div className="bm-progress__bar">
<div className="bm-progress__fill" style={{ width: `${poll.task.progress || 0}%` }} />
</div>
<div className="bm-progress__text">{poll.task.message || '처리 중...'}</div>
</div>
)}
<div className="bm-analyses">
{analyses.length === 0 && !poll.taskId && (
<div className="bm-empty">아직 분석 결과가 없습니다. 키워드를 입력해 분석을 시작하세요!</div>
)}
{analyses.map(a => (
<div key={a.id} className="bm-analysis-card">
<div className="bm-analysis-card__header">
<span className="bm-analysis-card__keyword">{a.keyword}</span>
<span className="bm-analysis-card__date">{fmtDate(a.created_at)}</span>
</div>
<div className="bm-analysis-card__scores">
<div className="bm-score">
<span className="bm-score__label">경쟁도</span>
<span className={`bm-score__value ${scoreColor(a.competition)}`}>{a.competition}</span>
</div>
<div className="bm-score">
<span className="bm-score__label">기회</span>
<span className={`bm-score__value ${scoreColor(a.opportunity)}`}>{a.opportunity}</span>
</div>
<div className="bm-score">
<span className="bm-score__label">블로그</span>
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
{(a.blog_total || 0).toLocaleString()}
</span>
</div>
<div className="bm-score">
<span className="bm-score__label">쇼핑</span>
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
{(a.shop_total || 0).toLocaleString()}
</span>
</div>
{a.avg_price != null && (
<div className="bm-score">
<span className="bm-score__label">평균가</span>
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
{fmtMoney(a.avg_price)}
</span>
</div>
)}
</div>
{expanded === a.id && a.top_products?.length > 0 && (
<div className="bm-analysis-card__summary">
<strong>상위 상품:</strong>
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
{a.top_products.map((p, i) => (
<li key={i}>{p.title} {fmtMoney(p.lprice)} ({p.mallName})</li>
))}
</ul>
</div>
)}
<div className="bm-analysis-card__actions">
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => handleGenerate(a.id)}>
생성
</button>
<button
className="bm-btn bm-btn--secondary bm-btn--sm"
onClick={() => setExpanded(expanded === a.id ? null : a.id)}
>
{expanded === a.id ? '접기' : '상세'}
</button>
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(a.id)}>
삭제
</button>
</div>
</div>
))}
</div>
</div>
);
}
/* ══════════════════════ Write 탭 ═════════════════════════════════════════ */
function WriteTab() {
const [posts, setPosts] = useState([]);
const [selected, setSelected] = useState(null);
const [post, setPost] = useState(null);
// 브랜드 링크 상태
const [links, setLinks] = useState([]);
const [showLinkForm, setShowLinkForm] = useState(false);
const [linkForm, setLinkForm] = useState({ url: '', product_name: '', description: '', placement_hint: '' });
const loadPosts = useCallback(() => {
Promise.all([
getBlogMarketingPosts('draft', 20),
getBlogMarketingPosts('marketed', 20),
]).then(([draftRes, marketedRes]) => {
const all = [...(draftRes.posts || []), ...(marketedRes.posts || [])];
all.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
setPosts(all);
if (all.length > 0 && !selected) setSelected(all[0].id);
}).catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadPosts(); }, [loadPosts]);
useEffect(() => {
if (!selected) { setPost(null); setLinks([]); return; }
getBlogMarketingPost(selected).then(setPost).catch(() => {});
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => setLinks([]));
}, [selected]);
const reviewPoll = usePollTask((t) => {
if (t.status === 'succeeded' && t.result_id) {
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
}
});
const regenPoll = usePollTask((t) => {
if (t.status === 'succeeded' && t.result_id) {
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
}
});
const marketPoll = usePollTask((t) => {
if (t.status === 'succeeded' && t.result_id) {
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
loadPosts();
}
});
const handleReview = async () => {
if (!post) return;
try {
const { task_id } = await startReview(post.id);
reviewPoll.start(task_id);
} catch (e) { alert(e.message); }
};
const handleRegenerate = async () => {
if (!post) return;
try {
const { task_id } = await startRegenerate(post.id);
regenPoll.start(task_id);
} catch (e) { alert(e.message); }
};
const handleMarket = async () => {
if (!post) return;
if (links.length === 0) {
alert('마케터 실행 전 브랜드커넥트 링크를 먼저 추가하세요.');
return;
}
try {
const { task_id } = await startMarket(post.id);
marketPoll.start(task_id);
} catch (e) { alert(e.message); }
};
const handleCopy = () => {
if (!post) return;
copyHtmlToClipboard(post.body);
};
const handleAddLink = async () => {
if (!linkForm.url.trim() || !linkForm.product_name.trim()) {
alert('URL과 상품명은 필수입니다.');
return;
}
try {
await createBrandLink({ ...linkForm, post_id: selected });
setLinkForm({ url: '', product_name: '', description: '', placement_hint: '' });
setShowLinkForm(false);
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => {});
} catch (e) { alert(e.message); }
};
const handleDeleteLink = async (linkId) => {
if (!confirm('이 링크를 삭제할까요?')) return;
await deleteBrandLink(linkId);
setLinks(prev => prev.filter(l => l.id !== linkId));
};
const activePoll = reviewPoll.task || regenPoll.task || marketPoll.task;
const isProcessing = activePoll && activePoll.status !== 'succeeded' && activePoll.status !== 'failed';
if (posts.length === 0 && !post) {
return (
<div className="bm-write-empty">
<div style={{ fontSize: '2rem', marginBottom: 8 }}>&#9997;</div>
<p>아직 작성 중인 글이 없습니다.<br />Research 탭에서 키워드를 분석하고 생성을 시작하세요.</p>
</div>
);
}
return (
<div>
{posts.length > 1 && (
<div style={{ display: 'flex', gap: 6, marginBottom: 16, flexWrap: 'wrap' }}>
{posts.map(p => (
<button
key={p.id}
className={`bm-filter-btn ${selected === p.id ? 'bm-filter-btn--active' : ''}`}
onClick={() => setSelected(p.id)}
>
{p.title?.slice(0, 20) || `${p.status === 'marketed' ? 'Marketed' : 'Draft'} #${p.id}`}
{p.status === 'marketed' && <span style={{ marginLeft: 4, fontSize: '0.7rem', color: '#f59e0b' }}>[M]</span>}
</button>
))}
</div>
)}
{isProcessing && activePoll && (
<div className="bm-progress">
<div className="bm-progress__bar">
<div className="bm-progress__fill" style={{ width: `${activePoll.progress || 0}%` }} />
</div>
<div className="bm-progress__text">{activePoll.message || '처리 중...'}</div>
</div>
)}
{post && (
<>
{/* 브랜드커넥트 링크 섹션 */}
<div className="bm-links-section" style={{ marginBottom: 16, padding: 12, background: 'rgba(255,255,255,0.04)', borderRadius: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h4 style={{ margin: 0, fontSize: '0.9rem' }}>브랜드커넥트 링크 ({links.length})</h4>
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => setShowLinkForm(!showLinkForm)}>
{showLinkForm ? '취소' : '+ 링크 추가'}
</button>
</div>
{showLinkForm && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12, padding: 12, background: 'rgba(0,0,0,0.2)', borderRadius: 6 }}>
<input
className="bm-research-input"
placeholder="제휴 링크 URL (필수)"
value={linkForm.url}
onChange={e => setLinkForm(p => ({ ...p, url: e.target.value }))}
style={{ fontSize: '0.85rem' }}
/>
<input
className="bm-research-input"
placeholder="상품명 (필수)"
value={linkForm.product_name}
onChange={e => setLinkForm(p => ({ ...p, product_name: e.target.value }))}
style={{ fontSize: '0.85rem' }}
/>
<input
className="bm-research-input"
placeholder="상품 설명 (선택)"
value={linkForm.description}
onChange={e => setLinkForm(p => ({ ...p, description: e.target.value }))}
style={{ fontSize: '0.85rem' }}
/>
<input
className="bm-research-input"
placeholder="배치 힌트 (선택, 예: 본문 중간 자연스럽게)"
value={linkForm.placement_hint}
onChange={e => setLinkForm(p => ({ ...p, placement_hint: e.target.value }))}
style={{ fontSize: '0.85rem' }}
/>
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={handleAddLink}>등록</button>
</div>
)}
{links.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{links.map(l => (
<div key={l.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 8px', background: 'rgba(255,255,255,0.03)', borderRadius: 4, fontSize: '0.8rem' }}>
<div style={{ flex: 1 }}>
<strong>{l.product_name}</strong>
{l.description && <span style={{ marginLeft: 8, color: 'rgba(255,255,255,.4)' }}>{l.description}</span>}
</div>
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDeleteLink(l.id)} style={{ fontSize: '0.7rem', padding: '2px 6px' }}>삭제</button>
</div>
))}
</div>
)}
</div>
<div className="bm-preview">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="bm-preview__title">{post.title || '(제목 없음)'}</div>
<span className={`bm-post-card__status bm-post-card__status--${post.status}`} style={{ fontSize: '0.75rem' }}>
{post.status}
</span>
</div>
<div className="bm-preview__body" dangerouslySetInnerHTML={{ __html: post.body }} />
{post.tags?.length > 0 && (
<div className="bm-preview__tags">
{post.tags.map((t, i) => <span key={i} className="bm-tag">#{t}</span>)}
</div>
)}
</div>
{post.review_detail && post.review_score != null && (
<div className="bm-review-box">
<h4>품질 리뷰 결과</h4>
<div className="bm-review-scores">
{Object.entries(post.review_detail.scores || {}).map(([k, v]) => (
<div key={k} className="bm-review-score">
<span className="bm-review-score__label">{k}</span>
<span className={`bm-review-score__val ${scoreColor(v, 10)}`}>{v}</span>
</div>
))}
</div>
<div className={`bm-review-total ${post.review_detail.pass ? 'bm-review-total--pass' : 'bm-review-total--fail'}`}>
총점: {post.review_score}/60 {post.review_detail.pass ? '(통과)' : '(미달)'}
</div>
{post.review_detail.feedback && (
<div className="bm-review-feedback">{post.review_detail.feedback}</div>
)}
</div>
)}
<div className="bm-write-actions">
{post.status === 'draft' && (
<button className="bm-btn bm-btn--primary" onClick={handleMarket} disabled={isProcessing} title={links.length === 0 ? '브랜드 링크를 먼저 추가하세요' : ''}>
{marketPoll.taskId ? <><span className="bm-spinner" /> 마케팅 ...</> : '마케터 실행'}
</button>
)}
<button className="bm-btn bm-btn--primary" onClick={handleReview} disabled={isProcessing}>
{reviewPoll.taskId ? <><span className="bm-spinner" /> 리뷰 ...</> : '품질 리뷰'}
</button>
<button className="bm-btn bm-btn--secondary" onClick={handleRegenerate} disabled={isProcessing}>
{regenPoll.taskId ? <><span className="bm-spinner" /> 재생성 ...</> : '재생성'}
</button>
<button className="bm-btn bm-btn--secondary" onClick={handleCopy}>
본문 복사
</button>
</div>
</>
)}
</div>
);
}
/* ══════════════════════ Posts 탭 ═════════════════════════════════════════ */
function PostsTab() {
const [filter, setFilter] = useState('');
const [posts, setPosts] = useState([]);
const [publishModal, setPublishModal] = useState(null);
const [naverUrl, setNaverUrl] = useState('');
const load = useCallback(() => {
getBlogMarketingPosts(filter || undefined).then(r => setPosts(r.posts || [])).catch(() => {});
}, [filter]);
useEffect(() => { load(); }, [load]);
const handleDelete = async (id) => {
if (!confirm('이 포스트를 삭제할까요?')) return;
await deleteBlogMarketingPost(id);
setPosts(prev => prev.filter(p => p.id !== id));
};
const handlePublish = async () => {
if (!publishModal) return;
await publishBlogMarketingPost(publishModal, naverUrl);
setPublishModal(null);
setNaverUrl('');
load();
};
const handleCopy = (body) => {
copyHtmlToClipboard(body);
};
const filters = [
{ id: '', label: '전체' },
{ id: 'draft', label: 'Draft' },
{ id: 'marketed', label: 'Marketed' },
{ id: 'reviewed', label: 'Reviewed' },
{ id: 'published', label: 'Published' },
];
return (
<div>
<div className="bm-posts-filter">
{filters.map(f => (
<button
key={f.id}
className={`bm-filter-btn ${filter === f.id ? 'bm-filter-btn--active' : ''}`}
onClick={() => setFilter(f.id)}
>
{f.label}
</button>
))}
</div>
<div className="bm-posts-list">
{posts.length === 0 && <div className="bm-empty">포스트가 없습니다.</div>}
{posts.map(p => (
<div key={p.id} className="bm-post-card">
<div className="bm-post-card__top">
<span className="bm-post-card__title">{p.title || '(제목 없음)'}</span>
<span className={`bm-post-card__status bm-post-card__status--${p.status}`}>
{p.status}
</span>
</div>
{p.excerpt && <div className="bm-post-card__excerpt">{p.excerpt}</div>}
<div className="bm-post-card__meta">
{p.review_score != null && <span>리뷰: {p.review_score}/60</span>}
{p.naver_url && <a href={p.naver_url} target="_blank" rel="noreferrer" style={{ color: '#10b981' }}>네이버 링크</a>}
<span>{fmtDate(p.created_at)}</span>
</div>
<div className="bm-post-card__actions">
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => handleCopy(p.body)}>복사</button>
{p.status !== 'published' && (
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => { setPublishModal(p.id); setNaverUrl(''); }}>
발행
</button>
)}
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(p.id)}>삭제</button>
</div>
</div>
))}
</div>
{publishModal && (
<div className="bm-modal-overlay" onClick={() => setPublishModal(null)}>
<div className="bm-modal" onClick={e => e.stopPropagation()}>
<h3>네이버 블로그 발행</h3>
<p style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginBottom: 12 }}>
본문을 네이버 블로그에 붙여넣기한 , 발행된 URL을 입력하세요.
</p>
<input
className="bm-modal__input"
placeholder="https://blog.naver.com/..."
value={naverUrl}
onChange={e => setNaverUrl(e.target.value)}
/>
<div className="bm-modal__buttons">
<button className="bm-btn bm-btn--secondary" onClick={() => setPublishModal(null)}>취소</button>
<button className="bm-btn bm-btn--primary" onClick={handlePublish}>발행 완료</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -10,11 +10,35 @@
align-items: center;
}
.blog-header__actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.blog-new-btn {
align-self: flex-start;
border: 1px solid rgba(192, 132, 252, 0.45);
background: rgba(192, 132, 252, 0.1);
color: var(--accent-blog);
border-radius: 999px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
}
.blog-new-btn:hover {
background: rgba(192, 132, 252, 0.2);
border-color: rgba(192, 132, 252, 0.7);
}
.blog-kicker {
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 12px;
color: var(--accent);
color: var(--accent-blog);
margin: 0 0 10px;
}
@@ -56,23 +80,27 @@
.blog-toggle-list {
display: none;
position: fixed;
top: 20px;
left: 20px;
/* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
right: 24px;
top: auto;
left: auto;
z-index: 1000;
width: 40px;
height: 40px;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--line);
background: rgba(10, 12, 20, 0.8);
color: var(--text);
border: 1px solid rgba(192, 132, 252, 0.45);
background: rgba(10, 12, 20, 0.88);
color: var(--accent-blog);
font-size: 18px;
cursor: pointer;
backdrop-filter: blur(10px);
backdrop-filter: blur(12px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
transition: transform 0.2s ease, opacity 0.2s ease;
}
.blog-toggle-list:hover {
transform: scale(1.1);
transform: scale(1.08);
opacity: 0.9;
}
@@ -98,29 +126,87 @@
}
.blog-category-chip.is-active {
border-color: rgba(247, 168, 165, 0.6);
background: rgba(247, 168, 165, 0.2);
border-color: rgba(192, 132, 252, 0.55);
background: rgba(192, 132, 252, 0.15);
color: var(--accent-blog);
}
.blog-list__item {
.blog-list__item-wrap {
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius-md);
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
box-shadow: var(--shadow-inset);
overflow: hidden;
}
.blog-list__item-wrap:hover {
border-color: var(--line-strong);
background: var(--surface-raised);
box-shadow: var(--shadow-sm), var(--shadow-inset);
}
.blog-list__item-wrap.is-active {
border-color: rgba(192, 132, 252, 0.5);
box-shadow: 0 4px 20px rgba(192, 132, 252, 0.12), var(--shadow-inset);
background: rgba(192, 132, 252, 0.05);
}
.blog-list__item-btn {
width: 100%;
padding: 16px;
border-radius: 18px;
text-align: left;
cursor: pointer;
display: grid;
gap: 8px;
transition: border-color 0.2s ease;
background: transparent;
border: none;
color: inherit;
}
.blog-list__item:hover {
border-color: rgba(255, 255, 255, 0.25);
.blog-list__actions {
display: flex;
gap: 6px;
padding: 0 12px 10px;
}
.blog-list__item.is-active {
border-color: rgba(247, 168, 165, 0.6);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
.blog-list__action-btn {
font-size: 11px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.blog-list__action-btn:hover {
border-color: var(--accent-blog);
color: var(--accent-blog);
}
.blog-list__action-btn--del:hover {
border-color: #f04452;
color: #f04452;
}
.blog-article__edit-btn {
font-size: 11px;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
cursor: pointer;
text-transform: none;
letter-spacing: 0;
transition: border-color 0.15s, color 0.15s;
}
.blog-article__edit-btn:hover {
border-color: var(--accent-blog);
color: var(--accent-blog);
}
.blog-pagination {
@@ -168,14 +254,15 @@
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
color: var(--accent-blog);
}
.blog-article {
border: 1px solid var(--line);
border-radius: 24px;
border-radius: var(--radius-lg);
background: rgba(9, 10, 16, 0.65);
padding: 24px;
padding: 28px;
box-shadow: var(--shadow-md), var(--shadow-inset);
}
.blog-article__meta {
@@ -277,8 +364,9 @@
.md-quote {
margin: 0 0 14px;
padding: 12px 16px;
border-left: 3px solid rgba(247, 168, 165, 0.6);
background: rgba(255, 255, 255, 0.03);
border-left: 3px solid rgba(192, 132, 252, 0.5);
background: rgba(192, 132, 252, 0.05);
border-radius: 0 8px 8px 0;
color: var(--muted);
}
@@ -363,22 +451,27 @@
color: var(--muted);
}
@media (max-width: 900px) {
.blog-header,
.blog-grid {
@media (max-width: 768px) {
.blog-header {
grid-template-columns: 1fr;
}
.blog-header__actions {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
}
.blog-toggle-list {
display: block;
}
.blog-list {
display: none;
gap: 10px;
}
.blog-list.is-visible {
display: block;
position: fixed;
top: 0;
left: 0;
@@ -396,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 {
@@ -404,23 +504,19 @@
.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 {
.blog-list__item-btn {
padding: 14px;
}
@@ -432,10 +528,6 @@
font-size: 12px;
}
.blog-article {
padding: 18px;
}
.blog-article__body h1 {
font-size: 24px;
}
@@ -469,3 +561,222 @@
padding: 16px;
}
}
/* ── 블로그 에디터 모달 ──────────────────────────────────────────────────── */
.blog-editor-overlay {
position: fixed;
inset: 0;
background: rgba(4, 6, 14, 0.75);
backdrop-filter: blur(6px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.blog-editor {
background: #0c0f1e;
border: 1px solid rgba(192, 132, 252, 0.25);
border-radius: var(--radius-xl, 20px);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(192, 132, 252, 0.06);
width: 100%;
max-width: 860px;
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.blog-editor__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px 14px;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.blog-editor__heading {
margin: 0;
font-size: 17px;
font-weight: 700;
color: var(--accent-blog);
letter-spacing: 0.04em;
}
.blog-editor__close {
background: transparent;
border: none;
color: var(--muted);
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
line-height: 1;
transition: color 0.15s;
}
.blog-editor__close:hover {
color: var(--text-bright, #fff);
}
.blog-editor__title-input {
margin: 14px 24px 0;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--line);
border-radius: var(--radius-md, 10px);
color: var(--text-bright, #f8f3ee);
font-size: 16px;
font-weight: 600;
outline: none;
transition: border-color 0.15s;
flex-shrink: 0;
}
.blog-editor__title-input:focus {
border-color: rgba(192, 132, 252, 0.5);
}
.blog-editor__title-input::placeholder {
color: var(--muted);
}
.blog-editor__tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 24px 0;
flex-shrink: 0;
}
.blog-editor__tab-bar {
display: flex;
gap: 4px;
padding: 12px 24px 0;
flex-shrink: 0;
}
.blog-editor__tab {
padding: 5px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.blog-editor__tab.is-active {
border-color: rgba(192, 132, 252, 0.55);
background: rgba(192, 132, 252, 0.12);
color: var(--accent-blog);
}
.blog-editor__textarea {
flex: 1;
margin: 10px 24px 0;
padding: 14px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--line);
border-radius: var(--radius-md, 10px);
color: var(--text-bright, #f8f3ee);
font-size: 14px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
line-height: 1.75;
resize: none;
outline: none;
min-height: 320px;
transition: border-color 0.15s;
}
.blog-editor__textarea:focus {
border-color: rgba(192, 132, 252, 0.4);
}
.blog-editor__preview {
flex: 1;
margin: 10px 24px 0;
padding: 14px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--line);
border-radius: var(--radius-md, 10px);
overflow-y: auto;
min-height: 320px;
}
.blog-editor__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 24px 18px;
border-top: 1px solid var(--line);
flex-shrink: 0;
margin-top: 12px;
}
.blog-editor__save-btn {
border-color: rgba(192, 132, 252, 0.55) !important;
background: rgba(192, 132, 252, 0.15) !important;
color: var(--accent-blog) !important;
}
.blog-editor__save-btn:hover:not(:disabled) {
background: rgba(192, 132, 252, 0.25) !important;
}
.blog-editor__save-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 768px) {
.blog-editor-overlay {
align-items: flex-end;
padding: 0;
}
.blog-editor {
max-width: 100%;
max-height: 95vh;
border-radius: var(--radius-xl, 20px) var(--radius-xl, 20px) 0 0;
}
.blog-editor__title-input,
.blog-editor__tag-row,
.blog-editor__tab-bar,
.blog-editor__textarea,
.blog-editor__preview {
margin-left: 16px;
margin-right: 16px;
}
.blog-editor__header,
.blog-editor__footer {
padding-left: 16px;
padding-right: 16px;
}
.blog-new-btn {
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;
}
}

View File

@@ -1,7 +1,17 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getBlogPosts } from '../../data/blog';
import {
getBlogPostsApi,
createBlogPost,
updateBlogPost,
deleteBlogPost,
} from '../../api';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import './Blog.css';
// ── 마크다운 렌더러 ──────────────────────────────────────────────────────────
const renderInline = (text) => {
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
const pattern =
@@ -122,9 +132,7 @@ const renderMarkdown = (body) => {
flushList();
if (!line.trim()) {
return;
}
if (!line.trim()) return;
if (line.startsWith('###### ')) {
blocks.push({ type: 'h6', value: line.replace(/^######\s+/, '') });
@@ -193,78 +201,281 @@ const renderMarkdown = (body) => {
});
};
// ── 블로그 에디터 모달 ────────────────────────────────────────────────────────
const PRESET_TAGS = ['일상', '개발', '공부', '아이디어', '기타'];
const BlogEditor = ({ post, onSave, onClose }) => {
const [title, setTitle] = useState(post?.title || '');
const [tags, setTags] = useState(post?.tags || []);
const [body, setBody] = useState(post?.body || '');
const [showPreview, setShowPreview] = useState(false);
const [saving, setSaving] = useState(false);
const textareaRef = useRef(null);
// Tab 키로 들여쓰기 삽입
const handleKeyDown = (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const el = textareaRef.current;
const start = el.selectionStart;
const end = el.selectionEnd;
const next = body.substring(0, start) + ' ' + body.substring(end);
setBody(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + 2;
});
}
};
const toggleTag = (tag) => {
setTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
const handleSave = async () => {
if (!title.trim()) return;
setSaving(true);
try {
const today = new Date().toISOString().slice(0, 10);
const excerpt = body
.split(/\r?\n/)
.find((l) => l.trim() && !l.startsWith('#'))
?.trim()
.slice(0, 120) || '';
await onSave({
title: title.trim(),
tags,
body,
excerpt,
date: post?.date || today,
});
onClose();
} finally {
setSaving(false);
}
};
// ESC 키로 닫기
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
return (
<div className="blog-editor-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="blog-editor">
<div className="blog-editor__header">
<h2 className="blog-editor__heading">
{post?.id ? '글 수정' : '새 글 쓰기'}
</h2>
<button type="button" className="blog-editor__close" onClick={onClose} aria-label="닫기">
</button>
</div>
<input
className="blog-editor__title-input"
type="text"
placeholder="제목을 입력하세요"
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
/>
<div className="blog-editor__tag-row">
{PRESET_TAGS.map((tag) => (
<button
key={tag}
type="button"
className={`blog-category-chip${tags.includes(tag) ? ' is-active' : ''}`}
onClick={() => toggleTag(tag)}
>
{tag}
</button>
))}
</div>
<div className="blog-editor__tab-bar">
<button
type="button"
className={`blog-editor__tab${!showPreview ? ' is-active' : ''}`}
onClick={() => setShowPreview(false)}
>
편집
</button>
<button
type="button"
className={`blog-editor__tab${showPreview ? ' is-active' : ''}`}
onClick={() => setShowPreview(true)}
>
미리보기
</button>
</div>
{showPreview ? (
<div className="blog-article__body blog-editor__preview">
{body
? renderMarkdown(body)
: <p style={{ color: 'var(--muted)' }}>본문을 입력하면 여기에 미리보기가 표시됩니다.</p>
}
</div>
) : (
<textarea
ref={textareaRef}
className="blog-editor__textarea"
placeholder="마크다운으로 글을 작성하세요...&#10;&#10;예시:&#10;# 제목&#10;## 소제목&#10;**굵게** *기울임* `코드`"
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={handleKeyDown}
spellCheck={false}
/>
)}
<div className="blog-editor__footer">
<button type="button" className="button" onClick={onClose}>
취소
</button>
<button
type="button"
className="button blog-editor__save-btn"
onClick={handleSave}
disabled={saving || !title.trim()}
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
</div>
);
};
// ── 메인 Blog 컴포넌트 ───────────────────────────────────────────────────────
const Blog = () => {
const posts = useMemo(() => getBlogPosts(), []);
const staticPosts = useMemo(() => getBlogPosts(), []);
const [apiPosts, setApiPosts] = useState([]);
const [apiError, setApiError] = useState(false);
const [editorPost, setEditorPost] = useState(null); // null=닫힘, {}=새글, post=수정
const [isEditorOpen, setIsEditorOpen] = useState(false);
const fetchPosts = useCallback(() => {
return getBlogPostsApi()
.then((data) => {
const posts = Array.isArray(data) ? data : (data?.posts ?? []);
setApiPosts(posts.map((p) => ({ ...p, slug: `api-${p.id}` })));
})
.catch(() => setApiError(true));
}, []);
// API 글 불러오기
useEffect(() => {
fetchPosts();
}, [fetchPosts]);
// 정적 + API 글 병합 (API 글이 앞에 표시)
const allPosts = useMemo(() => {
const combined = [...apiPosts, ...staticPosts];
return combined.sort((a, b) => {
const aDate = Date.parse(a.date || '') || 0;
const bDate = Date.parse(b.date || '') || 0;
return bDate - aDate;
});
}, [apiPosts, staticPosts]);
const categoryNames = ['일상', '개발', '공부', '아이디어'];
const categorized = useMemo(() => {
const map = new Map(categoryNames.map((name) => [name, []]));
const misc = [];
posts.forEach((post) => {
allPosts.forEach((post) => {
const matched = categoryNames.find((name) => post.tags.includes(name));
if (matched) {
map.get(matched).push(post);
} else {
misc.push(post);
}
if (matched) map.get(matched).push(post);
else misc.push(post);
});
return {
categories: categoryNames.map((name) => ({
name,
items: map.get(name),
})),
categories: categoryNames.map((name) => ({ name, items: map.get(name) })),
misc,
};
}, [posts]);
}, [allPosts]);
const [selectedCategory, setSelectedCategory] = useState('전체');
const [page, setPage] = useState(1);
const [showList, setShowList] = useState(false);
const pageSize = 10;
const filteredPosts = useMemo(() => {
if (selectedCategory === '전체') return posts;
if (selectedCategory === '전체') return allPosts;
if (selectedCategory === '기타') return categorized.misc;
return posts.filter((post) => post.tags.includes(selectedCategory));
}, [posts, categorized.misc, selectedCategory]);
return allPosts.filter((post) => post.tags.includes(selectedCategory));
}, [allPosts, categorized.misc, selectedCategory]);
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / pageSize));
const pagedPosts = filteredPosts.slice((page - 1) * pageSize, page * pageSize);
const [activeSlug, setActiveSlug] = useState(pagedPosts[0]?.slug);
const activePost =
pagedPosts.find((post) => post.slug === activeSlug) || pagedPosts[0];
const activePost = pagedPosts.find((p) => p.slug === activeSlug) || pagedPosts[0];
useEffect(() => { if (page > totalPages) setPage(1); }, [page, totalPages]);
useEffect(() => {
if (page > totalPages) {
setPage(1);
}
}, [page, totalPages]);
useEffect(() => {
if (!pagedPosts.find((post) => post.slug === activeSlug)) {
if (!pagedPosts.find((p) => p.slug === activeSlug)) {
setActiveSlug(pagedPosts[0]?.slug);
}
}, [pagedPosts, activeSlug]);
useEffect(() => { setPage(1); }, [selectedCategory]);
useEffect(() => {
setPage(1);
}, [selectedCategory]);
// 에디터 저장 핸들러
const handleSave = useCallback(async (data) => {
if (editorPost?.id) {
// 수정
const updated = await updateBlogPost(editorPost.id, data);
setApiPosts((prev) =>
prev.map((p) =>
p.id === editorPost.id ? { ...p, ...updated, slug: `api-${updated.id ?? editorPost.id}` } : p
)
);
} else {
// 새 글
const created = await createBlogPost(data);
setApiPosts((prev) => [{ ...created, slug: `api-${created.id}` }, ...prev]);
setActiveSlug(`api-${created.id}`);
}
}, [editorPost]);
// 삭제 핸들러
const handleDelete = useCallback(async (post) => {
if (!window.confirm(`"${post.title}" 글을 삭제하시겠습니까?`)) return;
await deleteBlogPost(post.id);
setApiPosts((prev) => prev.filter((p) => p.id !== post.id));
if (activeSlug === post.slug) setActiveSlug(null);
}, [activeSlug]);
const openNewEditor = () => { setEditorPost({}); setIsEditorOpen(true); };
const openEditEditor = (post) => { setEditorPost(post); setIsEditorOpen(true); };
const closeEditor = useCallback(() => { setIsEditorOpen(false); setEditorPost(null); }, []);
return (
<PullToRefresh onRefresh={fetchPosts}>
<div className="blog">
<header className="blog-header">
<div>
<p className="blog-kicker">Journal</p>
<h1>개인 블로그</h1>
<p className="blog-sub">
마크다운 파일을 추가하면 자동으로 글이 목록에 추가됩니다.
글을 작성하고 태그를 달아 정리하세요.
</p>
</div>
<div className="blog-status">
<p className="blog-status__title">이번 주의 기록</p>
<p className="blog-status__desc">
손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
</p>
<div className="blog-header__actions">
<div className="blog-status">
<p className="blog-status__title">이번 주의 기록</p>
<p className="blog-status__desc">
손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
</p>
</div>
<button type="button" className="blog-new-btn" onClick={openNewEditor}>
+ 쓰기
</button>
</div>
</header>
@@ -283,32 +494,54 @@ const Blog = () => {
<button
key={name}
type="button"
className={`blog-category-chip${
selectedCategory === name ? ' is-active' : ''
}`}
className={`blog-category-chip${selectedCategory === name ? ' is-active' : ''}`}
onClick={() => setSelectedCategory(name)}
>
{name}
</button>
))}
</div>
{pagedPosts.map((post) => (
<button
<div
key={post.slug}
type="button"
className={`blog-list__item${
post.slug === activeSlug ? ' is-active' : ''
}`}
onClick={() => {
setActiveSlug(post.slug);
setShowList(false); // 모바일에서 글 선택 시 리스트 숨김
}}
className={`blog-list__item-wrap${post.slug === activeSlug ? ' is-active' : ''}`}
>
<p className="blog-list__title">{post.title}</p>
<p className="blog-list__excerpt">{post.excerpt}</p>
<span className="blog-list__meta">{post.date || '작성일 미정'}</span>
</button>
<button
type="button"
className="blog-list__item-btn"
onClick={() => {
setActiveSlug(post.slug);
setShowList(false);
}}
>
<p className="blog-list__title">{post.title}</p>
<p className="blog-list__excerpt">{post.excerpt}</p>
<span className="blog-list__meta">{post.date || '작성일 미정'}</span>
</button>
{post.id && (
<div className="blog-list__actions">
<button
type="button"
className="blog-list__action-btn"
title="수정"
onClick={() => openEditEditor(post)}
>
편집
</button>
<button
type="button"
className="blog-list__action-btn blog-list__action-btn--del"
title="삭제"
onClick={() => handleDelete(post)}
>
삭제
</button>
</div>
)}
</div>
))}
<div className="blog-pagination">
<button
type="button"
@@ -318,35 +551,41 @@ const Blog = () => {
>
이전
</button>
<span className="blog-page-indicator">
{page} / {totalPages}
</span>
<span className="blog-page-indicator">{page} / {totalPages}</span>
<button
type="button"
className="blog-page-btn"
onClick={() =>
setPage((prev) => Math.min(totalPages, prev + 1))
}
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
disabled={page === totalPages}
>
다음
</button>
</div>
</aside>
<article className="blog-article">
{activePost ? (
<>
<div className="blog-article__meta">
<span>{activePost.date || '작성일 미정'}</span>
{activePost.tags.length > 0 && (
<span className="blog-tags">
{activePost.tags.map((tag) => (
<span key={tag} className="blog-tag">
{tag}
</span>
))}
</span>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{activePost.tags.length > 0 && (
<span className="blog-tags">
{activePost.tags.map((tag) => (
<span key={tag} className="blog-tag">{tag}</span>
))}
</span>
)}
{activePost.id && (
<button
type="button"
className="blog-article__edit-btn"
onClick={() => openEditEditor(activePost)}
>
편집
</button>
)}
</div>
</div>
<div className="blog-article__body">
{renderMarkdown(activePost.body)}
@@ -354,8 +593,9 @@ const Blog = () => {
</>
) : (
<p className="blog-empty">
아직 작성된 글이 없습니다. `src/content/blog` 마크다운 파일을
추가해 주세요.
{apiError
? '블로그 API에 연결할 수 없습니다. 백엔드 서버를 확인해 주세요.'
: '아직 작성된 글이 없습니다. 새 글 쓰기 버튼으로 첫 글을 작성해 보세요.'}
</p>
)}
</article>
@@ -376,9 +616,7 @@ const Blog = () => {
>
<div className="blog-category-card__head">
<span>{group.name}</span>
<span className="blog-category-card__count">
{group.items.length}
</span>
<span className="blog-category-card__count">{group.items.length}</span>
</div>
<div className="blog-category-card__list">
{group.items.length ? (
@@ -386,9 +624,7 @@ const Blog = () => {
<span key={post.slug}>{post.title}</span>
))
) : (
<span className="blog-category-card__empty">
아직 글이 없습니다.
</span>
<span className="blog-category-card__empty">아직 글이 없습니다.</span>
)}
</div>
</button>
@@ -400,9 +636,7 @@ const Blog = () => {
>
<div className="blog-category-card__head">
<span>기타</span>
<span className="blog-category-card__count">
{categorized.misc.length}
</span>
<span className="blog-category-card__count">{categorized.misc.length}</span>
</div>
<div className="blog-category-card__list">
{categorized.misc.length ? (
@@ -410,15 +644,24 @@ const Blog = () => {
<span key={post.slug}>{post.title}</span>
))
) : (
<span className="blog-category-card__empty">
아직 글이 없습니다.
</span>
<span className="blog-category-card__empty">아직 글이 없습니다.</span>
)}
</div>
</button>
</div>
</section>
{isEditorOpen && (
<BlogEditor
post={editorPost}
onSave={handleSave}
onClose={closeEditor}
/>
)}
<FAB onClick={openNewEditor} label="글 쓰기" />
</div>
</PullToRefresh>
);
};

View File

@@ -0,0 +1,448 @@
/* ── DayCalc ─────────────────────────────────────────────────────────────── */
.daycalc {
max-width: 860px;
margin: 0 auto;
padding: 32px 24px 64px;
display: flex;
flex-direction: column;
gap: 28px;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.daycalc__back {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
text-decoration: none;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
margin-bottom: 12px;
transition: color 0.2s, border-color 0.2s;
}
.daycalc__back:hover {
color: var(--neon-cyan);
border-color: var(--line-bright);
}
.daycalc__kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent-lab);
margin-bottom: 6px;
}
.daycalc__header h1 {
font-size: 28px;
font-weight: 700;
color: var(--text-bright);
margin-bottom: 6px;
}
.daycalc__desc {
font-size: 14px;
color: var(--text-dim);
margin: 0;
}
/* ── Input Section ───────────────────────────────────────────────────────── */
.daycalc__input-section {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
.daycalc__date-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
align-items: center;
}
.daycalc__date-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.daycalc__date-field label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.daycalc__date-field input[type="date"] {
padding: 10px 14px;
font-size: 15px;
font-family: var(--font-display);
font-weight: 500;
color: var(--text-bright);
background: var(--bg-secondary);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
outline: none;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
width: 100%;
color-scheme: dark;
}
.daycalc__date-field input[type="date"]:focus {
border-color: var(--neon-cyan-dim);
box-shadow: 0 0 0 3px var(--neon-cyan-muted);
}
.daycalc__date-fmt {
font-size: 12px;
color: var(--text-dim);
padding-left: 2px;
}
.daycalc__arrow {
font-size: 22px;
font-weight: 300;
color: var(--text-muted);
text-align: center;
user-select: none;
padding-top: 20px;
}
.daycalc__arrow .fwd { color: var(--neon-cyan); }
.daycalc__arrow .bwd { color: var(--neon-purple); }
/* ── Presets ─────────────────────────────────────────────────────────────── */
.daycalc__presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.daycalc__presets-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-right: 4px;
}
.daycalc__preset-btn {
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--line);
border-radius: 20px;
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition: border-color 0.18s, color 0.18s, background 0.18s;
}
.daycalc__preset-btn:hover {
border-color: var(--neon-cyan-dim);
color: var(--neon-cyan);
background: var(--neon-cyan-muted);
}
.daycalc__preset-btn--clear {
color: var(--text-muted);
border-color: transparent;
}
.daycalc__preset-btn--clear:hover {
border-color: rgba(248, 113, 113, 0.4);
color: #f87171;
background: rgba(248, 113, 113, 0.08);
}
/* ── Tabs ────────────────────────────────────────────────────────────────── */
.daycalc__tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--line);
padding-bottom: 0;
}
.daycalc__tab {
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
border: none;
background: transparent;
color: var(--text-dim);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.18s, border-color 0.18s;
}
.daycalc__tab:hover {
color: var(--text-bright);
}
.daycalc__tab.is-active {
color: var(--accent-lab);
border-bottom-color: var(--accent-lab);
}
/* ── Result Section ──────────────────────────────────────────────────────── */
.daycalc__result {
display: flex;
flex-direction: column;
gap: 20px;
}
.daycalc__big-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.daycalc__big-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 24px 20px;
text-align: center;
transition: border-color 0.2s;
}
.daycalc__big-card--primary {
border-color: rgba(251, 191, 36, 0.3);
background: rgba(251, 191, 36, 0.04);
}
.daycalc__big-num {
font-family: var(--font-display);
font-size: 40px;
font-weight: 700;
color: var(--text-bright);
line-height: 1;
margin-bottom: 4px;
}
.daycalc__big-card--primary .daycalc__big-num {
color: var(--accent-lab);
text-shadow: 0 0 24px rgba(251, 191, 36, 0.4);
}
.daycalc__big-label {
font-size: 16px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
.daycalc__big-sub {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* ── Breakdown ───────────────────────────────────────────────────────────── */
.daycalc__breakdown {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 20px 24px;
}
.daycalc__breakdown h3 {
font-size: 13px;
font-weight: 700;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 16px;
}
.daycalc__breakdown-row {
display: flex;
gap: 32px;
align-items: baseline;
margin-bottom: 14px;
}
.daycalc__breakdown-item {
display: flex;
align-items: baseline;
gap: 5px;
}
.daycalc__breakdown-num {
font-family: var(--font-display);
font-size: 32px;
font-weight: 700;
color: var(--text-bright);
}
.daycalc__breakdown-unit {
font-size: 14px;
color: var(--text-dim);
font-weight: 500;
}
.daycalc__breakdown-summary {
font-size: 13px;
color: var(--text-muted);
border-top: 1px solid var(--line-subtle);
padding-top: 12px;
margin: 0;
}
/* ── Milestones ──────────────────────────────────────────────────────────── */
.daycalc__milestones {
display: flex;
flex-direction: column;
gap: 16px;
}
.daycalc__milestones-desc {
font-size: 13px;
color: var(--text-dim);
margin: 0;
}
.daycalc__milestones-desc strong {
color: var(--text-bright);
}
.daycalc__milestone-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.daycalc__milestone-row {
display: grid;
grid-template-columns: 100px 1fr auto;
align-items: center;
gap: 16px;
padding: 12px 16px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
transition: border-color 0.18s;
}
.daycalc__milestone-row:hover {
border-color: rgba(251, 191, 36, 0.3);
}
.daycalc__milestone-row.is-past {
opacity: 0.45;
}
.daycalc__milestone-row.is-today {
border-color: var(--accent-lab);
background: rgba(251, 191, 36, 0.06);
opacity: 1;
}
.daycalc__milestone-badge {
font-family: var(--font-display);
font-size: 14px;
font-weight: 700;
color: var(--accent-lab);
}
.daycalc__milestone-row.is-past .daycalc__milestone-badge {
color: var(--text-muted);
}
.daycalc__milestone-date {
font-size: 14px;
color: var(--text);
}
.daycalc__milestone-dday {
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
text-align: right;
white-space: nowrap;
}
.daycalc__milestone-row.is-today .daycalc__milestone-dday {
color: var(--accent-lab);
}
/* ── Empty State ─────────────────────────────────────────────────────────── */
.daycalc__empty {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.daycalc__empty-icon {
font-size: 48px;
margin-bottom: 4px;
}
.daycalc__empty p {
font-size: 14px;
color: var(--text-muted);
}
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.daycalc {
padding: 20px 16px 48px;
gap: 20px;
}
.daycalc__date-row {
grid-template-columns: 1fr;
gap: 12px;
}
.daycalc__arrow {
display: none;
}
.daycalc__big-cards {
grid-template-columns: 1fr;
}
.daycalc__milestone-row {
grid-template-columns: 80px 1fr auto;
gap: 10px;
}
.daycalc__breakdown-row {
gap: 20px;
}
.daycalc__breakdown-num {
font-size: 26px;
}
}

View File

@@ -0,0 +1,275 @@
import React, { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import './DayCalc.css';
const today = () => new Date().toISOString().slice(0, 10);
const fmt = (d) => {
if (!d) return '';
const [y, m, day] = d.split('-');
return `${y}${parseInt(m)}${parseInt(day)}`;
};
// 두 날짜 사이 diff 계산
const calcDiff = (from, to) => {
const f = new Date(from);
const t = new Date(to);
const totalMs = t - f;
const totalDays = Math.round(totalMs / 86400000);
// 연/월/일 분리 계산
let years = t.getFullYear() - f.getFullYear();
let months = t.getMonth() - f.getMonth();
let days = t.getDate() - f.getDate();
if (days < 0) {
months -= 1;
const prevMonth = new Date(t.getFullYear(), t.getMonth(), 0);
days += prevMonth.getDate();
}
if (months < 0) {
years -= 1;
months += 12;
}
const totalMonths = years * 12 + months;
const weeks = Math.floor(Math.abs(totalDays) / 7);
const remDays = Math.abs(totalDays) % 7;
return { totalDays, totalMonths, years, months, days, weeks, remDays };
};
// 특정 날짜로부터 N일 후 날짜 계산
const addDays = (dateStr, n) => {
const d = new Date(dateStr);
d.setDate(d.getDate() + n);
return d.toISOString().slice(0, 10);
};
// 기념일 체크포인트
const MILESTONES = [100, 200, 365, 500, 730, 1000, 1461, 2000, 3000];
const QUICK_PRESETS = [
{ label: '오늘 기준', offset: 0 },
{ label: '1주 후', offset: 7 },
{ label: '1개월 후', offset: 30 },
{ label: '3개월 후', offset: 90 },
{ label: '6개월 후', offset: 180 },
{ label: '1년 후', offset: 365 },
];
const DayCalc = () => {
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState(today());
const [tab, setTab] = useState('diff'); // diff | milestone | future
const result = useMemo(() => {
if (!fromDate || !toDate) return null;
try {
return calcDiff(fromDate, toDate);
} catch {
return null;
}
}, [fromDate, toDate]);
const milestones = useMemo(() => {
if (!fromDate) return [];
return MILESTONES.map((n) => ({
days: n,
date: addDays(fromDate, n - 1),
}));
}, [fromDate]);
const isForward = result ? result.totalDays >= 0 : true;
const applyPreset = (offset) => {
setToDate(addDays(today(), offset));
};
return (
<div className="daycalc">
<header className="daycalc__header">
<div>
<Link to="/lab" className="daycalc__back"> Lab</Link>
<p className="daycalc__kicker">Lab · 날짜 도구</p>
<h1>일수 계산기</h1>
<p className="daycalc__desc"> 날짜 사이의 기간과 기념일 날짜를 계산합니다.</p>
</div>
</header>
{/* 날짜 입력 */}
<section className="daycalc__input-section">
<div className="daycalc__date-row">
<div className="daycalc__date-field">
<label>시작일</label>
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
max={toDate || undefined}
/>
{fromDate && <span className="daycalc__date-fmt">{fmt(fromDate)}</span>}
</div>
<div className="daycalc__arrow">
{result
? <span className={isForward ? 'fwd' : 'bwd'}>{isForward ? '→' : '←'}</span>
: <span></span>}
</div>
<div className="daycalc__date-field">
<label>종료일</label>
<input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
/>
{toDate && <span className="daycalc__date-fmt">{fmt(toDate)}</span>}
</div>
</div>
{/* 빠른 종료일 설정 */}
<div className="daycalc__presets">
<span className="daycalc__presets-label">빠른 설정</span>
{QUICK_PRESETS.map((p) => (
<button
key={p.label}
className="daycalc__preset-btn"
onClick={() => applyPreset(p.offset)}
>
{p.label}
</button>
))}
<button
className="daycalc__preset-btn daycalc__preset-btn--clear"
onClick={() => { setFromDate(''); setToDate(today()); }}
>
초기화
</button>
</div>
</section>
{/* 결과 탭 */}
{fromDate && (
<>
<div className="daycalc__tabs">
{[
{ id: 'diff', label: '기간 계산' },
{ id: 'milestone', label: '기념일' },
].map((t) => (
<button
key={t.id}
className={`daycalc__tab${tab === t.id ? ' is-active' : ''}`}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</div>
{/* 기간 계산 탭 */}
{tab === 'diff' && result && (
<section className="daycalc__result">
{/* 메인 수치 */}
<div className="daycalc__big-cards">
<div className="daycalc__big-card daycalc__big-card--primary">
<p className="daycalc__big-num">
{isForward ? '+' : ''}{result.totalDays.toLocaleString()}
</p>
<p className="daycalc__big-label"></p>
<p className="daycalc__big-sub">
{isForward ? '경과' : '이전'}
</p>
</div>
<div className="daycalc__big-card">
<p className="daycalc__big-num">{result.totalMonths.toLocaleString()}</p>
<p className="daycalc__big-label">개월</p>
<p className="daycalc__big-sub"> 개월 </p>
</div>
<div className="daycalc__big-card">
<p className="daycalc__big-num">{result.weeks.toLocaleString()}</p>
<p className="daycalc__big-label"> {result.remDays}</p>
<p className="daycalc__big-sub"> 단위</p>
</div>
</div>
{/* 세부 분해 */}
<div className="daycalc__breakdown">
<h3>상세 기간</h3>
<div className="daycalc__breakdown-row">
{result.years > 0 && (
<div className="daycalc__breakdown-item">
<span className="daycalc__breakdown-num">{result.years}</span>
<span className="daycalc__breakdown-unit"></span>
</div>
)}
<div className="daycalc__breakdown-item">
<span className="daycalc__breakdown-num">{result.months}</span>
<span className="daycalc__breakdown-unit">개월</span>
</div>
<div className="daycalc__breakdown-item">
<span className="daycalc__breakdown-num">{result.days}</span>
<span className="daycalc__breakdown-unit"></span>
</div>
</div>
<p className="daycalc__breakdown-summary">
{fmt(fromDate)} 부터 {fmt(toDate)} 까지
</p>
</div>
</section>
)}
{/* 기념일 탭 */}
{tab === 'milestone' && (
<section className="daycalc__milestones">
<p className="daycalc__milestones-desc">
<strong>{fmt(fromDate)}</strong> 기준으로 기념일 날짜입니다.
</p>
<div className="daycalc__milestone-list">
{milestones.map(({ days, date }) => {
const isPast = date < today();
const isToday = date === today();
const diff = calcDiff(today(), date);
return (
<div
key={days}
className={`daycalc__milestone-row${isPast ? ' is-past' : ''}${isToday ? ' is-today' : ''}`}
>
<div className="daycalc__milestone-badge">
{days < 365
? `D+${days}`
: days % 365 === 0
? `${days / 365}주년`
: `D+${days}`}
</div>
<div className="daycalc__milestone-date">{fmt(date)}</div>
<div className="daycalc__milestone-dday">
{isToday
? '🎉 오늘'
: isPast
? `${Math.abs(diff.totalDays)}일 전`
: `D-${diff.totalDays}`}
</div>
</div>
);
})}
</div>
</section>
)}
</>
)}
{!fromDate && (
<div className="daycalc__empty">
<p className="daycalc__empty-icon">📅</p>
<p>시작일을 입력하면 기간 계산을 시작합니다.</p>
</div>
)}
</div>
);
};
export default DayCalc;

View File

@@ -1,59 +1,196 @@
.effect-lab {
position: relative;
width: 100%;
height: 100%;
min-height: calc(100vh - 80px);
/* Adjust based on navbar height */
overflow: hidden;
background-color: #050505;
border-radius: 20px;
border: 1px solid var(--line);
/* ── Lab Landing Page ────────────────────────────────────────────────────── */
.lab {
max-width: 1000px;
margin: 0 auto;
padding: 40px 24px 80px;
display: flex;
flex-direction: column;
gap: 40px;
}
.effect-lab canvas {
display: block;
outline: none;
/* ── Header ─────────────────────────────────────────────────────────────── */
.lab__header {
display: flex;
flex-direction: column;
gap: 8px;
}
.effect-lab-overlay {
position: absolute;
top: 20px;
left: 20px;
color: rgba(255, 255, 255, 0.7);
pointer-events: none;
z-index: 10;
.lab__kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-lab);
}
.effect-lab-overlay h2 {
margin: 0 0 8px;
font-family: var(--font-display);
font-size: 28px;
color: var(--text);
text-shadow: 0 0 20px rgba(68, 170, 221, 0.5);
.lab__header h1 {
font-size: 36px;
font-weight: 700;
color: var(--text-bright);
line-height: 1.1;
}
.active-mode {
display: inline-block;
background: rgba(68, 170, 221, 0.1);
border: 1px solid rgba(68, 170, 221, 0.3);
padding: 6px 12px;
border-radius: 99px;
font-size: 12px;
color: #44aadd;
margin-bottom: 12px;
font-weight: 600;
}
.active-mode span {
color: #fff;
}
.effect-lab-overlay p {
margin: 0;
.lab__desc {
font-size: 14px;
color: var(--muted);
max-width: 400px;
line-height: 1.5;
}
color: var(--text-dim);
max-width: 560px;
margin: 0;
}
/* ── Grid ────────────────────────────────────────────────────────────────── */
.lab__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 18px;
}
/* ── Lab Card ────────────────────────────────────────────────────────────── */
.lab-card {
--card-accent: var(--neon-cyan);
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
text-decoration: none;
color: inherit;
position: relative;
overflow: hidden;
transition: border-color 0.22s, transform 0.22s, box-shadow 0.22s;
}
.lab-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, color-mix(in srgb, var(--card-accent) 8%, transparent), transparent 60%);
opacity: 0;
transition: opacity 0.22s;
pointer-events: none;
}
.lab-card:hover {
border-color: color-mix(in srgb, var(--card-accent) 40%, transparent);
transform: translateY(-3px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px color-mix(in srgb, var(--card-accent) 15%, transparent);
}
.lab-card:hover::before {
opacity: 1;
}
/* ── Card Top ────────────────────────────────────────────────────────────── */
.lab-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.lab-card__icon {
font-size: 32px;
line-height: 1;
filter: drop-shadow(0 0 10px color-mix(in srgb, var(--card-accent) 50%, transparent));
}
.lab-card__status {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
padding: 3px 8px;
border-radius: 20px;
border: 1px solid;
background: rgba(255, 255, 255, 0.04);
}
/* ── Card Body ───────────────────────────────────────────────────────────── */
.lab-card__body {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.lab-card__category {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--card-accent) 80%, var(--text-dim));
margin: 0;
}
.lab-card__title {
font-size: 20px;
font-weight: 700;
color: var(--text-bright);
line-height: 1.2;
}
.lab-card__desc {
font-size: 13px;
color: var(--text-dim);
line-height: 1.6;
margin: 4px 0 0;
}
/* ── Card Footer ─────────────────────────────────────────────────────────── */
.lab-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: auto;
}
.lab-card__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.lab-card__tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--line);
color: var(--text-muted);
}
.lab-card__arrow {
font-size: 18px;
color: color-mix(in srgb, var(--card-accent) 60%, transparent);
transition: transform 0.18s, color 0.18s;
flex-shrink: 0;
}
.lab-card:hover .lab-card__arrow {
transform: translateX(4px);
color: var(--card-accent);
}
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.lab {
padding: 24px 16px 60px;
gap: 28px;
}
.lab__grid {
grid-template-columns: 1fr;
}
.lab__header h1 {
font-size: 28px;
}
}

View File

@@ -1,218 +1,92 @@
import React, { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import React from 'react';
import { Link } from 'react-router-dom';
import './EffectLab.css';
const EffectLab = () => {
const containerRef = useRef(null);
const requestRef = useRef();
const [mode, setMode] = useState('HOVER'); // HOVER, ATTACK, ORBIT
const LAB_ITEMS = [
{
id: 'sword-stream',
path: '/lab/sword-stream',
title: 'Sword Stream',
category: '3D · 인터랙티브',
desc: '1,500개의 검 파티클이 마우스를 따라 흐릅니다. 클릭하면 나선형 궤도로 전환됩니다.',
tags: ['Three.js', '파티클', '인터랙티브'],
accent: '#44aadd',
icon: '⚔️',
status: 'live',
},
{
id: 'day-calc',
path: '/lab/day-calc',
title: '일수 계산기',
category: '유틸리티 · 날짜',
desc: '두 날짜 사이의 기간을 일, 주, 월, 연 단위로 계산하고 기념일 날짜를 확인합니다.',
tags: ['날짜', '계산기', '기념일'],
accent: '#fbbf24',
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',
},
];
useEffect(() => {
if (!containerRef.current) return;
// --- Configuration ---
const COUNT = 1500;
const SWORD_COLOR = 0x44aadd;
const SWORD_EMISSIVE = 0x112244;
// --- Helper: Random Range ---
const rand = (min, max) => Math.random() * (max - min) + min;
// --- Setup Scene ---
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050505, 0.002);
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
camera.position.z = 80;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// Tone mapping for better glow look
renderer.toneMapping = THREE.ACESFilmicToneMapping;
containerRef.current.appendChild(renderer.domElement);
// --- Lighting ---
const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);
const pointLight = new THREE.PointLight(SWORD_COLOR, 2, 100);
scene.add(pointLight);
// --- Geometry & Material ---
// Sword shape: Cone stretched
const geometry = new THREE.CylinderGeometry(0.1, 0.4, 4, 8);
geometry.rotateX(Math.PI / 2); // Point towards Z
const material = new THREE.MeshPhongMaterial({
color: SWORD_COLOR,
emissive: SWORD_EMISSIVE,
shininess: 100,
flatShading: true,
});
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
scene.add(mesh);
// --- Particle Data ---
const dummy = new THREE.Object3D();
const particles = [];
for (let i = 0; i < COUNT; i++) {
particles.push({
pos: new THREE.Vector3(rand(-100, 100), rand(-100, 100), rand(-50, 50)),
vel: new THREE.Vector3(),
acc: new THREE.Vector3(),
// Orbit parameters
angle: rand(0, Math.PI * 2),
radius: rand(15, 30),
speed: rand(0.02, 0.05),
// Offset for natural movement
offset: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5))
});
}
// --- Mouse & Interaction State ---
const mouse = new THREE.Vector3();
const target = new THREE.Vector3();
const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const raycaster = new THREE.Raycaster();
let isMouseDown = false;
let time = 0;
const handleMouseMove = (e) => {
const rect = renderer.domElement.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
raycaster.ray.intersectPlane(mousePlane, mouse);
// Allow light to follow mouse
pointLight.position.copy(mouse);
pointLight.position.z = 20;
};
const handleMouseDown = () => { isMouseDown = true; setMode('ORBIT'); };
const handleMouseUp = () => { isMouseDown = false; setMode('HOVER'); };
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
// --- Animation Loop ---
const animate = () => {
requestRef.current = requestAnimationFrame(animate);
time += 0.01;
for (let i = 0; i < COUNT; i++) {
const p = particles[i];
// --- Behavior Logic ---
if (isMouseDown) {
// 1. ORBIT MODE: Rotate around mouse
p.angle += p.speed + 0.02; // Spin faster
const orbitX = mouse.x + Math.cos(p.angle + time) * p.radius;
const orbitY = mouse.y + Math.sin(p.angle + time) * p.radius;
// Spiraling Z for depth
const orbitZ = Math.sin(p.angle * 2 + time) * 10;
target.set(orbitX, orbitY, orbitZ);
// Strong pull to orbit positions
p.acc.subVectors(target, p.pos).multiplyScalar(0.08);
} else {
// 2. HOVER/FOLLOW MODE: Follow mouse with flocking feel
// Add noise/wandering
const noiseX = Math.sin(time + i * 0.1) * 5;
const noiseY = Math.cos(time + i * 0.1) * 5;
target.copy(mouse).add(p.offset).add(new THREE.Vector3(noiseX, noiseY, 0));
// Gentle pull
p.acc.subVectors(target, p.pos).multiplyScalar(0.008);
}
// Physics update
p.vel.add(p.acc);
p.vel.multiplyScalar(isMouseDown ? 0.90 : 0.94); // Drag
p.pos.add(p.vel);
// Update Matrix
dummy.position.copy(p.pos);
// Rotation: Look at velocity direction (dynamic) or mouse (focused)
// Blending lookAt target for smoother rotation
const lookPos = new THREE.Vector3().copy(p.pos).add(p.vel.clone().multiplyScalar(5));
// If moving very slowly, keep previous rotation to avoid jitter
if (p.vel.lengthSq() > 0.01) {
dummy.lookAt(lookPos);
}
// Scale effect based on speed (stretch when fast)
const speedScale = 1 + Math.min(p.vel.length(), 2) * 0.5;
dummy.scale.set(1, 1, speedScale);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
renderer.render(scene, camera);
};
animate();
// --- Resize ---
const handleResize = () => {
if (!containerRef.current) return;
const newWidth = containerRef.current.clientWidth;
const newHeight = containerRef.current.clientHeight;
camera.aspect = newWidth / newHeight;
camera.updateProjectionMatrix();
renderer.setSize(newWidth, newHeight);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('resize', handleResize);
cancelAnimationFrame(requestRef.current);
if (containerRef.current && renderer.domElement) {
containerRef.current.removeChild(renderer.domElement);
}
geometry.dispose();
material.dispose();
renderer.dispose();
};
}, []);
const STATUS_LABEL = {
live: { label: 'LIVE', color: '#34d399' },
wip: { label: 'WIP', color: '#fbbf24' },
planned: { label: 'PLANNED', color: '#94a3b8' },
};
const LabCard = ({ item }) => {
const st = STATUS_LABEL[item.status] || STATUS_LABEL.planned;
return (
<div className="effect-lab" ref={containerRef}>
<div className="effect-lab-overlay">
<h2>Sword Stream</h2>
<div className="active-mode">
MODE: <span>{mode}</span>
</div>
<p>
<strong>Move</strong> to Guide &nbsp;|&nbsp;
<strong>Click & Hold</strong> to Orbit & Charge
</p>
<Link to={item.path} className="lab-card" style={{ '--card-accent': item.accent }}>
<div className="lab-card__top">
<span className="lab-card__icon">{item.icon}</span>
<span className="lab-card__status" style={{ color: st.color, borderColor: `${st.color}40` }}>
{st.label}
</span>
</div>
</div>
<div className="lab-card__body">
<p className="lab-card__category">{item.category}</p>
<h2 className="lab-card__title">{item.title}</h2>
<p className="lab-card__desc">{item.desc}</p>
</div>
<div className="lab-card__footer">
<div className="lab-card__tags">
{item.tags.map((t) => (
<span key={t} className="lab-card__tag">{t}</span>
))}
</div>
<span className="lab-card__arrow"></span>
</div>
</Link>
);
};
const EffectLab = () => (
<div className="lab">
<header className="lab__header">
<p className="lab__kicker">STREAM</p>
<h1>Lab</h1>
<p className="lab__desc">
실험적인 UI, 인터랙티브 효과, 유틸리티 도구를 테스트하고 탐구하는 공간입니다.
</p>
</header>
<div className="lab__grid">
{LAB_ITEMS.map((item) => (
<LabCard key={item.id} item={item} />
))}
</div>
</div>
);
export default EffectLab;

View File

@@ -0,0 +1,93 @@
.sword-stream {
position: relative;
width: 100%;
height: 100%;
min-height: calc(100vh - 80px);
overflow: hidden;
background-color: #050505;
border-radius: 20px;
border: 1px solid var(--line);
}
.sword-stream canvas {
display: block;
outline: none;
}
.sword-stream__overlay {
position: absolute;
top: 20px;
left: 20px;
color: rgba(255, 255, 255, 0.7);
pointer-events: none;
z-index: 10;
display: flex;
flex-direction: column;
gap: 8px;
}
.sword-stream__back {
pointer-events: all;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: rgba(68, 170, 221, 0.7);
text-decoration: none;
padding: 5px 10px;
border: 1px solid rgba(68, 170, 221, 0.2);
border-radius: 6px;
background: rgba(68, 170, 221, 0.06);
transition: color 0.2s, border-color 0.2s, background 0.2s;
width: fit-content;
}
.sword-stream__back:hover {
color: #44aadd;
border-color: rgba(68, 170, 221, 0.5);
background: rgba(68, 170, 221, 0.12);
}
.sword-stream__overlay h2 {
margin: 0;
font-family: var(--font-display);
font-size: 28px;
color: var(--text);
text-shadow: 0 0 20px rgba(68, 170, 221, 0.5);
}
.sword-stream__mode {
display: inline-block;
background: rgba(68, 170, 221, 0.1);
border: 1px solid rgba(68, 170, 221, 0.3);
padding: 6px 12px;
border-radius: 99px;
font-size: 12px;
color: #44aadd;
font-weight: 600;
width: fit-content;
}
.sword-stream__mode span {
color: #fff;
}
.sword-stream__overlay p {
margin: 0;
font-size: 14px;
color: var(--muted);
max-width: 400px;
line-height: 1.5;
}
@media (max-width: 768px) {
.sword-stream {
touch-action: none;
}
.sword-stream__overlay {
padding: 12px;
font-size: 12px;
}
}

View File

@@ -0,0 +1,184 @@
import React, { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import * as THREE from 'three';
import './SwordStream.css';
const SwordStream = () => {
const containerRef = useRef(null);
const requestRef = useRef();
const [mode, setMode] = useState('HOVER'); // HOVER, ORBIT
useEffect(() => {
if (!containerRef.current) return;
const COUNT = 1500;
const SWORD_COLOR = 0x44aadd;
const SWORD_EMISSIVE = 0x112244;
const rand = (min, max) => Math.random() * (max - min) + min;
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050505, 0.002);
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
camera.position.z = 80;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
containerRef.current.appendChild(renderer.domElement);
const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);
const pointLight = new THREE.PointLight(SWORD_COLOR, 2, 100);
scene.add(pointLight);
const geometry = new THREE.CylinderGeometry(0.1, 0.4, 4, 8);
geometry.rotateX(Math.PI / 2);
const material = new THREE.MeshPhongMaterial({
color: SWORD_COLOR,
emissive: SWORD_EMISSIVE,
shininess: 100,
flatShading: true,
});
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
scene.add(mesh);
const dummy = new THREE.Object3D();
const particles = [];
for (let i = 0; i < COUNT; i++) {
particles.push({
pos: new THREE.Vector3(rand(-100, 100), rand(-100, 100), rand(-50, 50)),
vel: new THREE.Vector3(),
acc: new THREE.Vector3(),
angle: rand(0, Math.PI * 2),
radius: rand(15, 30),
speed: rand(0.02, 0.05),
offset: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5)),
});
}
const mouse = new THREE.Vector3();
const target = new THREE.Vector3();
const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const raycaster = new THREE.Raycaster();
let isMouseDown = false;
let time = 0;
const handleMouseMove = (e) => {
const rect = renderer.domElement.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
raycaster.ray.intersectPlane(mousePlane, mouse);
pointLight.position.copy(mouse);
pointLight.position.z = 20;
};
const handleMouseDown = () => { isMouseDown = true; setMode('ORBIT'); };
const handleMouseUp = () => { isMouseDown = false; setMode('HOVER'); };
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
const animate = () => {
requestRef.current = requestAnimationFrame(animate);
time += 0.01;
for (let i = 0; i < COUNT; i++) {
const p = particles[i];
if (isMouseDown) {
p.angle += p.speed + 0.02;
const orbitX = mouse.x + Math.cos(p.angle + time) * p.radius;
const orbitY = mouse.y + Math.sin(p.angle + time) * p.radius;
const orbitZ = Math.sin(p.angle * 2 + time) * 10;
target.set(orbitX, orbitY, orbitZ);
p.acc.subVectors(target, p.pos).multiplyScalar(0.08);
} else {
const noiseX = Math.sin(time + i * 0.1) * 5;
const noiseY = Math.cos(time + i * 0.1) * 5;
target.copy(mouse).add(p.offset).add(new THREE.Vector3(noiseX, noiseY, 0));
p.acc.subVectors(target, p.pos).multiplyScalar(0.008);
}
p.vel.add(p.acc);
p.vel.multiplyScalar(isMouseDown ? 0.90 : 0.94);
p.pos.add(p.vel);
dummy.position.copy(p.pos);
const lookPos = new THREE.Vector3().copy(p.pos).add(p.vel.clone().multiplyScalar(5));
if (p.vel.lengthSq() > 0.01) {
dummy.lookAt(lookPos);
}
const speedScale = 1 + Math.min(p.vel.length(), 2) * 0.5;
dummy.scale.set(1, 1, speedScale);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
renderer.render(scene, camera);
};
animate();
const handleResize = () => {
if (!containerRef.current) return;
const newWidth = containerRef.current.clientWidth;
const newHeight = containerRef.current.clientHeight;
camera.aspect = newWidth / newHeight;
camera.updateProjectionMatrix();
renderer.setSize(newWidth, newHeight);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('resize', handleResize);
cancelAnimationFrame(requestRef.current);
if (containerRef.current && renderer.domElement) {
containerRef.current.removeChild(renderer.domElement);
}
geometry.dispose();
material.dispose();
renderer.dispose();
};
}, []);
return (
<div className="sword-stream" ref={containerRef}>
<div className="sword-stream__overlay">
<Link to="/lab" className="sword-stream__back"> Lab</Link>
<h2>Sword Stream</h2>
<div className="sword-stream__mode">
MODE: <span>{mode}</span>
</div>
<p>
<strong>Move</strong> to Guide &nbsp;|&nbsp;
<strong>Click &amp; Hold</strong> to Orbit &amp; Charge
</p>
</div>
</div>
);
};
export default SwordStream;

View File

@@ -1,77 +1,113 @@
/* ═══════════════════════════════════════════════════════════════════════
Home Page — Dashboard Style
═══════════════════════════════════════════════════════════════════════ */
.home {
display: grid;
gap: 60px;
gap: 32px;
animation: fadeIn 0.4s var(--ease-out) both;
}
.home > section {
animation: fadeUp 0.7s ease both;
}
.home > section:nth-child(1) {
animation-delay: 0.05s;
}
.home > section:nth-child(2) {
animation-delay: 0.12s;
}
.home > section:nth-child(3) {
animation-delay: 0.18s;
}
/* ── Hero ────────────────────────────────────────────────────────────── */
.home-hero {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
gap: 32px;
align-items: center;
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.7fr);
gap: 24px;
align-items: stretch;
}
.home-hero__kicker {
font-size: 12px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.28em;
color: var(--accent);
margin: 0 0 12px;
letter-spacing: 0.3em;
color: var(--neon-cyan);
margin: 0 0 14px;
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
}
.home-hero__kicker::before {
content: '';
display: block;
width: 24px;
height: 1.5px;
background: var(--neon-cyan);
border-radius: 2px;
box-shadow: 0 0 6px var(--neon-cyan);
}
.home-hero h1 {
font-family: var(--font-display);
font-size: clamp(32px, 4vw, 46px);
font-size: clamp(28px, 3.5vw, 44px);
margin: 0 0 16px;
line-height: 1.2;
color: var(--text-bright);
letter-spacing: -0.03em;
}
.home-hero__lead {
color: var(--muted);
line-height: 1.7;
color: var(--text-dim);
line-height: 1.75;
margin: 0 0 24px;
font-size: 14px;
}
.home-hero__actions {
display: flex;
gap: 12px;
gap: 10px;
flex-wrap: wrap;
}
/* ── Hero Card ───────────────────────────────────────────────────────── */
.home-hero__card {
background: var(--surface);
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: 24px;
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
box-shadow: var(--shadow-card);
position: relative;
overflow: hidden;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.home-hero__card-title {
.home-hero__card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--grad-accent);
opacity: 0.5;
}
.home-hero__card-eyebrow {
margin: 0 0 12px;
color: var(--muted);
font-size: 13px;
letter-spacing: 0.12em;
color: var(--text-muted);
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
font-family: var(--font-display);
}
.home-hero__card-body h2 {
font-family: var(--font-display);
font-size: 24px;
margin: 0 0 12px;
font-size: 20px;
margin: 0 0 8px;
color: var(--text-bright);
letter-spacing: -0.02em;
}
.home-hero__card-body p {
margin: 0;
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
}
.home-hero__stats {
@@ -85,81 +121,184 @@
.stat-label {
margin: 0;
color: var(--muted);
font-size: 12px;
color: var(--text-muted);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.stat-value {
margin: 6px 0 0;
font-weight: 600;
font-size: 18px;
margin: 5px 0 0;
font-weight: 700;
font-size: 20px;
color: var(--text-bright);
line-height: 1;
font-family: var(--font-display);
letter-spacing: -0.03em;
}
.stat-unit {
font-size: 13px;
font-weight: 500;
color: var(--text-dim);
margin-left: 2px;
}
.stat-value--sm {
font-size: 15px;
}
/* ── Section Header ──────────────────────────────────────────────────── */
.home-section__header {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 18px;
gap: 4px;
margin-bottom: 16px;
}
.home-section__header h2 {
margin: 0;
font-size: 26px;
font-size: clamp(17px, 2vw, 22px);
font-family: var(--font-display);
color: var(--text-bright);
letter-spacing: -0.02em;
}
.home-section__header p {
margin: 0;
color: var(--muted);
color: var(--text-muted);
font-size: 13px;
}
/* ── Navigation Cards Grid ───────────────────────────────────────────── */
.home-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.home-card {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
flex-direction: column;
gap: 12px;
text-decoration: none;
color: inherit;
padding: 18px;
border-radius: 18px;
border-radius: var(--radius-md);
border: 1px solid var(--line);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01));
transition: transform 0.2s ease, border-color 0.2s ease;
background: var(--surface-card);
box-shadow: var(--shadow-card);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition:
transform 0.22s var(--ease-out),
border-color 0.22s ease,
box-shadow 0.22s ease,
background 0.22s ease;
position: relative;
overflow: hidden;
}
.home-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--grad-accent);
opacity: 0;
transition: opacity 0.25s ease;
}
.home-card::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
circle at 30% 0%,
rgba(var(--card-accent-rgb, 0, 212, 255), 0.08),
transparent 60%
);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.home-card:hover {
transform: translateY(-4px);
border-color: rgba(255, 255, 255, 0.22);
border-color: rgba(0, 212, 255, 0.2);
box-shadow:
var(--shadow-md),
0 0 0 1px rgba(0, 212, 255, 0.08);
}
.home-card:hover::before {
opacity: 0.6;
}
.home-card:hover::after {
opacity: 1;
}
.home-card__icon {
width: 38px;
height: 38px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 212, 255, 0.08);
border: 1px solid rgba(0, 212, 255, 0.15);
flex-shrink: 0;
transition: transform 0.22s var(--ease-spring);
color: var(--neon-cyan);
}
.home-card:hover .home-card__icon {
transform: scale(1.1) rotate(-4deg);
}
.home-card__body {
flex: 1;
}
.home-card__title {
font-weight: 600;
font-size: 18px;
margin: 0 0 8px;
font-weight: 700;
font-size: 15px;
margin: 0 0 5px;
color: var(--text-bright);
letter-spacing: -0.01em;
}
.home-card__desc {
margin: 0;
color: var(--muted);
font-size: 14px;
color: var(--text-dim);
font-size: 12px;
line-height: 1.6;
}
.home-card__cta {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
.home-card__arrow {
font-size: 16px;
color: var(--neon-cyan);
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.22s ease, transform 0.22s ease;
align-self: flex-end;
}
.home-card:hover .home-card__arrow {
opacity: 1;
transform: translateX(0);
}
/* ── Blog Posts ──────────────────────────────────────────────────────── */
.home-posts {
display: grid;
gap: 12px;
gap: 8px;
}
.home-post {
@@ -167,46 +306,300 @@
color: inherit;
border: 1px solid var(--line);
padding: 16px 18px;
border-radius: 16px;
background: var(--surface);
border-radius: var(--radius-md);
background: var(--surface-card);
display: grid;
gap: 8px;
transition: border-color 0.2s ease;
grid-template-columns: auto 1fr auto;
align-items: start;
gap: 14px;
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
box-shadow: var(--shadow-card);
}
.home-post:hover {
border-color: rgba(255, 255, 255, 0.25);
border-color: rgba(192, 132, 252, 0.25);
background: var(--surface-raised);
transform: translateX(4px);
}
.home-post__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--neon-purple);
box-shadow: 0 0 6px var(--neon-purple);
margin-top: 7px;
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.home-post:hover .home-post__dot {
opacity: 1;
}
.home-post__content {
display: grid;
gap: 5px;
}
.home-post__title {
margin: 0;
font-weight: 600;
font-size: 18px;
font-size: 15px;
color: var(--text-bright);
letter-spacing: -0.01em;
}
.home-post__excerpt {
margin: 0;
color: var(--muted);
color: var(--text-dim);
font-size: 12px;
line-height: 1.6;
}
.home-post__meta {
font-size: 12px;
color: var(--accent);
font-size: 11px;
color: var(--neon-purple-dim);
text-transform: uppercase;
letter-spacing: 0.14em;
letter-spacing: 0.12em;
white-space: nowrap;
padding-top: 4px;
}
/* ── TODO Board ──────────────────────────────────────────────────────── */
.home-todo-wrapper {
position: relative;
}
.home-todo-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--line-bright);
background: var(--surface-raised);
color: var(--text-bright);
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease, border-color 0.2s ease;
box-shadow: var(--shadow-md);
}
.home-todo-nav:hover {
background: var(--bg-tertiary);
border-color: var(--neon-cyan);
}
.home-todo-nav--left { left: -16px; }
.home-todo-nav--right { right: -16px; }
.home-todo-board {
display: flex;
gap: 12px;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
padding-bottom: 4px;
}
.home-todo-board::-webkit-scrollbar {
height: 4px;
}
.home-todo-board::-webkit-scrollbar-track {
background: transparent;
}
.home-todo-board::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: 2px;
}
.home-todo-col {
flex: 1 0 260px;
min-width: 0;
max-width: 340px;
scroll-snap-align: start;
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-card);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.home-todo-col__head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.02);
flex-shrink: 0;
}
.home-todo-col__dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.home-todo-col__label {
font-size: 12px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: 0.04em;
text-transform: uppercase;
font-family: var(--font-display);
flex: 1;
}
.home-todo-col__count {
font-size: 11px;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--line);
border-radius: 999px;
padding: 1px 7px;
font-family: var(--font-display);
}
.home-todo-col__body {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
max-height: calc(40vh);
min-height: 60px;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
}
.home-todo-col__body::-webkit-scrollbar {
width: 3px;
}
.home-todo-col__body::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: 2px;
}
.home-todo-col__empty {
margin: auto;
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 16px 0;
}
.home-todo-card {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 11px 13px;
background: rgba(255, 255, 255, 0.02);
display: grid;
gap: 4px;
transition: border-color 0.2s ease, background 0.2s ease;
}
.home-todo-card:hover {
border-color: rgba(0, 212, 255, 0.18);
background: rgba(0, 212, 255, 0.03);
}
.home-todo-card__title {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: -0.01em;
line-height: 1.4;
}
.home-todo-card__desc {
margin: 0;
font-size: 11px;
color: var(--text-dim);
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.home-todo-card__date {
margin: 2px 0 0;
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.home-todo-footer {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.home-todo-footer__link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #34d399;
text-decoration: none;
padding: 6px 0;
transition: opacity 0.2s ease;
font-weight: 500;
}
.home-todo-footer__link:hover {
opacity: 0.75;
}
/* ── Profile ─────────────────────────────────────────────────────────── */
.home-profile {
display: grid;
}
.home-profile__card {
border: 1px solid var(--line);
border-radius: 22px;
padding: 22px;
background: var(--surface);
border-radius: var(--radius-lg);
padding: 24px;
background: var(--surface-card);
display: grid;
gap: 16px;
gap: 18px;
box-shadow: var(--shadow-card);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
position: relative;
overflow: hidden;
}
.home-profile__card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--grad-accent);
opacity: 0.3;
}
.home-profile__identity {
@@ -216,31 +609,39 @@
}
.home-profile__avatar {
width: 52px;
height: 52px;
width: 56px;
height: 56px;
border-radius: 16px;
object-fit: cover;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
box-shadow:
0 0 0 1px rgba(0, 212, 255, 0.2),
0 0 12px rgba(0, 212, 255, 0.1),
0 4px 16px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.home-profile__role {
margin: 0;
font-size: 12px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
letter-spacing: 0.22em;
color: var(--neon-cyan);
font-family: var(--font-display);
}
.home-profile__name {
margin: 6px 0 0;
font-weight: 600;
margin: 4px 0 0;
font-weight: 700;
font-size: 18px;
color: var(--text-bright);
letter-spacing: -0.02em;
}
.home-profile__bio {
margin: 0;
color: var(--muted);
line-height: 1.6;
color: var(--text-dim);
line-height: 1.75;
font-size: 13px;
}
.home-profile__timeline {
@@ -250,10 +651,11 @@
.home-profile__section-title {
margin: 0;
font-size: 12px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--accent);
letter-spacing: 0.24em;
color: var(--neon-cyan);
font-family: var(--font-display);
}
.home-profile__timeline ul {
@@ -261,87 +663,137 @@
margin: 0;
padding: 0;
display: grid;
gap: 10px;
gap: 6px;
}
.home-profile__timeline li {
display: grid;
gap: 4px;
gap: 2px;
padding: 12px 14px;
border-radius: 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
background: rgba(255, 255, 255, 0.02);
transition: border-color 0.2s ease, background 0.2s ease;
}
.home-profile__timeline span {
font-size: 12px;
color: var(--muted);
.home-profile__timeline li:hover {
border-color: rgba(0, 212, 255, 0.15);
background: rgba(0, 212, 255, 0.03);
}
.timeline-period {
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.home-profile__timeline strong {
font-size: 15px;
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
}
.home-profile__timeline span:not(.timeline-period) {
font-size: 12px;
color: var(--text-dim);
}
.home-profile__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 6px;
}
.home-profile__tags span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
color: var(--muted);
padding: 4px 10px;
font-size: 11px;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.02);
transition: border-color 0.15s ease, color 0.15s ease;
}
.home-profile__tags span:hover {
border-color: rgba(0, 212, 255, 0.2);
color: var(--neon-cyan);
}
.home-profile__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 8px;
}
@media (max-width: 900px) {
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 1024px) {
.home-hero {
grid-template-columns: 1fr;
}
.home-hero__card {
max-width: 480px;
}
}
@media (max-width: 768px) {
.home {
gap: 24px;
}
.home-todo-col {
flex: 0 0 80vw;
max-width: 80vw;
}
.home-todo-col__body {
max-height: 30vh;
}
.home-todo-nav {
display: none;
}
.home-hero h1 {
font-size: clamp(24px, 6vw, 36px);
font-size: clamp(22px, 6vw, 32px);
}
.home-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.home-card {
padding: 14px;
gap: 12px;
}
.home-card__title {
font-size: 16px;
}
.home-card__desc {
font-size: 13px;
}
.home-posts {
gap: 10px;
}
.home-card__icon {
width: 34px;
height: 34px;
}
.home-card__title {
font-size: 13px;
}
.home-card__desc {
font-size: 11px;
}
.home-post {
padding: 14px 16px;
padding: 12px 14px;
grid-template-columns: auto 1fr;
gap: 10px;
}
.home-post__meta {
grid-column: 2;
}
.home-post__title {
font-size: 16px;
font-size: 14px;
}
.home-profile__card {
@@ -352,7 +804,26 @@
font-size: 16px;
}
.home-profile__bio {
font-size: 14px;
.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;
}
}

View File

@@ -1,20 +1,61 @@
import React 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 = [
{ id: 'todo', label: '계획', color: 'var(--neon-purple)' },
{ id: 'in_progress', label: '진행 중', color: '#f59e0b' },
{ id: 'done', label: '완료', color: '#34d399' },
];
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: [] });
const [portfolio, setPortfolio] = useState(null);
useEffect(() => {
fetch('/api/profile/public')
.then(r => r.ok ? r.json() : null)
.catch(() => null)
.then(d => setPortfolio(d));
}, []);
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;
return (
<div className="home">
<section className="home-hero">
<div className="home-hero__text">
<p className="home-hero__kicker">Personal Archive</p>
<h1>기록을 모으고, 이야기를 이어붙이는 작은 .</h1>
<h1>기록을 모으고,<br />이야기를 이어붙이는 작은 .</h1>
<p className="home-hero__lead">
개발, 여행 스냅, 그리고 생각을 모아두는 공간입니다.
</p>
@@ -28,22 +69,23 @@ const Home = () => {
</div>
</div>
<div className="home-hero__card">
<p className="home-hero__card-title">이번 집중 테마</p>
<p className="home-hero__card-eyebrow">이번 집중 테마</p>
<div className="home-hero__card-body">
<h2>느린 기록, 깊은 회고</h2>
<p>
빠르게 업데이트하는 대신, 번쯤 되돌아보며 기록하는 목표로
합니다. 글은 매주 편씩 추가될 예정이에요.
</p>
<h2>{theme.theme}</h2>
<p>{theme.desc}</p>
</div>
<div className="home-hero__stats">
<div>
<p className="stat-label">게시 </p>
<p className="stat-value">{posts.length}</p>
<div className="home-hero__stat">
<p className="stat-label">전체 태스크</p>
<p className="stat-value">
{totalTasks}<span className="stat-unit"></span>
</p>
</div>
<div>
<p className="stat-label">다음 업데이트</p>
<p className="stat-value">이번 주말</p>
<div className="home-hero__stat">
<p className="stat-label">진행 / 완료</p>
<p className="stat-value stat-value--sm">
{inProgress}<span className="stat-unit"> / </span>{doneTasks}
</p>
</div>
</div>
</div>
@@ -56,12 +98,23 @@ const Home = () => {
</div>
<div className="home-grid">
{highlights.map((item) => (
<Link key={item.id} to={item.path} className="home-card">
<div>
<Link
key={item.id}
to={item.path}
className="home-card"
style={{ '--card-accent': item.accent }}
>
<div
className="home-card__icon"
style={{ color: item.accent }}
>
{item.icon}
</div>
<div className="home-card__body">
<p className="home-card__title">{item.label}</p>
<p className="home-card__desc">{item.description}</p>
</div>
<span className="home-card__cta">열기</span>
<span className="home-card__arrow"></span>
</Link>
))}
</div>
@@ -75,14 +128,98 @@ const Home = () => {
<div className="home-posts">
{posts.map((post) => (
<Link key={post.slug} to="/blog" className="home-post">
<p className="home-post__title">{post.title}</p>
<p className="home-post__excerpt">{post.excerpt}</p>
<div className="home-post__dot" />
<div className="home-post__content">
<p className="home-post__title">{post.title}</p>
<p className="home-post__excerpt">{post.excerpt}</p>
</div>
<span className="home-post__meta">{post.date || '작성일 미정'}</span>
</Link>
))}
</div>
</section>
{/* ── TODO 보드 ──────────────────────────────────────────── */}
<section className="home-section">
<div className="home-section__header">
<h2>TODO</h2>
<p>계획 · 진행 · 완료 태스크를 한눈에 확인합니다.</p>
</div>
<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">
<div className="home-section__header">
<h2>Profile</h2>
@@ -93,52 +230,30 @@ const Home = () => {
<div className="home-profile__identity">
<img
className="home-profile__avatar"
src={myPhoto}
src={portfolio?.profile?.photo_url || myPhoto}
alt="Profile"
/>
<div>
<p className="home-profile__role">Server Developer</p>
<p className="home-profile__name"> </p>
<p className="home-profile__role">{portfolio?.profile?.role || 'Server Developer'}</p>
<p className="home-profile__name">{portfolio?.profile?.name || '박 재 오'}</p>
</div>
</div>
<p className="home-profile__bio">
주변 동료와 함께 소통하며 성장하는걸 좋아합니다. <br />
성능 최적화, 인프라 자동화를 중요하게 생각합니다. <br />
여행과 사진, 새로운 기술 탐구를 좋아합니다.
{portfolio?.profile?.bio || '주변 동료와 함께 소통하며 성장하는걸 좋아합니다.'}
</p>
<div className="home-profile__timeline">
<p className="home-profile__section-title">연혁</p>
<ul>
<li>
<span>2023.02 - 현재</span>
<strong>Server Developer</strong>
<span>내비 TIS 교통 서버/현대오토에버</span>
</li>
<li>
<span>2020.01 - 2023.02</span>
<strong>Embedded Device SW Developer</strong>
<span>캐시비 단말기 개발/롯데정보통신</span>
</li>
<li>
<span>2019.07 - 2019.12</span>
<strong>SSAFY - 삼성 SW Academy</strong>
<span>SSAFY</span>
</li>
</ul>
</div>
<div className="home-profile__tags">
<span>C++</span>
<span>Git</span>
<span>AWS</span>
<span>Jira</span>
<span>MySQL</span>
<span>Docker</span>
<span>Kubernetes</span>
<span>Linux</span>
{(portfolio?.skills || []).slice(0, 8).map((s) => (
<span key={s.id || s.name}>{s.name}</span>
))}
{!portfolio && ['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
<div className="home-profile__actions">
<button className="button ghost">프로필 수정</button>
<a className="button primary" href="mailto:bgg8988@gmail.com">
<Link className="button ghost" to="/portfolio">
포트폴리오 보기
</Link>
<a className="button primary" href={`mailto:${portfolio?.profile?.email || 'bgg8988@gmail.com'}`}>
연락하기
</a>
</div>
@@ -149,4 +264,99 @@ const Home = () => {
);
};
/* ── TodoBoard ──────────────────────────────────────────────────────── */
const TodoBoard = ({ todosByStatus }) => {
const boardRef = useRef(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkScroll = () => {
const el = boardRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 4);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
};
useEffect(() => {
checkScroll();
const el = boardRef.current;
if (!el) return;
el.addEventListener('scroll', checkScroll, { passive: true });
const ro = new ResizeObserver(checkScroll);
ro.observe(el);
return () => { el.removeEventListener('scroll', checkScroll); ro.disconnect(); };
}, [todosByStatus]);
const scroll = (dir) => {
const el = boardRef.current;
if (!el) return;
el.scrollBy({ left: dir * 280, behavior: 'smooth' });
};
const isEmpty = TODO_COLUMNS.every((col) => todosByStatus[col.id].length === 0);
return (
<div className="home-todo-wrapper">
{canScrollLeft && (
<button
className="home-todo-nav home-todo-nav--left"
onClick={() => scroll(-1)}
aria-label="왼쪽으로"
></button>
)}
{canScrollRight && (
<button
className="home-todo-nav home-todo-nav--right"
onClick={() => scroll(1)}
aria-label="오른쪽으로"
></button>
)}
<div className="home-todo-board" ref={boardRef}>
{TODO_COLUMNS.map((col) => {
const items = todosByStatus[col.id] ?? [];
return (
<div key={col.id} className="home-todo-col">
<div className="home-todo-col__head">
<span
className="home-todo-col__dot"
style={{ background: col.color, boxShadow: `0 0 6px ${col.color}` }}
/>
<span className="home-todo-col__label">{col.label}</span>
<span className="home-todo-col__count">{items.length}</span>
</div>
<div className="home-todo-col__body">
{items.length === 0 ? (
<p className="home-todo-col__empty">태스크가 없습니다.</p>
) : (
items.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>
</div>
);
})}
</div>
<div className="home-todo-footer">
<Link to="/todo" className="home-todo-footer__link">
Todo 보드 열기
</Link>
</div>
</div>
);
};
export default Home;

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 12px;
color: var(--accent);
color: var(--accent-lotto);
margin: 0 0 10px;
}
@@ -63,10 +63,11 @@
.lotto-panel {
border: 1px solid var(--line);
background: var(--surface);
border-radius: 24px;
border-radius: var(--radius-lg);
padding: 20px;
display: grid;
gap: 16px;
box-shadow: var(--shadow-sm), var(--shadow-inset);
}
.lotto-panel--wide .lotto-chart {
@@ -94,7 +95,7 @@
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--accent);
color: var(--accent-lotto);
}
.lotto-panel__sub {
@@ -213,7 +214,8 @@
}
.lotto-field input:focus {
border-color: rgba(247, 168, 165, 0.6);
border-color: rgba(52, 211, 153, 0.6);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
}
.lotto-result {
@@ -732,14 +734,411 @@
font-weight: 600;
}
/* ── 신뢰도 배너 ─────────────────────────────────────────────────────────── */
.lotto-perf-banner {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 14px 20px;
background: rgba(151, 201, 170, 0.06);
border-color: rgba(151, 201, 170, 0.25);
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.lotto-perf-banner__label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: rgba(151, 201, 170, 0.85);
flex-shrink: 0;
}
.lotto-perf-banner__items {
display: flex;
align-items: center;
gap: 0;
flex-wrap: wrap;
flex: 1;
}
.lotto-perf-banner__item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 16px;
}
.lotto-perf-banner__divider {
width: 1px;
height: 32px;
background: var(--line);
flex-shrink: 0;
}
.lotto-perf-banner__val {
font-size: 18px;
font-weight: 700;
line-height: 1;
}
.lotto-perf-banner__val.is-pos { color: #97c9aa; }
.lotto-perf-banner__val.is-neg { color: #f7a8a5; }
.lotto-perf-banner__val.is-prize { color: #fdd4b1; }
.lotto-perf-banner__lbl {
font-size: 11px;
color: var(--muted);
letter-spacing: 0.04em;
}
/* ── 공략 리포트 ─────────────────────────────────────────────────────────── */
.lotto-report-history {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.lotto-report-top {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 20px;
}
.lotto-report-confidence {
display: flex;
align-items: flex-start;
gap: 16px;
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.02);
}
.lotto-confidence-ring {
flex-shrink: 0;
}
.lotto-report-confidence__title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
}
.lotto-report-confidence__factors {
display: grid;
gap: 7px;
}
.lotto-report-confidence__factor {
display: grid;
grid-template-columns: 90px minmax(0, 1fr) 28px;
align-items: center;
gap: 8px;
font-size: 11px;
}
.lotto-report-confidence__factor-lbl {
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lotto-report-confidence__factor-val {
text-align: right;
color: var(--muted);
font-weight: 600;
}
.lotto-report-pattern {
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.02);
}
.lotto-report-pattern__title {
margin: 0 0 12px;
font-size: 13px;
font-weight: 600;
}
.lotto-report-pattern__stats {
display: grid;
gap: 10px;
}
.lotto-report-pattern__stat {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--muted);
}
.lotto-report-pattern__stat strong {
color: var(--text);
font-size: 15px;
font-weight: 700;
}
/* 전략 카드 */
.lotto-strategy-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.lotto-strategy-card {
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
background: rgba(133, 165, 216, 0.05);
display: grid;
gap: 10px;
}
.lotto-strategy-card__name {
margin: 0;
font-size: 13px;
font-weight: 600;
color: rgba(133, 165, 216, 0.9);
}
.lotto-strategy-card__desc {
margin: 0;
font-size: 11px;
color: var(--muted);
line-height: 1.5;
}
/* ── 개인 패턴 분석 ──────────────────────────────────────────────────────── */
.lotto-personal-tendency {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.lotto-personal-tendency__badge {
font-size: 11px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(253, 212, 177, 0.4);
background: rgba(253, 212, 177, 0.1);
color: #fdd4b1;
letter-spacing: 0.04em;
}
/* ── 구매 기록 ───────────────────────────────────────────────────────────── */
.lotto-purchase-stats {
display: flex;
flex-wrap: wrap;
gap: 0;
border: 1px solid var(--line);
border-radius: 16px;
overflow: hidden;
}
.lotto-purchase-stat {
display: flex;
flex-direction: column;
gap: 3px;
padding: 14px 18px;
border-right: 1px solid var(--line);
flex: 1;
min-width: 100px;
}
.lotto-purchase-stat:last-child {
border-right: none;
}
.lotto-purchase-stat__val {
font-size: 16px;
font-weight: 700;
line-height: 1;
}
.lotto-purchase-stat__val.is-pos { color: #97c9aa; }
.lotto-purchase-stat__val.is-neg { color: #f7a8a5; }
.lotto-purchase-stat__val.is-prize { color: #fdd4b1; }
.lotto-purchase-stat__lbl {
font-size: 11px;
color: var(--muted);
}
/* 구매 폼 */
.lotto-purchase-form {
border: 1px solid var(--line);
border-radius: 16px;
padding: 18px;
background: rgba(255, 255, 255, 0.02);
display: grid;
gap: 14px;
}
.lotto-purchase-form__title {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.lotto-purchase-form__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 10px;
}
.lotto-purchase-form__note {
grid-column: span 2;
}
.lotto-purchase-form__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* 구매 목록 */
.lotto-purchase-list {
display: grid;
gap: 0;
border: 1px solid var(--line);
border-radius: 16px;
overflow: hidden;
}
.lotto-purchase-list__head {
display: grid;
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
gap: 8px;
padding: 10px 14px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--line);
}
.lotto-purchase-row {
display: grid;
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
gap: 8px;
align-items: center;
padding: 12px 14px;
font-size: 13px;
border-bottom: 1px solid var(--line);
transition: background 0.15s ease;
}
.lotto-purchase-row:last-child {
border-bottom: none;
}
.lotto-purchase-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.lotto-purchase-row__drw {
font-weight: 600;
}
.lotto-purchase-row__note {
color: var(--muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lotto-purchase-row__actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.lotto-purchase-row__hits {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
overflow: hidden;
}
.hit-badge { display: inline-block; min-width: 16px; padding: 1px 4px; margin-right: 2px;
font-size: 10px; border-radius: 4px; background: rgba(255,255,255,0.06); text-align: center; }
.hit-badge.hit-3 { background: rgba(80, 200, 120, 0.2); color: #76e09a; }
.hit-badge.hit-4 { background: rgba(255, 200, 80, 0.25); color: #ffce6e; font-weight: 700; }
.hit-badge.hit-5, .hit-badge.hit-6 { background: rgba(255, 100, 130, 0.3); color: #ff8aa0; font-weight: 700; }
.prize-flag { font-size: 10px; color: #ff8aa0; margin-left: 6px; }
.is-pos { color: #97c9aa; }
.is-neg { color: #f7a8a5; }
.is-prize { color: #fdd4b1; }
/* ── 반응형 ─────────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.lotto-header {
grid-template-columns: 1fr;
@media (max-width: 480px) {
.lotto-purchase-stats {
flex-direction: column;
}
.lotto-history__item {
.lotto-purchase-stat {
border-right: none;
border-bottom: 1px solid var(--line);
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
}
.lotto-purchase-stat:last-child {
border-bottom: none;
}
.lotto-purchase-list__head,
.lotto-purchase-row {
grid-template-columns: 56px minmax(0, 1fr) auto;
gap: 8px;
}
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+6),
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+6) {
display: none;
}
.lotto-purchase-form__note {
grid-column: span 1;
}
.lotto-perf-banner {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.lotto-perf-banner__items {
width: 100%;
}
.lotto-perf-banner__item {
padding: 0 10px;
}
}
@media (max-width: 768px) {
.lotto-header {
grid-template-columns: 1fr;
}
@@ -752,9 +1151,21 @@
grid-template-columns: 24px minmax(0, 1fr) auto;
gap: 8px;
}
}
@media (max-width: 768px) {
.lotto-report-top {
grid-template-columns: 1fr;
}
.lotto-purchase-list__head,
.lotto-purchase-row {
grid-template-columns: 56px 90px 90px minmax(0, 120px) 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);
}
@@ -779,9 +1190,9 @@
}
.lotto-ball {
width: 36px;
height: 36px;
font-size: 14px;
width: 32px;
height: 32px;
font-size: 13px;
}
.lotto-meta__title {
@@ -789,7 +1200,355 @@
}
.lotto-history__item {
grid-template-columns: 1fr;
padding: 14px;
gap: 12px;
}
}
/* ═══════════════════════════════════════════════════════
종합 추론 패널
═══════════════════════════════════════════════════════ */
.lotto-combined {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 기법별 추천 행 */
.lotto-combined__methods {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--line, rgba(255,255,255,0.08));
border-radius: 14px;
}
.lotto-combined__method {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.lotto-combined__method-head {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 200px;
}
.lotto-combined__method-icon {
font-size: 18px;
flex-shrink: 0;
margin-top: 2px;
}
.lotto-combined__method-name {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.lotto-combined__method-weight {
font-size: 11px;
opacity: 0.6;
font-weight: 400;
}
.lotto-combined__method-desc {
margin: 2px 0 0;
font-size: 11px;
color: var(--text-muted, rgba(255,255,255,0.45));
}
.lotto-combined__method-nums {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
/* 최종 결과 */
.lotto-combined__final {
padding: 20px;
background: rgba(129, 140, 248, 0.06);
border: 1px solid rgba(129, 140, 248, 0.25);
border-radius: 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
.lotto-combined__final-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.lotto-combined__final-badge {
font-size: 11px;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
color: #818cf8;
background: rgba(129, 140, 248, 0.15);
border: 1px solid rgba(129, 140, 248, 0.3);
border-radius: 20px;
padding: 3px 10px;
}
.lotto-combined__final-balls {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.lotto-combined__final-ball-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.lotto-combined__final-ball-wrap .lotto-ball {
width: 52px;
height: 52px;
font-size: 18px;
}
.lotto-combined__vote-dots {
display: flex;
gap: 3px;
}
.lotto-combined__vote-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
transition: background 0.2s;
}
.lotto-combined__vote-dot.is-on {
background: #818cf8;
}
.lotto-combined__final-sub {
margin: 0;
font-size: 11px;
color: var(--text-muted, rgba(255,255,255,0.4));
}
/* 볼 상태 */
.lotto-ball.is-dim {
opacity: 0.35;
transform: scale(0.92);
}
.lotto-ball.is-final {
opacity: 1;
box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.5);
}
/* 점수 바 */
.lotto-combined__scores {
display: flex;
flex-direction: column;
gap: 8px;
}
.lotto-combined__scores-title {
margin: 0 0 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted, rgba(255,255,255,0.5));
text-transform: uppercase;
letter-spacing: .05em;
}
.lotto-combined__score-row {
display: flex;
align-items: center;
gap: 8px;
}
.lotto-combined__score-label {
font-size: 12px;
color: var(--text-muted, rgba(255,255,255,0.5));
width: 72px;
flex-shrink: 0;
}
.lotto-combined__score-weight {
font-size: 11px;
color: var(--text-muted, rgba(255,255,255,0.35));
width: 28px;
flex-shrink: 0;
text-align: right;
}
.lotto-combined__score-bar-wrap {
flex: 1;
height: 6px;
background: rgba(255,255,255,0.06);
border-radius: 3px;
overflow: hidden;
}
.lotto-combined__score-bar {
height: 100%;
border-radius: 3px;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.lotto-combined__score-val {
font-size: 12px;
font-weight: 600;
color: var(--text-bright, #fff);
width: 28px;
text-align: right;
flex-shrink: 0;
}
.lotto-combined__score-total {
margin-top: 6px;
font-size: 13px;
color: var(--text-muted, rgba(255,255,255,0.5));
text-align: right;
}
.lotto-combined__score-total strong {
color: #818cf8;
font-size: 16px;
}
.lotto-combined__disclaimer {
margin: 0;
font-size: 11px;
color: var(--text-muted, rgba(255,255,255,0.35));
}
/* 이력 */
.lotto-combined__history {
border-top: 1px solid var(--line, rgba(255,255,255,0.08));
padding-top: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.lotto-combined__history-title {
margin: 0 0 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted, rgba(255,255,255,0.45));
text-transform: uppercase;
letter-spacing: .05em;
}
.lotto-combined__history-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: rgba(255,255,255,0.02);
border: 1px solid var(--line, rgba(255,255,255,0.06));
border-radius: 10px;
flex-wrap: wrap;
}
.lotto-combined__history-meta {
display: flex;
gap: 8px;
font-size: 11px;
color: var(--text-muted, rgba(255,255,255,0.4));
flex-shrink: 0;
}
@media (max-width: 480px) {
.lotto-combined__method {
flex-direction: column;
align-items: flex-start;
}
.lotto-combined__final-ball-wrap .lotto-ball {
width: 42px;
height: 42px;
font-size: 15px;
}
.lotto-combined__final-balls {
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;
}
}
.lotto-section-fold { margin-bottom: 14px; }
.lotto-section-fold > summary { cursor: pointer; padding: 12px 16px; background: rgba(255,255,255,0.03);
border-radius: 10px; font-weight: 600; font-size: 14px; opacity: 0.85; }
.lotto-section-fold[open] > summary { margin-bottom: 12px; opacity: 1; }
.trend-chart { display: block; margin: 0 auto; }
.trend-legend { display: flex; gap: 16px; justify-content: center; font-size: 11px; opacity: 0.7; margin-top: 8px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
.dot--curator { background: #b8a8ff; }
.dot--user { background: #76e09a; }

View File

@@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { ballClass, NumberRow, METHOD_META, METHOD_ORDER, SCORE_META, fmtKST } from '../lottoUtils';
const CombinedRecommendPanel = ({ combined, history, loading, histLoading, onRun, onCopy }) => {
const [histExpand, setHistExpand] = useState(false);
return (
<section className="lotto-panel lotto-panel--wide lotto-combined">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">AI · 종합 추론</p>
<h3>종합 추론 번호 추천</h3>
<p className="lotto-panel__sub">
5가지 통계 기법(빈도·지문··공동출현·다양성) 가중 투표로 합산해
최적 6 번호를 도출합니다.
</p>
</div>
<div className="lotto-panel__actions">
{loading && <span className="lotto-chip">분석 </span>}
<button className="button primary small" onClick={onRun} disabled={loading}>
{loading ? '추론 중…' : '🔮 종합 추론 실행'}
</button>
{history.length > 0 && (
<button className="button ghost small" onClick={() => setHistExpand(p => !p)}>
이력 {history.length} {histExpand ? '▲' : '▼'}
</button>
)}
</div>
</div>
{!combined && !loading && (
<p className="lotto-empty">버튼을 눌러 종합 추론을 실행하세요.</p>
)}
{combined && (
<>
{/* 기법별 추천 번호 */}
<div className="lotto-combined__methods">
{METHOD_ORDER.map((key) => {
const meta = METHOD_META[key];
const m = combined.methods?.[key];
if (!m) return null;
return (
<div key={key} className="lotto-combined__method">
<div className="lotto-combined__method-head">
<span className="lotto-combined__method-icon">{meta.icon}</span>
<div>
<p className="lotto-combined__method-name" style={{ color: meta.color }}>
{meta.label}
<span className="lotto-combined__method-weight"> ({m.weight_pct}%)</span>
</p>
<p className="lotto-combined__method-desc">{meta.desc}</p>
</div>
</div>
<div className="lotto-combined__method-nums">
{m.numbers.map((n) => {
const inFinal = combined.final_numbers.includes(n);
return (
<span
key={n}
className={`lotto-ball ${ballClass(n).replace('lotto-ball ', '')} ${inFinal ? 'is-final' : 'is-dim'}`}
>
{n}
</span>
);
})}
</div>
</div>
);
})}
</div>
{/* 최종 추론 결과 */}
<div className="lotto-combined__final">
<div className="lotto-combined__final-head">
<span className="lotto-combined__final-badge">종합 추론 결과</span>
{combined.deduped && (
<span className="lotto-chip lotto-chip--muted">중복 (이미 저장됨)</span>
)}
<button className="button ghost small" onClick={() => onCopy(combined.final_numbers)}>
복사
</button>
</div>
<div className="lotto-combined__final-balls">
{combined.final_numbers.map((n) => {
const votes = combined.vote_counts?.[String(n)] ?? 0;
return (
<div key={n} className="lotto-combined__final-ball-wrap">
<span className={ballClass(n)}>{n}</span>
<span className="lotto-combined__vote-dots">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={`lotto-combined__vote-dot ${i < votes ? 'is-on' : ''}`} />
))}
</span>
</div>
);
})}
</div>
<p className="lotto-combined__final-sub">
점은 해당 번호가 채택된 기법 (최대 5)
</p>
</div>
{/* 점수 바 */}
<div className="lotto-combined__scores">
<p className="lotto-combined__scores-title">조합 품질 점수</p>
{SCORE_META.map(({ key, label, color, weight }) => {
const val = combined.scores?.[key] ?? 0;
const pct = Math.round(val * 100);
return (
<div key={key} className="lotto-combined__score-row">
<span className="lotto-combined__score-label">{label}</span>
<span className="lotto-combined__score-weight">{weight}%</span>
<div className="lotto-combined__score-bar-wrap">
<div
className="lotto-combined__score-bar"
style={{ width: `${pct}%`, background: color }}
/>
</div>
<span className="lotto-combined__score-val">{pct}</span>
</div>
);
})}
<div className="lotto-combined__score-total">
종합 점수 <strong>{Math.round((combined.scores?.score_total ?? 0) * 100)}</strong> / 100
</div>
</div>
<p className="lotto-combined__disclaimer">
추천은 역대 통계 패턴 기반 참고 자료이며, 당첨을 보장하지 않습니다.
</p>
</>
)}
{/* 추천 이력 */}
{histExpand && (
<div className="lotto-combined__history">
<p className="lotto-combined__history-title">종합 추론 이력</p>
{histLoading && <p className="lotto-empty">로딩 </p>}
{history.map((item) => (
<div key={item.id} className="lotto-combined__history-item">
<div className="lotto-combined__history-meta">
<span>#{item.id}</span>
<span>{fmtKST(item.created_at)}</span>
<span>기준 {item.based_on_draw ?? '-'}</span>
</div>
<NumberRow nums={item.numbers} />
<button className="button ghost small" onClick={() => onCopy(item.numbers)}>복사</button>
</div>
))}
</div>
)}
</section>
);
};
export default CombinedRecommendPanel;

View File

@@ -0,0 +1,25 @@
import React from 'react';
const ConfidenceRing = ({ score }) => {
const r = 28, c = 2 * Math.PI * r;
const fill = (score / 100) * c;
const color = score >= 80 ? '#97c9aa' : score >= 60 ? '#fdd4b1' : '#f7a8a5';
return (
<svg width="72" height="72" viewBox="0 0 72 72" className="lotto-confidence-ring" aria-hidden>
<circle cx="36" cy="36" r={r} stroke="rgba(255,255,255,0.08)" strokeWidth="6" fill="none" />
<circle
cx="36" cy="36" r={r}
stroke={color} strokeWidth="6" fill="none"
strokeDasharray={`${fill} ${c - fill}`}
strokeLinecap="round"
transform="rotate(-90 36 36)"
/>
<text x="36" y="41" textAnchor="middle" fill={color} fontSize="16" fontWeight="600"
style={{ fontFamily: 'inherit' }}>
{score}
</text>
</svg>
);
};
export default ConfidenceRing;

View File

@@ -0,0 +1,39 @@
import React, { useMemo } from 'react';
import { buildFrequencySeries } from '../lottoUtils';
const FrequencyChart = ({ stats }) => {
const { series, max } = useMemo(() => buildFrequencySeries(stats?.frequency), [stats]);
const ticks = useMemo(() => [max, Math.round(max * 0.5), 0], [max]);
if (!stats) return null;
return (
<div className="lotto-chart">
<div className="lotto-chart__y">
<span>횟수</span>
<div className="lotto-chart__ticks">
{ticks.map((value) => <span key={value}>{value}</span>)}
</div>
</div>
<div className="lotto-chart__plot" role="list">
{series.map((item) => {
const showLabel = item.number === 1 || item.number % 5 === 0;
return (
<div key={item.number} className="lotto-chart__col" role="listitem">
<span
className="lotto-chart__bar"
style={{ height: `${(item.count / max) * 100}%` }}
title={`${item.number}번: ${item.count}`}
aria-label={`${item.number}${item.count}`}
/>
<span className="lotto-chart__x" aria-hidden={!showLabel}>
{showLabel ? item.number : ''}
</span>
</div>
);
})}
</div>
</div>
);
};
export default FrequencyChart;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { toBucketEntries } from '../lottoUtils';
const MetricBlock = ({ title, metrics }) => {
if (!metrics) return null;
const buckets = toBucketEntries(metrics);
const maxBucket = buckets.length ? Math.max(...buckets.map(([, v]) => Number(v) || 0), 1) : 1;
const odd = Number(metrics.odd) || 0;
const even = Number(metrics.even) || 0;
const totalOE = odd + even || 1;
const oddPct = (odd / totalOE) * 100;
return (
<div className="lotto-metrics">
<div className="lotto-metrics__head">
<p className="lotto-metrics__title">{title}</p>
<span className="lotto-metrics__sum"> 출현 횟수 {metrics.sum ?? '-'}</span>
</div>
<div className="lotto-metric-cards">
<div className="lotto-metric-card">
<p className="lotto-metric-card__label">최소 출현</p>
<p className="lotto-metric-card__value">{metrics.min ?? '-'}</p>
</div>
<div className="lotto-metric-card">
<p className="lotto-metric-card__label">최대 출현</p>
<p className="lotto-metric-card__value">{metrics.max ?? '-'}</p>
</div>
<div className="lotto-metric-card">
<p className="lotto-metric-card__label">출현 편차</p>
<p className="lotto-metric-card__value">{metrics.range ?? '-'}</p>
</div>
</div>
<div className="lotto-odd-even">
<div className="lotto-odd-even__labels">
<span> {odd}</span><span> {even}</span>
</div>
<div className="lotto-odd-even__bar" aria-hidden>
<span className="lotto-odd-even__odd" style={{ width: `${oddPct}%` }} />
<span className="lotto-odd-even__even" style={{ width: `${100 - oddPct}%` }} />
</div>
</div>
{buckets.length ? (
<div className="lotto-buckets">
{buckets.map(([label, value]) => (
<div key={label} className="lotto-bucket">
<span className="lotto-bucket__label">{label}</span>
<div className="lotto-bucket__bar" aria-hidden>
<span style={{ width: `${((Number(value) || 0) / maxBucket) * 100}%` }} />
</div>
<span className="lotto-bucket__value">{value}</span>
</div>
))}
</div>
) : null}
</div>
);
};
export default MetricBlock;

View File

@@ -0,0 +1,48 @@
import React from 'react';
const PerformanceBanner = ({ perf }) => {
if (!perf || perf.total_checked === 0) return null;
const imp = perf.vs_random?.improvement_pct ?? 0;
const prizeHits = (perf.by_rank?.rank_3 ?? 0) + (perf.by_rank?.rank_4 ?? 0) + (perf.by_rank?.rank_5 ?? 0);
return (
<div className="lotto-perf-banner">
<span className="lotto-perf-banner__label">신뢰도 지표</span>
<div className="lotto-perf-banner__items">
<div className="lotto-perf-banner__item">
<span className="lotto-perf-banner__val">{perf.total_checked}</span>
<span className="lotto-perf-banner__lbl">검증 회차</span>
</div>
<div className="lotto-perf-banner__divider" />
<div className="lotto-perf-banner__item">
<span className="lotto-perf-banner__val">{(perf.avg_correct ?? 0).toFixed(1)}</span>
<span className="lotto-perf-banner__lbl">평균 일치수</span>
</div>
<div className="lotto-perf-banner__divider" />
<div className="lotto-perf-banner__item">
<span className={`lotto-perf-banner__val ${imp > 0 ? 'is-pos' : ''}`}>
{imp > 0 ? '+' : ''}{imp.toFixed(1)}%
</span>
<span className="lotto-perf-banner__lbl">무작위 대비</span>
</div>
<div className="lotto-perf-banner__divider" />
<div className="lotto-perf-banner__item">
<span className="lotto-perf-banner__val">
{((perf.rate_3plus ?? 0) * 100).toFixed(1)}%
</span>
<span className="lotto-perf-banner__lbl">3 일치율</span>
</div>
{prizeHits > 0 && (
<>
<div className="lotto-perf-banner__divider" />
<div className="lotto-perf-banner__item">
<span className="lotto-perf-banner__val is-prize">{prizeHits}</span>
<span className="lotto-perf-banner__lbl">3~5 당첨</span>
</div>
</>
)}
</div>
</div>
);
};
export default PerformanceBanner;

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { NumberRow } from '../lottoUtils';
const PersonalAnalysisPanel = ({ data, loading }) => {
const zones = Object.entries(data?.pattern?.zone_avg ?? {});
const maxZone = zones.length ? Math.max(...zones.map(([, v]) => Number(v) || 0), 1) : 1;
return (
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">My Pattern</p>
<h3> 번호 패턴</h3>
{data && data.total_analyzed > 0 && (
<p className="lotto-panel__sub"> {data.total_analyzed} 추천 기반 분석</p>
)}
</div>
</div>
{(loading || !data || data.total_analyzed === 0) ? (
<p className="lotto-empty">
{loading ? '불러오는 중...' : '추천 이력이 없습니다.'}
</p>
) : (
<div className="lotto-analysis">
<div className="lotto-analysis__row">
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">
내가 자주 선택한 번호 <span>TOP 10</span>
</p>
<NumberRow nums={data.top_picks ?? []} />
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">선택 성향</p>
<div className="lotto-personal-tendency">
{data.vs_draw_avg?.odd_tendency && (
<span className="lotto-personal-tendency__badge">
{data.vs_draw_avg.odd_tendency}
</span>
)}
{data.vs_draw_avg?.sum_tendency && (
<span className="lotto-personal-tendency__badge">
{data.vs_draw_avg.sum_tendency}
</span>
)}
</div>
<div className="lotto-analysis__stats">
<span>홀수 평균 <strong>{data.pattern?.avg_odd_count?.toFixed(1)}</strong></span>
<span>합계 평균 <strong>{data.pattern?.avg_sum?.toFixed(1)}</strong></span>
<span>
연속번호 포함률{' '}
<strong>
{((data.pattern?.consecutive_rate ?? 0) * 100).toFixed(0)}%
</strong>
</span>
</div>
</div>
{zones.length > 0 && (
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">구간별 선택 비율</p>
<div className="lotto-buckets">
{zones.map(([zone, avg]) => (
<div key={zone} className="lotto-bucket">
<span className="lotto-bucket__label">{zone}</span>
<div className="lotto-bucket__bar" aria-hidden>
<span style={{ width: `${((Number(avg) || 0) / maxZone) * 100}%` }} />
</div>
<span className="lotto-bucket__value">{Number(avg).toFixed(1)}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</section>
);
};
export default PersonalAnalysisPanel;

View File

@@ -0,0 +1,182 @@
import React from 'react';
import { fmtWon } from '../lottoUtils';
const PurchasePanel = ({
records, stats, loading,
formOpen, form, formSaving, formError, editId,
onFormOpen, onFormClose, onFormChange, onFormSubmit,
onEditStart, onDelete,
}) => {
const winRate = stats?.total_records > 0
? ((stats.prize_count / stats.total_records) * 100).toFixed(1)
: '0.0';
const netColor = (stats?.net ?? 0) >= 0 ? 'is-pos' : 'is-neg';
return (
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Purchase Tracker</p>
<h3>구매 기록</h3>
<p className="lotto-panel__sub">구매 내역 기록 수익률 추적</p>
</div>
<div className="lotto-panel__actions">
{loading && <span className="lotto-chip">로딩 </span>}
<button className="button small" onClick={onFormOpen} disabled={formOpen}>
+ 추가
</button>
</div>
</div>
{/* 통계 바 */}
{stats && stats.total_records > 0 && (
<div className="lotto-purchase-stats">
<div className="lotto-purchase-stat">
<span className="lotto-purchase-stat__val">{fmtWon(stats.total_invested)}</span>
<span className="lotto-purchase-stat__lbl"> 투자</span>
</div>
<div className="lotto-purchase-stat">
<span className="lotto-purchase-stat__val">{fmtWon(stats.total_prize)}</span>
<span className="lotto-purchase-stat__lbl"> 당첨금</span>
</div>
<div className="lotto-purchase-stat">
<span className={`lotto-purchase-stat__val ${netColor}`}>
{(stats.net ?? 0) >= 0 ? '+' : ''}{fmtWon(stats.net)}
</span>
<span className="lotto-purchase-stat__lbl">순손익</span>
</div>
<div className="lotto-purchase-stat">
<span className="lotto-purchase-stat__val">{stats.return_rate?.toFixed(1)}%</span>
<span className="lotto-purchase-stat__lbl">회수율</span>
</div>
<div className="lotto-purchase-stat">
<span className="lotto-purchase-stat__val">{winRate}%</span>
<span className="lotto-purchase-stat__lbl">당첨률</span>
</div>
{stats.max_prize > 0 && (
<div className="lotto-purchase-stat">
<span className="lotto-purchase-stat__val is-prize">{fmtWon(stats.max_prize)}</span>
<span className="lotto-purchase-stat__lbl">최대 당첨금</span>
</div>
)}
</div>
)}
{/* 입력 폼 */}
{formOpen && (
<form className="lotto-purchase-form" onSubmit={onFormSubmit}>
<p className="lotto-purchase-form__title">
{editId != null ? '기록 수정' : '구매 기록 추가'}
</p>
<div className="lotto-purchase-form__grid">
<label className="lotto-field">
회차
<input
type="number" min={1}
value={form.draw_no}
onChange={(e) => onFormChange('draw_no', e.target.value)}
placeholder="예: 1181"
required
/>
</label>
<label className="lotto-field">
구매금액
<input
type="number" step={1000} min={1000}
value={form.amount}
onChange={(e) => onFormChange('amount', Number(e.target.value))}
/>
</label>
<label className="lotto-field">
세트
<input
type="number" min={1} max={20}
value={form.sets}
onChange={(e) => onFormChange('sets', Number(e.target.value))}
/>
</label>
<label className="lotto-field">
당첨금
<input
type="number" min={0}
value={form.prize}
onChange={(e) => onFormChange('prize', Number(e.target.value))}
/>
</label>
<label className="lotto-field lotto-purchase-form__note">
메모
<input
type="text"
value={form.note}
onChange={(e) => onFormChange('note', e.target.value)}
placeholder="예: 5등 1개"
/>
</label>
</div>
{formError && (
<p className="lotto-empty" style={{ color: '#f9b6b1' }}>{formError}</p>
)}
<div className="lotto-purchase-form__actions">
<button type="button" className="button ghost small" onClick={onFormClose}>
취소
</button>
<button type="submit" className="button primary small" disabled={formSaving}>
{formSaving ? '저장 중...' : editId != null ? '수정 완료' : '추가'}
</button>
</div>
</form>
)}
{/* 기록 목록 */}
{records.length === 0 ? (
<p className="lotto-empty">구매 기록이 없습니다.</p>
) : (
<div className="lotto-purchase-list">
<div className="lotto-purchase-list__head">
<span>회차</span>
<span>투자금</span>
<span>당첨금</span>
<span>손익</span>
<span>채점</span>
<span>메모</span>
<span />
</div>
{records.map((rec) => {
const net = (rec.prize ?? 0) - (rec.amount ?? 0);
return (
<div key={rec.id} className="lotto-purchase-row">
<span className="lotto-purchase-row__drw">{rec.draw_no}</span>
<span>{fmtWon(rec.amount)}</span>
<span className={(rec.prize ?? 0) > 0 ? 'is-prize' : ''}>
{fmtWon(rec.prize)}
</span>
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
{net >= 0 ? '+' : ''}{fmtWon(net)}
</span>
<span className="lotto-purchase-row__hits">
{(rec.results || []).map((r, i) => (
<span key={i} className={`hit-badge hit-${r.correct}`}>{r.correct}</span>
))}
{(rec.results || []).some((r) => r.correct >= 4) && (
<span className="prize-flag">🚨 4 확인 필요</span>
)}
</span>
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
<div className="lotto-purchase-row__actions">
<button className="button ghost small" onClick={() => onEditStart(rec)}>
수정
</button>
<button className="button danger small" onClick={() => onDelete(rec.id)}>
삭제
</button>
</div>
</div>
);
})}
</div>
)}
</section>
);
};
export default PurchasePanel;

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { getReviewHistory } from '../../../api';
export default function PurchaseTrendChart() {
const [reviews, setReviews] = useState([]);
useEffect(() => {
getReviewHistory(4).then(rs => setReviews(rs.reverse())); // asc
}, []);
if (reviews.length === 0) return null;
const maxAvg = Math.max(
...reviews.flatMap(r => [r.curator_avg_match || 0, r.user_avg_match || 0]),
2.5
);
const w = 320, h = 80, pad = 16;
const xs = (i) => pad + (i / Math.max(reviews.length - 1, 1)) * (w - 2 * pad);
const ys = (v) => v == null ? null : h - pad - (v / maxAvg) * (h - 2 * pad);
const line = (key) => reviews
.map((r, i) => ({ x: xs(i), y: ys(r[key]) }))
.filter(p => p.y != null)
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`)
.join(' ');
return (
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Trend (last 4 weeks)</p>
<h3> vs 큐레이터 평균 일치 </h3>
</div>
</div>
<svg width={w} height={h} className="trend-chart">
<path d={line('curator_avg_match')} stroke="#b8a8ff" strokeWidth="2" fill="none" />
<path d={line('user_avg_match')} stroke="#76e09a" strokeWidth="2" fill="none" />
</svg>
<div className="trend-legend">
<span><span className="dot dot--curator" /> 큐레이터</span>
<span><span className="dot dot--user" /> </span>
</div>
</section>
);
}

View File

@@ -0,0 +1,142 @@
import React, { useState } from 'react';
import { NumberRow } from '../lottoUtils';
import ConfidenceRing from './ConfidenceRing';
const ReportPanel = ({ report, history, loading, onRefresh, onSelectDrw }) => {
const [histExpand, setHistExpand] = useState(false);
return (
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Weekly Report</p>
<h3>이번 공략 리포트</h3>
{report && (
<p className="lotto-panel__sub">
{report.target_drw_no} 대상 · {report.based_on_draw} 기준
</p>
)}
</div>
<div className="lotto-panel__actions">
{loading && <span className="lotto-chip">로딩 </span>}
<button className="button ghost small" onClick={onRefresh} disabled={loading}>
새로고침
</button>
{history?.length > 0 && (
<button className="button ghost small" onClick={() => setHistExpand((p) => !p)}>
지난 리포트 {histExpand ? '▲' : '▼'}
</button>
)}
</div>
</div>
{/* 지난 리포트 목록 */}
{histExpand && history?.length > 0 && (
<div className="lotto-report-history">
{history.map((h) => (
<button
key={h.drw_no}
className="button ghost small"
onClick={() => { onSelectDrw(h.drw_no); setHistExpand(false); }}
>
{h.drw_no}
</button>
))}
</div>
)}
{!report && !loading && (
<p className="lotto-empty">리포트 데이터가 없습니다.</p>
)}
{loading && !report && (
<p className="lotto-empty">불러오는 ...</p>
)}
{report && (
<>
{/* 신뢰도 + 패턴 요약 */}
<div className="lotto-report-top">
<div className="lotto-report-confidence">
<ConfidenceRing score={report.confidence_score ?? 0} />
<div>
<p className="lotto-report-confidence__title">신뢰도 점수</p>
<div className="lotto-report-confidence__factors">
{Object.entries(report.confidence_factors ?? {}).map(([k, v]) => (
<div key={k} className="lotto-report-confidence__factor">
<span className="lotto-report-confidence__factor-lbl">
{k === 'data_volume' ? '데이터 충분도'
: k === 'pattern_consistency' ? '패턴 안정성'
: k === 'recent_trend' ? '최근 트렌드' : k}
</span>
<div className="lotto-pick__bar">
<span style={{ width: `${v}%` }} />
</div>
<span className="lotto-report-confidence__factor-val">{v}</span>
</div>
))}
</div>
</div>
</div>
<div className="lotto-report-pattern">
<p className="lotto-report-pattern__title">최근 패턴</p>
<div className="lotto-report-pattern__stats">
<div className="lotto-report-pattern__stat">
<span>합계 평균</span>
<strong>{report.recent_pattern?.recent_sum_avg?.toFixed(1) ?? '-'}</strong>
</div>
<div className="lotto-report-pattern__stat">
<span>홀수 평균</span>
<strong>{report.recent_pattern?.recent_odd_avg?.toFixed(1) ?? '-'}</strong>
</div>
{(report.recent_pattern?.triple_appear ?? []).length > 0 && (
<div className="lotto-report-pattern__stat">
<span>3 연속 출현</span>
<NumberRow nums={report.recent_pattern.triple_appear} />
</div>
)}
</div>
</div>
</div>
{/* 핫 / 콜드 / 오버듀 */}
<div className="lotto-analysis__row">
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">
🔥 번호 <span>최근 10 과출현</span>
</p>
<NumberRow nums={report.hot_numbers ?? []} />
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">
🧊 콜드 번호 <span>역대 저빈도 10</span>
</p>
<NumberRow nums={report.cold_numbers ?? []} />
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">
오버듀 <span>가장 오래 미출현</span>
</p>
<NumberRow nums={report.overdue_numbers ?? []} />
</div>
</div>
{/* 전략 추천 세트 */}
{(report.recommended_sets ?? []).length > 0 && (
<div className="lotto-strategy-cards">
{report.recommended_sets.map((set, i) => (
<div key={i} className="lotto-strategy-card">
<p className="lotto-strategy-card__name">{set.strategy}</p>
<NumberRow nums={set.numbers} />
<p className="lotto-strategy-card__desc">{set.description}</p>
</div>
))}
</div>
)}
</>
)}
</section>
);
};
export default ReportPanel;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
import { useState } from 'react';
import { bulkPurchase } from '../../../../api';
import { MODES } from './TierModeToggle';
export default function BulkPurchaseButton({ drawNo, tierMode, onSuccess }) {
const [busy, setBusy] = useState(false);
const mode = MODES.find(m => m.key === tierMode) || MODES[0];
const onClick = async () => {
if (busy) return;
setBusy(true);
try {
await bulkPurchase({
draw_no: drawNo,
tier_mode: tierMode,
sets: mode.sets,
amount: mode.amount,
});
onSuccess?.();
alert(`${mode.sets}세트 구매 기록 완료!`);
} catch (e) {
alert(`구매 기록 실패: ${e?.message || e}`);
} finally {
setBusy(false);
}
};
return (
<button className="lc-btn lc-btn--prim" onClick={onClick} disabled={busy || !drawNo}>
{busy ? '저장 중...' : `이대로 ${mode.sets}세트 구매했음`}
</button>
);
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useMemo, useState } from 'react';
import RetrospectiveBox from './RetrospectiveBox';
import TierModeToggle, { MODES } from './TierModeToggle';
import TierSection from './TierSection';
import BulkPurchaseButton from './BulkPurchaseButton';
import './decision.css';
const TIER_CHAIN = {
core: ['core'],
core_bonus: ['core', 'bonus'],
core_bonus_extended: ['core', 'bonus', 'extended'],
full: ['core', 'bonus', 'extended', 'pool'],
};
const STORAGE_KEY = 'lotto.tier_mode';
export default function DecisionCard({ briefing, review, onPurchaseSuccess }) {
const [tierMode, setTierMode] = useState(() =>
localStorage.getItem(STORAGE_KEY) || 'core'
);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, tierMode);
}, [tierMode]);
const visibleTiers = TIER_CHAIN[tierMode];
const totalSets = useMemo(
() => visibleTiers.reduce((sum, t) => sum + (briefing?.picks?.[t]?.length || 0), 0),
[briefing, visibleTiers]
);
// 분배 칩 — 보이는 계층의 risk_tag 합산
const balance = useMemo(() => {
const acc = { '안정': 0, '균형': 0, '공격': 0 };
for (const t of visibleTiers) {
for (const p of (briefing?.picks?.[t] || [])) {
if (acc[p.risk_tag] !== undefined) acc[p.risk_tag]++;
}
}
return acc;
}, [briefing, visibleTiers]);
if (!briefing) return null;
let cursor = 0;
return (
<div className="lc-card">
<header className="lc-head">
<div>
<p className="lc-eyebrow">Curator Briefing · {briefing.draw_no}</p>
<h3 className="lc-title">{briefing.narrative.headline}</h3>
</div>
<div className="lc-conf">
<div className="lc-conf__num">{briefing.confidence}</div>
<div className="lc-conf__lbl">CONFIDENCE</div>
</div>
</header>
<RetrospectiveBox briefing={briefing} review={review} />
<p className="lc-headline-3">
{(briefing.narrative.summary_3lines || []).join(' · ')}
</p>
<div className="lc-balance">
<div className="lc-balance__chips">
{balance['안정'] > 0 && <span className="lc-chip lc-chip--stable">안정 ×{balance['안정']}</span>}
{balance['균형'] > 0 && <span className="lc-chip lc-chip--balance">균형 ×{balance['균형']}</span>}
{balance['공격'] > 0 && <span className="lc-chip lc-chip--aggro">공격 ×{balance['공격']}</span>}
</div>
</div>
<TierModeToggle value={tierMode} onChange={setTierMode} />
{visibleTiers.map(tier => {
const picks = briefing.picks?.[tier] || [];
const idxBase = cursor;
cursor += picks.length;
return (
<TierSection
key={tier}
tier={tier}
picks={picks}
rationale={briefing.tier_rationale?.[tier]}
indexBase={idxBase}
totalSets={totalSets}
/>
);
})}
<div className="lc-actions">
<BulkPurchaseButton
drawNo={briefing.draw_no}
tierMode={tierMode}
onSuccess={onPurchaseSuccess}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
const ROLE_COLOR = { '안정': 'stable', '균형': 'balance', '공격': 'aggro' };
export default function PickCard({ pick, index, total }) {
const role = pick.risk_tag;
return (
<div className="lc-set">
<div className="lc-set__head">
<span className={`lc-set__role lc-set__role--${ROLE_COLOR[role]}`}> {role}</span>
<span className="lc-set__idx">Set {index + 1} / {total}</span>
</div>
<div className="lc-balls">
{pick.numbers.map(n => (
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
))}
</div>
<p className="lc-set__reason">{pick.reason}</p>
</div>
);
}

View File

@@ -0,0 +1,11 @@
export default function RetrospectiveBox({ briefing, review }) {
const retro = briefing?.narrative?.retrospective;
if (!retro) return null;
const drawNo = review?.draw_no ?? (briefing?.draw_no ? briefing.draw_no - 1 : null);
return (
<aside className="lc-retro">
<p className="lc-retro__time"> 지난 {drawNo ? `${drawNo}` : ''} 회고</p>
<p className="lc-retro__body">{retro}</p>
</aside>
);
}

View File

@@ -0,0 +1,28 @@
const MODES = [
{ key: 'core', label: '코어', sets: 5, amount: 5000 },
{ key: 'core_bonus', label: '+ 보너스', sets: 10, amount: 10000 },
{ key: 'core_bonus_extended', label: '+ 확장', sets: 15, amount: 15000 },
{ key: 'full', label: '+ 풀', sets: 20, amount: 20000 },
];
export default function TierModeToggle({ value, onChange }) {
return (
<div className="lc-toggle" role="tablist">
{MODES.map((m, i) => (
<button
key={m.key}
role="tab"
aria-selected={value === m.key}
className={`lc-toggle__chip ${value === m.key ? 'is-active' : ''}`}
onClick={() => onChange(m.key)}
>
<span className="lc-toggle__dots">{'●'.repeat(i + 1) + '○'.repeat(3 - i)}</span>
<span className="lc-toggle__lbl">{m.label}</span>
<span className="lc-toggle__sub">{m.sets}세트 · {m.amount.toLocaleString()}</span>
</button>
))}
</div>
);
}
export { MODES };

View File

@@ -0,0 +1,25 @@
import PickCard from './PickCard';
const TIER_TITLE = {
core: '코어 (필수, 5세트)',
bonus: '보너스 (+5)',
extended: '확장 (+5)',
pool: '풀 (+5)',
};
export default function TierSection({ tier, picks, rationale, indexBase = 0, totalSets }) {
if (!picks?.length) return null;
return (
<section className={`lc-tier lc-tier--${tier}`}>
<header className="lc-tier__head">
<h4>{TIER_TITLE[tier]}</h4>
{rationale && tier !== 'core' && (
<p className="lc-tier__rationale">{rationale}</p>
)}
</header>
{picks.map((p, i) => (
<PickCard key={i} pick={p} index={indexBase + i} total={totalSets} />
))}
</section>
);
}

View File

@@ -0,0 +1,52 @@
.lc-card { max-width: 720px; margin: 0 auto; background: linear-gradient(180deg, #161220 0%, #1a1426 100%);
border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; color: #ece6f7; }
.lc-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; }
.lc-eyebrow { font-size: 10px; letter-spacing: 2px; opacity: 0.5; text-transform: uppercase; margin: 0 0 4px; }
.lc-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.lc-conf { display: flex; flex-direction: column; align-items: flex-end; }
.lc-conf__num { font-family: 'Courier New', monospace; font-size: 28px; font-weight: 700; color: #b8a8ff; letter-spacing: -0.04em; }
.lc-conf__lbl { font-size: 9px; letter-spacing: 1.5px; opacity: 0.55; }
.lc-retro { background: rgba(184, 168, 255, 0.06); border-left: 2px solid rgba(184, 168, 255, 0.4);
padding: 10px 14px; margin: 14px 0; border-radius: 4px; }
.lc-retro__time { font-size: 9px; letter-spacing: 1.5px; color: #b8a8ff; opacity: 0.7; margin: 0 0 4px; }
.lc-retro__body { font-size: 13px; line-height: 1.55; opacity: 0.85; margin: 0; }
.lc-headline { font-size: 16px; font-weight: 600; line-height: 1.5; margin: 18px 0 4px; }
.lc-headline-3 { font-size: 12px; opacity: 0.65; line-height: 1.55; margin: 0 0 18px; }
.lc-balance { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 16px; font-size: 11px; }
.lc-balance__chips { display: flex; gap: 8px; }
.lc-chip { padding: 3px 8px; border-radius: 100px; font-weight: 600; font-size: 11px; }
.lc-chip--stable { background: rgba(80, 200, 120, 0.15); color: #76e09a; }
.lc-chip--balance { background: rgba(255, 200, 80, 0.15); color: #ffce6e; }
.lc-chip--aggro { background: rgba(255, 100, 130, 0.15); color: #ff8aa0; }
.lc-toggle { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 16px 0; }
.lc-toggle__chip { padding: 10px 8px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; color: #ece6f7; cursor: pointer; display: flex; flex-direction: column; gap: 4px; align-items: center; }
.lc-toggle__chip.is-active { background: rgba(184, 168, 255, 0.15); border-color: rgba(184, 168, 255, 0.5); }
.lc-toggle__dots { letter-spacing: 2px; font-size: 10px; opacity: 0.7; }
.lc-toggle__lbl { font-size: 12px; font-weight: 600; }
.lc-toggle__sub { font-size: 10px; opacity: 0.55; }
.lc-tier { margin-bottom: 14px; }
.lc-tier__head { padding: 8px 0; border-top: 1px dashed rgba(255,255,255,0.1); margin-bottom: 8px; }
.lc-tier:first-of-type .lc-tier__head { border-top: none; }
.lc-tier__head h4 { font-size: 12px; font-weight: 600; margin: 0 0 4px; opacity: 0.75; letter-spacing: 0.5px; }
.lc-tier__rationale { font-size: 11px; opacity: 0.55; margin: 0; }
.lc-set { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px;
padding: 14px; margin-bottom: 10px; }
.lc-set__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.lc-set__role { font-size: 11px; font-weight: 600; letter-spacing: 0.5px; }
.lc-set__role--stable { color: #76e09a; }
.lc-set__role--balance { color: #ffce6e; }
.lc-set__role--aggro { color: #ff8aa0; }
.lc-set__idx { font-size: 10px; opacity: 0.4; }
.lc-balls { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.lc-set__reason { font-size: 12px; opacity: 0.7; line-height: 1.45; margin: 0; }
.lc-actions { display: flex; gap: 10px; margin-top: 18px; }
.lc-btn { padding: 12px 16px; border-radius: 10px; border: none; font-weight: 600; cursor: pointer;
font-size: 14px; min-width: 160px; }
.lc-btn--prim { background: linear-gradient(135deg, #b8a8ff, #8a78db); color: #14101e; }
.lc-btn--prim:disabled { opacity: 0.5; cursor: not-allowed; }
.lc-btn--ghost { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: #ece6f7; }
@media (max-width: 480px) {
.lc-toggle { grid-template-columns: repeat(2, 1fr); }
}

View File

@@ -0,0 +1,68 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getLatestBriefing, triggerLottoCurate } from '../../../api';
const normalizePicks = (picks) => {
if (Array.isArray(picks)) {
return { core: picks, bonus: [], extended: [], pool: [] };
}
return {
core: picks?.core || [],
bonus: picks?.bonus || [],
extended: picks?.extended || [],
pool: picks?.pool || [],
};
};
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 ? { ...data, picks: normalizePicks(data.picks) } : 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, picks: normalizePicks(data.picks) });
setRegenerating(false);
clearInterval(pollingRef.current);
}
} catch {}
if (attempts >= 40) {
clearInterval(pollingRef.current);
setRegenerating(false);
setError('재생성 타임아웃 (2분)');
}
}, 3000);
} catch (e) {
setError(e.message);
setRegenerating(false);
}
}, [briefing?.generated_at]);
useEffect(() => () => { if (pollingRef.current) clearInterval(pollingRef.current); }, []);
return { briefing, loading, error, regenerating, reload: load, regenerate };
}

View File

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

View File

@@ -0,0 +1,162 @@
import { useCallback, useEffect, useState } from 'react';
import {
getLatest, getStats, getBestPicks, getAnalysis,
getPerformanceStats, getLatestReport, getReportHistory, getReport,
getPersonalAnalysis, getCombinedRecommend, getCombinedHistory,
} from '../../../api';
import { readStatsCache, writeStatsCache } from '../lottoUtils';
export default function useLottoData() {
const [latest, setLatest] = useState(null);
const [stats, setStats] = useState(() => readStatsCache());
const [statsLoading, setStatsLoading] = useState(false);
const [statsError, setStatsError] = useState('');
const [loading, setLoading] = useState({
latest: false, bestPicks: false, analysis: false,
});
const [error, setError] = useState('');
const [bestPicks, setBestPicks] = useState([]);
const [bestPicksExpanded, setBestPicksExpanded] = useState(false);
const [analysis, setAnalysis] = useState(null);
const [simulating, setSimulating] = useState(false);
const [simResult, setSimResult] = useState(null);
// 종합 추론
const [combined, setCombined] = useState(null);
const [combinedLoading, setCombinedLoading] = useState(false);
const [combinedHistory, setCombinedHistory] = useState([]);
const [combinedHistLoading, setCombinedHistLoading] = useState(false);
// 신뢰도·리포트·개인분석
const [perfStats, setPerfStats] = useState(null);
const [report, setReport] = useState(null);
const [reportHistory, setReportHistory] = useState([]);
const [reportLoading, setReportLoading] = useState(false);
const [personalAnalysis, setPersonalAnalysis] = useState(null);
const [personalLoading, setPersonalLoading] = useState(false);
const refreshLatest = useCallback(async () => {
setLoading((s) => ({ ...s, latest: true }));
setError('');
try { setLatest(await getLatest()); }
catch (e) { setError(e?.message ?? String(e)); }
finally { setLoading((s) => ({ ...s, latest: false })); }
}, []);
const refreshStats = useCallback(async () => {
setStatsLoading(true); setStatsError('');
try {
const cached = readStatsCache();
if (cached && !stats) setStats(cached);
const data = await getStats();
if (!cached || cached.total_draws !== data?.total_draws) {
setStats(data); writeStatsCache(data);
}
} catch (e) { setStatsError(e?.message ?? String(e)); }
finally { setStatsLoading(false); }
}, [stats]);
const refreshBestPicks = useCallback(async () => {
setLoading((s) => ({ ...s, bestPicks: true }));
try { setBestPicks((await getBestPicks(20)).items ?? []); }
catch {}
finally { setLoading((s) => ({ ...s, bestPicks: false })); }
}, []);
const refreshAnalysis = useCallback(async () => {
setLoading((s) => ({ ...s, analysis: true }));
try { setAnalysis(await getAnalysis()); }
catch {}
finally { setLoading((s) => ({ ...s, analysis: false })); }
}, []);
const refreshPerfStats = useCallback(async () => {
try { setPerfStats(await getPerformanceStats()); } catch {}
}, []);
const refreshReport = useCallback(async () => {
setReportLoading(true);
try {
const [rep, hist] = await Promise.all([
getLatestReport(),
getReportHistory(10),
]);
setReport(rep);
setReportHistory(hist?.reports ?? []);
} catch {}
finally { setReportLoading(false); }
}, []);
const loadSpecificReport = useCallback(async (drwNo) => {
setReportLoading(true);
try { setReport(await getReport(drwNo)); }
catch {}
finally { setReportLoading(false); }
}, []);
const runCombinedRecommend = useCallback(async () => {
setCombinedLoading(true);
try {
const data = await getCombinedRecommend();
setCombined(data);
const hist = await getCombinedHistory(30);
setCombinedHistory(hist?.items ?? []);
} catch (e) { setError(e?.message ?? String(e)); }
finally { setCombinedLoading(false); }
}, []);
const loadCombinedHistory = useCallback(async () => {
setCombinedHistLoading(true);
try {
const hist = await getCombinedHistory(30);
setCombinedHistory(hist?.items ?? []);
} catch {}
finally { setCombinedHistLoading(false); }
}, []);
const refreshPersonalAnalysis = useCallback(async () => {
setPersonalLoading(true);
try { setPersonalAnalysis(await getPersonalAnalysis()); }
catch {}
finally { setPersonalLoading(false); }
}, []);
const onSimulate = useCallback(async () => {
const ok = confirm('시뮬레이션을 즉시 실행할까요?\n20,000개 후보를 분석합니다. (약 1~3분 소요)');
if (!ok) return;
setSimulating(true); setSimResult(null); setError('');
try {
const { triggerSimulate } = await import('../../../api');
const data = await triggerSimulate();
setSimResult(data);
await refreshBestPicks();
} catch (e) { setError(e?.message ?? String(e)); }
finally { setSimulating(false); }
}, [refreshBestPicks]);
// 초기 로드
useEffect(() => {
refreshLatest();
refreshStats();
refreshBestPicks();
refreshAnalysis();
refreshPerfStats();
refreshReport();
refreshPersonalAnalysis();
loadCombinedHistory();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return {
latest, loading, error, setError,
stats, statsLoading, statsError, refreshStats,
refreshLatest,
bestPicks, bestPicksExpanded, setBestPicksExpanded, refreshBestPicks,
analysis, refreshAnalysis,
simulating, simResult, onSimulate,
combined, combinedLoading, combinedHistory, combinedHistLoading,
runCombinedRecommend,
perfStats,
report, reportHistory, reportLoading, refreshReport, loadSpecificReport,
personalAnalysis, personalLoading,
};
}

View File

@@ -0,0 +1,75 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { deleteHistory, getHistory, recommend } from '../../../api';
import { buildMetricsFromHistory } from '../lottoUtils';
export default function useManualRecommend() {
const [params, setParams] = useState({
recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5,
});
const presets = useMemo(() => [
{ name: '기본', recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5 },
{ name: '최근 가중치↑', recent_window: 100, recent_weight: 3.0, avoid_recent_k: 10 },
{ name: '안전(분산)', recent_window: 300, recent_weight: 1.6, avoid_recent_k: 8 },
{ name: '공격(최근)', recent_window: 80, recent_weight: 3.5, avoid_recent_k: 12 },
], []);
const [result, setResult] = useState(null);
const [history, setHistory] = useState([]);
const [historyExpanded, setHistoryExpanded] = useState(false);
const historyEndRef = useRef(null);
const prevHistoryExpandedRef = useRef(false);
const [loading, setLoading] = useState({ recommend: false, history: false });
const [error, setError] = useState('');
const historyMetrics = useMemo(() => buildMetricsFromHistory(history), [history]);
const visibleHistory = historyExpanded ? history : history.slice(0, 5);
const refreshHistory = useCallback(async () => {
setLoading((s) => ({ ...s, history: true }));
setError('');
try {
const limit = 100; let offset = 0; const allItems = [];
while (true) {
const data = await getHistory(limit, offset);
const items = data.items ?? [];
allItems.push(...items);
if (items.length < limit) break;
offset += limit;
}
setHistory(allItems);
} catch (e) { setError(e?.message ?? String(e)); }
finally { setLoading((s) => ({ ...s, history: false })); }
}, []);
const onRecommend = useCallback(async () => {
setLoading((s) => ({ ...s, recommend: true })); setError('');
try { const data = await recommend(params); setResult(data); await refreshHistory(); }
catch (e) { setError(e?.message ?? String(e)); }
finally { setLoading((s) => ({ ...s, recommend: false })); }
}, [params, refreshHistory]);
const onDelete = useCallback(async (id) => {
if (!confirm(`히스토리 #${id}를 삭제할까요?`)) return;
setError('');
try { await deleteHistory(id); setHistory((prev) => prev.filter((item) => item.id !== id)); }
catch (e) { setError(e?.message ?? String(e)); }
}, []);
useEffect(() => {
if (historyExpanded && !prevHistoryExpandedRef.current) {
requestAnimationFrame(() => {
historyEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
});
}
prevHistoryExpandedRef.current = historyExpanded;
}, [historyExpanded, visibleHistory.length]);
useEffect(() => { refreshHistory(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
return {
params, setParams, presets,
result, history, historyExpanded, setHistoryExpanded,
historyEndRef, loading, error, setError,
historyMetrics, visibleHistory,
refreshHistory, onRecommend, onDelete,
};
}

View File

@@ -0,0 +1,113 @@
import { useCallback, useEffect, useState } from 'react';
import {
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
bulkPurchase as apiBulkPurchase,
} from '../../../api';
import { emptyPurchaseForm } from '../lottoUtils';
export default function usePurchases() {
const [purchases, setPurchases] = useState([]);
const [purchaseStats, setPurchaseStats] = useState(null);
const [purchaseLoading, setPurchaseLoading] = useState(false);
// 폼 상태
const [purchaseFormOpen, setPurchaseFormOpen] = useState(false);
const [purchaseForm, setPurchaseForm] = useState(emptyPurchaseForm);
const [purchaseFormSaving, setPurchaseFormSaving] = useState(false);
const [purchaseFormError, setPurchaseFormError] = useState('');
const [purchaseEditId, setPurchaseEditId] = useState(null);
const refreshPurchases = useCallback(async () => {
setPurchaseLoading(true);
try {
const [recs, st] = await Promise.all([getPurchases(), getPurchaseStats()]);
setPurchases(recs?.records ?? []);
setPurchaseStats(st);
} catch {}
finally { setPurchaseLoading(false); }
}, []);
const handlePurchaseFormOpen = useCallback(() => {
setPurchaseEditId(null);
setPurchaseForm(emptyPurchaseForm());
setPurchaseFormError('');
setPurchaseFormOpen(true);
}, []);
const handlePurchaseFormClose = useCallback(() => {
setPurchaseFormOpen(false);
setPurchaseEditId(null);
setPurchaseFormError('');
}, []);
const handlePurchaseFormChange = useCallback((field, value) => {
setPurchaseForm((prev) => ({ ...prev, [field]: value }));
}, []);
const handlePurchaseEditStart = useCallback((rec) => {
setPurchaseEditId(rec.id);
setPurchaseForm({
draw_no: String(rec.draw_no ?? ''),
amount: rec.amount ?? 5000,
sets: rec.sets ?? 5,
prize: rec.prize ?? 0,
note: rec.note ?? '',
});
setPurchaseFormError('');
setPurchaseFormOpen(true);
}, []);
const handlePurchaseFormSubmit = useCallback(async (e) => {
e.preventDefault();
setPurchaseFormSaving(true); setPurchaseFormError('');
const payload = {
draw_no: Number(purchaseForm.draw_no),
amount: Number(purchaseForm.amount),
sets: Number(purchaseForm.sets),
prize: Number(purchaseForm.prize),
note: purchaseForm.note.trim(),
};
try {
if (purchaseEditId != null) {
const updated = await updatePurchase(purchaseEditId, payload);
setPurchases((prev) =>
prev.map((r) => r.id === purchaseEditId ? (updated ?? { ...payload, id: purchaseEditId }) : r)
);
} else {
const saved = await addPurchase(payload);
setPurchases((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]);
}
try { setPurchaseStats(await getPurchaseStats()); } catch {}
handlePurchaseFormClose();
} catch (err) {
setPurchaseFormError(err?.message ?? String(err));
} finally {
setPurchaseFormSaving(false);
}
}, [purchaseForm, purchaseEditId, handlePurchaseFormClose]);
const handlePurchaseDelete = useCallback(async (id) => {
if (!confirm('이 구매 기록을 삭제할까요?')) return;
setPurchases((prev) => prev.filter((r) => r.id !== id));
try {
await deletePurchase(id);
try { setPurchaseStats(await getPurchaseStats()); } catch {}
} catch { refreshPurchases(); }
}, [refreshPurchases]);
const handleBulkPurchase = useCallback(async (params) => {
const result = await apiBulkPurchase(params);
await refreshPurchases();
return result;
}, [refreshPurchases]);
useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
return {
purchases, purchaseStats, purchaseLoading,
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
handleBulkPurchase,
};
}

Some files were not shown because too many files have changed in this diff Show More