Compare commits
22 Commits
9cde0e927a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f008af0d9b | |||
| 2ec2ba4b3a | |||
| 25a4ebd342 | |||
| 1ccb9e517a | |||
| 82e2c08262 | |||
| 332c2c5996 | |||
| 166d04384f | |||
| efdec596b2 | |||
| f32b7ee615 | |||
| 6640962573 | |||
| 8e9ba0431b | |||
| 99c996b20e | |||
| d60d4ccd9e | |||
| bb1a6fbdab | |||
| 35188c7b52 | |||
| 751b34c39f | |||
| e9bd08c75d | |||
| 15c2100ba2 | |||
| c37678ca01 | |||
| 1fd357cf95 | |||
| 73654788da | |||
| 751c99ebe6 |
80
.cli/AGENTS.override.md
Normal file
80
.cli/AGENTS.override.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# .cli/AGENTS.override.md (기획자/Planner)
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
|
||||||
|
당신은 기획자(Planner)입니다. 목적은 “다음 작업 티켓(current)”을 **실행 가능한 수준**으로 작성하는 것입니다.
|
||||||
|
개발자는 이 티켓만 보고 레포에 직접 적용할 수 있어야 합니다.
|
||||||
|
|
||||||
|
## 허용 범위(절대 규칙)
|
||||||
|
|
||||||
|
- 수정/생성 가능: `.cli/**`만
|
||||||
|
- 금지: 제품 코드 수정(`src/**`, `app/**`, `components/**` 등)
|
||||||
|
|
||||||
|
## 훌륭한 기획자의 기준(운영 원칙)
|
||||||
|
|
||||||
|
1. **사용자 문제를 정확히 정의한다**
|
||||||
|
|
||||||
|
- “무엇이 불편한가/왜 중요한가/언제 발생하는가”를 짧게 명확히 쓴다.
|
||||||
|
- 재현 단계(있으면) + 기대 동작을 분리한다.
|
||||||
|
|
||||||
|
2. **모호함을 줄이고, 판단을 기록한다**
|
||||||
|
|
||||||
|
- 불확실한 부분이 있으면 질문 1개만 하거나,
|
||||||
|
- 질문 없이 진행 가능하면 “가정(Assumption)”을 1~3줄로 명시한다.
|
||||||
|
|
||||||
|
3. **작게 쪼개고, 순서를 설계한다**
|
||||||
|
|
||||||
|
- 한 티켓 = 한 축(기능/리팩토링/디자인 변경을 섞지 않음)
|
||||||
|
- 범위가 크면 “토대→핵심→마감” 순으로 티켓을 쪼갠다.
|
||||||
|
|
||||||
|
4. **성공 기준(AC)을 검증 가능하게 만든다**
|
||||||
|
|
||||||
|
- AC는 체크 가능한 문장으로 3~7개 이내.
|
||||||
|
- “좋아 보이게” 같은 감상형 문장 금지.
|
||||||
|
- 회귀 방지용 AC(기존 동작 유지) 1~2개를 포함한다.
|
||||||
|
|
||||||
|
5. **범위를 좁혀 토큰/탐색 비용을 줄인다**
|
||||||
|
|
||||||
|
- 수정 대상 파일을 가능한 1~3개로 제한한다.
|
||||||
|
- 검색이 필요하면 1회 `rg`로 찾을 수 있게 키워드/경로 힌트를 제공한다.
|
||||||
|
|
||||||
|
6. **Non-scope로 일을 ‘하지 않게’ 만든다**
|
||||||
|
|
||||||
|
- 이번 티켓에서 하지 않을 것(금지)을 명시해 회귀/확장 욕구를 막는다.
|
||||||
|
- “리팩토링 금지/스타일 변경 금지/신규 기능 금지” 등 포함.
|
||||||
|
|
||||||
|
7. **리스크를 먼저 처리한다**
|
||||||
|
|
||||||
|
- 사용자 데이터 손상, 플로우 단절, 저장/타이머 핵심 로직 같은 리스크를 우선 점검한다.
|
||||||
|
- 필요한 경우 “호환 유지(딥링크/기존 데이터)”를 요구사항/AC에 명시한다.
|
||||||
|
|
||||||
|
8. **제품 톤/감정 설계를 훼손하지 않는다**
|
||||||
|
|
||||||
|
- 실패/미완료 같은 부정 표현 금지 정책을 존중한다(관련 작업일 때).
|
||||||
|
- 문구 변경이 필요하면 최소 범위로, 중립/다음 행동 중심으로 작성한다.
|
||||||
|
|
||||||
|
9. **작업 결과가 ‘다음 결정’을 돕게 한다**
|
||||||
|
|
||||||
|
- 가능하면 티켓에 “관측 포인트”를 1줄로 남긴다(예: 제출 성공률/이탈 구간 확인).
|
||||||
|
- 단, 신규 분석 기능 추가는 별도 티켓으로 분리한다.
|
||||||
|
|
||||||
|
## 산출물(필수)
|
||||||
|
|
||||||
|
- `@.cli/current.md`에 작업 티켓을 작성/갱신한다.
|
||||||
|
- current에는 반드시 포함:
|
||||||
|
- TASK_META
|
||||||
|
- 작업 목표
|
||||||
|
- 요구사항(번호)
|
||||||
|
- Non-scope
|
||||||
|
- 적용 파일(예상)
|
||||||
|
- AC(체크리스트)
|
||||||
|
- 완료 후 출력(최소)
|
||||||
|
|
||||||
|
## 참고 문서(읽기 전용)
|
||||||
|
|
||||||
|
- 구조/경계(FSD) 규칙은 `@.cli/docs/architecture.md`, `@.cli/docs/rules.md`를 참고한다.
|
||||||
|
(필요할 때만 최소 변경)
|
||||||
|
|
||||||
|
## 출력(최소)
|
||||||
|
|
||||||
|
- 변경된 파일 경로만 출력한다. (긴 설명/코드 덤프 금지)
|
||||||
1
.cli/current.md
Normal file
1
.cli/current.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# .cli/current.md (empty)
|
||||||
89
.cli/docs/architecture.md
Normal file
89
.cli/docs/architecture.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
- 사람이 유지보수하기 쉬운 구조
|
||||||
|
- LLM/CLI가 토큰을 낭비하지 않고 정확히 수정할 수 있는 구조
|
||||||
|
- 경계(레이어)를 강제해 회귀(regression)를 예방
|
||||||
|
|
||||||
|
## 레이어 규칙 (FSD)
|
||||||
|
|
||||||
|
### app/\*
|
||||||
|
|
||||||
|
- 페이지/레이아웃은 **조합자(composer)** 역할만 한다
|
||||||
|
- `app/**` 는 **widgets public API만** import 한다
|
||||||
|
- `app/**` 는 `features/entities/shared`의 UI를 직접 import 하지 않는다
|
||||||
|
|
||||||
|
### widgets/\*
|
||||||
|
|
||||||
|
- 페이지 단위 블록(화면 조립 단위)
|
||||||
|
- `index.ts`를 통해 public API를 노출한다
|
||||||
|
- 내부에서 `features/entities/shared`를 조합한다
|
||||||
|
|
||||||
|
### features/\*
|
||||||
|
|
||||||
|
- 제품 기능 단위(UI + model + lib)
|
||||||
|
- `app/*` 를 import 하면 안 된다
|
||||||
|
- `entities/shared` 는 import 가능
|
||||||
|
|
||||||
|
### entities/\*
|
||||||
|
|
||||||
|
- 도메인 모델(session/log 등)
|
||||||
|
- 가능한 한 타입/스키마를 스스로 소유한다
|
||||||
|
- `shared` 만 import 가능
|
||||||
|
|
||||||
|
### shared/\*
|
||||||
|
|
||||||
|
- 재사용 가능한 최소 단위(ui/lib/config/types)
|
||||||
|
- `entities/features/widgets/app` 를 import 하면 안 된다
|
||||||
|
|
||||||
|
## Public API 규칙
|
||||||
|
|
||||||
|
- 모든 widget은 `index.ts`를 가진다
|
||||||
|
- 페이지는 아래 형태로 import 한다:
|
||||||
|
- `import { X } from "@/widgets/x";`
|
||||||
|
- `app/*`에서 `widgets/x/ui/*` 같은 깊은 경로 import는 피한다
|
||||||
|
|
||||||
|
## 공용 위치 규칙
|
||||||
|
|
||||||
|
- `shared/ui`: 공용 UI 프리미티브(button/card/dialog/input…)
|
||||||
|
- `shared/lib`: 순수 유틸(math/motion 등)
|
||||||
|
- `shared/config`: 튜닝 상수(예: starfield params)
|
||||||
|
|
||||||
|
## LLM/CLI 작업 규칙
|
||||||
|
|
||||||
|
- 모든 작업은 `current.md`에 **대상 파일 경로**를 반드시 명시한다
|
||||||
|
- 튜닝 작업은 가능하면 **config만 수정**한다
|
||||||
|
- 레포 검색이 필요하면 `grep/rg`는 1회만 사용하고, 결과 요약은 5줄 이내로 제한한다
|
||||||
|
|
||||||
|
## Starfield 소유권
|
||||||
|
|
||||||
|
- Flight 배경 로직: `features/flight-starfield/**`
|
||||||
|
- Lobby 별자리/글린트: `features/lobby-starfield/**`
|
||||||
|
- 페이지 마운트: `widgets/flight-background`, `widgets/lobby-background`
|
||||||
|
- 튜닝 파라미터: `shared/config/starfield.ts`
|
||||||
|
|
||||||
|
## Boarding 소유권
|
||||||
|
|
||||||
|
- 탑승 시작 로직: `features/boarding/model/startVoyage.ts`
|
||||||
|
- 탑승 미션 폼 UI: `features/boarding/ui/BoardingMissionForm.tsx`
|
||||||
|
- 로비 출항 진입: `widgets/lobby-routes` (modal open/close + flight 진입)
|
||||||
|
- `/boarding` 라우트: 딥링크 호환을 위해 동일 form/model 재사용
|
||||||
|
- 메모/노트 입력은 탑승 생성 경로에서 제거됨
|
||||||
|
|
||||||
|
## I18n 소유권 (1단계)
|
||||||
|
|
||||||
|
- 지원 언어/기본값/카피 상수: `shared/config/i18n.ts`
|
||||||
|
- 초기 언어 결정/수동 고정 저장: `features/i18n/model/resolveInitialLocale.ts`
|
||||||
|
- 런타임 번역 접근(context/hook): `features/i18n/model/useI18n.tsx`
|
||||||
|
- 앱 초기 bootstrap + 수동 변경 UI(헤더 select): `features/i18n/ui/I18nLayoutShell.tsx`
|
||||||
|
- `app/layout.tsx`는 i18n shell을 마운트해 단일 URL에서 언어 상태만 관리한다
|
||||||
|
- 우선순위: `수동 저장값(localStorage) > 브라우저 언어 > en`
|
||||||
|
- 지원 외 언어는 `en`으로 폴백한다
|
||||||
|
- 페이지/UI 문구는 key(`lobby.*`, `flight.*`, `debrief.*`, `log.*`, `settings.*`, `routes.*`)로 관리한다
|
||||||
|
- 항로 메타(`shared/config/routes.ts`)는 사용자 노출 문자열 대신 i18n key를 소유한다
|
||||||
|
|
||||||
|
## 변경 정책
|
||||||
|
|
||||||
|
- 구조 리팩토링은 명시적으로 요청되지 않는 한 동작을 바꾸면 안 된다
|
||||||
|
- 포맷만 바꾸는 변경(diff) 지양
|
||||||
35
.cli/docs/rules.md
Normal file
35
.cli/docs/rules.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# .cli/rules.md
|
||||||
|
|
||||||
|
## 범위 & 출력
|
||||||
|
|
||||||
|
- 변경 사항은 항상 **레포지토리에 직접 적용**한다. (diff만 출력하고 끝내기 금지)
|
||||||
|
- 출력은 최소로 유지한다:
|
||||||
|
1. 수정/생성/삭제된 파일 경로 목록
|
||||||
|
2. AC 체크리스트
|
||||||
|
3. housekeeping 결과 1줄
|
||||||
|
- 응답에 코드 전체를 덤프하지 않는다.
|
||||||
|
|
||||||
|
## 단일 진실(Source of Truth)
|
||||||
|
|
||||||
|
- `@.cli/current.md`는 요구사항과 AC의 **단일 진실**이다.
|
||||||
|
- current.md가 기존 문서/과거 지시와 충돌하면, **해당 실행(run)에서는 current.md를 우선**한다.
|
||||||
|
|
||||||
|
## FSD 경계 규칙
|
||||||
|
|
||||||
|
- `app/**`는 `widgets`의 public API만 import 한다.
|
||||||
|
- `widgets/**`는 `index.ts`를 통해 public API를 노출한다.
|
||||||
|
- `shared/**`는 안쪽 의존성이 없다(즉, `entities/features/widgets/app`를 import 하면 안 된다).
|
||||||
|
- 튜닝 파라미터는 가능하면 `shared/config`를 우선 사용한다.
|
||||||
|
|
||||||
|
## 검색 규율(토큰 절약)
|
||||||
|
|
||||||
|
- 필요하지 않으면 레포 전체를 스캔하지 않는다.
|
||||||
|
- 검색이 필요하면:
|
||||||
|
- `rg`는 1회만 실행한다
|
||||||
|
- 결과 요약은 5줄 이내로 제한한다 (내부 판단용; 장황한 로그 출력 금지)
|
||||||
|
|
||||||
|
## 안전 규칙
|
||||||
|
|
||||||
|
- current.md에 명시되지 않은 신규 기능을 추가하지 않는다.
|
||||||
|
- 요구되지 않으면 디자인/동작을 변경하지 않는다.
|
||||||
|
- 포맷만 바꾸는 변경(diff)은 피한다.
|
||||||
20
.cli/ops/archive_current.md
Normal file
20
.cli/ops/archive_current.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# .cli/ops/archive_current.md
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
- `.cli/current.md`의 전체 내용을 스냅샷으로 tasks 폴더에 저장한다.
|
||||||
|
- task 파일의 내용은 “이번 작업에 사용된 current.md 전체 내용”과 동일해야 한다.
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
|
||||||
|
- `.cli/_task_context.md`에서 아래를 읽는다:
|
||||||
|
- TASK_ID
|
||||||
|
- TASK_SLUG
|
||||||
|
|
||||||
|
## 절차
|
||||||
|
|
||||||
|
1. `.cli/tasks/` 폴더가 없으면 생성한다.
|
||||||
|
2. 아카이브 파일 경로를 만든다:
|
||||||
|
- `.cli/tasks/{TASK_ID}-{TASK_SLUG}.md`
|
||||||
|
3. `.cli/current.md`의 전체 내용을 위 파일에 그대로 저장한다.
|
||||||
|
4. 저장된 파일이 존재하는지 확인한다.
|
||||||
40
.cli/ops/issue_task_id.md
Normal file
40
.cli/ops/issue_task_id.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# .cli/ops/issue_task_id.md
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
- 다음 TASK_ID를 발급하고,
|
||||||
|
- ops 간 공유 컨텍스트 파일 `.cli/_task_context.md`를 생성/갱신한다.
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
|
||||||
|
- `.cli/current.md`의 `TASK_META`에서 아래를 읽는다:
|
||||||
|
- TASK_TITLE
|
||||||
|
- TASK_SLUG
|
||||||
|
|
||||||
|
## 절차
|
||||||
|
|
||||||
|
1. `.cli/tasks/` 폴더가 없으면 생성한다.
|
||||||
|
2. `.cli/tasks/` 내 파일명 중 `^\d{4}-` 패턴(예: `0007-...`)을 가진 파일들을 찾는다.
|
||||||
|
3. 존재하면 가장 큰 4자리 번호를 TASK_ID_MAX로 두고, TASK_ID = TASK_ID_MAX + 1
|
||||||
|
4. 아무 파일도 없으면 TASK_ID = `0001`
|
||||||
|
5. TASK_ID는 4자리 zero-pad 유지(예: 0007)
|
||||||
|
|
||||||
|
## 날짜(DATE)
|
||||||
|
|
||||||
|
- DATE는 `YYYY-MM-DD` 형식으로 기록한다.
|
||||||
|
|
||||||
|
## 수정 파일 목록(FILES)
|
||||||
|
|
||||||
|
- 가능한 경우 `git status --porcelain`로 수정 파일 경로를 수집한다.
|
||||||
|
- git을 사용할 수 없으면, 최소한 이번 작업의 주요 파일을 나열한다(예: `src/app/page.tsx`).
|
||||||
|
- FILES는 쉼표로 구분된 문자열로 기록한다.
|
||||||
|
|
||||||
|
## 출력 파일(컨텍스트) — 고정 포맷
|
||||||
|
|
||||||
|
아래 형식으로 `.cli/_task_context.md`를 생성/덮어쓴다(포맷 변경 금지):
|
||||||
|
|
||||||
|
- TASK_ID: ####
|
||||||
|
- TASK_TITLE: ...
|
||||||
|
- TASK_SLUG: ...
|
||||||
|
- DATE: YYYY-MM-DD
|
||||||
|
- FILES: path1, path2
|
||||||
14
.cli/ops/reset_current.md
Normal file
14
.cli/ops/reset_current.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# .cli/ops/reset_current.md
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
- `.cli/current.md`를 다음 작업을 위해 비운다.
|
||||||
|
|
||||||
|
## 절차
|
||||||
|
|
||||||
|
- `.cli/current.md`의 내용을 아래 한 줄로 교체한다:
|
||||||
|
- `# .cli/current.md (empty)`
|
||||||
|
|
||||||
|
## 출력(없음)
|
||||||
|
|
||||||
|
- 별도 출력하지 않는다. (run_housekeeping이 최종 1줄 출력)
|
||||||
19
.cli/ops/run_housekeeping.md
Normal file
19
.cli/ops/run_housekeeping.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# .cli/run_housekeeping.md
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
작업 종료 후 정리 절차를 수행한다.
|
||||||
|
|
||||||
|
## 실행 순서(필수)
|
||||||
|
|
||||||
|
아래 문서들을 순서대로 수행하라:
|
||||||
|
|
||||||
|
1. `@.cli/ops/issue_task_id.md`
|
||||||
|
2. `@.cli/ops/archive_current.md`
|
||||||
|
3. `@.cli/ops/update_changelog.md`
|
||||||
|
4. `@.cli/ops/reset_current.md`
|
||||||
|
|
||||||
|
## 출력(최소)
|
||||||
|
|
||||||
|
마지막에 아래 1줄만 출력하라:
|
||||||
|
`HOUSEKEEPING | task_id=<####> | archived=OK | changelog=OK | current=reset`
|
||||||
50
.cli/ops/update_changelog.md
Normal file
50
.cli/ops/update_changelog.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# .cli/ops/update_changelog.md
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
- `.cli/changelog.md`에 이번 작업 항목을 “최신이 위”가 되도록 추가한다.
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
|
||||||
|
- `.cli/_task_context.md`에서 아래를 읽는다:
|
||||||
|
- TASK_ID
|
||||||
|
- TASK_TITLE
|
||||||
|
- DATE
|
||||||
|
- FILES
|
||||||
|
|
||||||
|
## 준비
|
||||||
|
|
||||||
|
- `.cli/changelog.md`가 없으면 아래 템플릿으로 생성한다(그대로 복사).
|
||||||
|
|
||||||
|
### changelog 초기 템플릿(파일 생성 시)
|
||||||
|
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
> 규칙
|
||||||
|
>
|
||||||
|
> - 새 작업이 끝나면 맨 위(최신)에 추가한다.
|
||||||
|
> - 날짜 섹션이 없으면 새로 만든다.
|
||||||
|
> - 각 항목은 3~5줄 이내로 짧게.
|
||||||
|
> - “무엇이 바뀌었는지”와 “영향 범위(파일)”만 남긴다.
|
||||||
|
|
||||||
|
## YYYY-MM-DD
|
||||||
|
|
||||||
|
- [0001] 작업 제목(짧게)
|
||||||
|
- Summary: 변경 요약 1
|
||||||
|
- Summary: 변경 요약 2
|
||||||
|
- Files: path1, path2
|
||||||
|
|
||||||
|
## 추가 규칙
|
||||||
|
|
||||||
|
1. `## {DATE}` 섹션이 있으면, 그 섹션 **바로 아래 최상단**에 항목을 추가한다.
|
||||||
|
2. `## {DATE}` 섹션이 없으면, 파일 맨 위(헤더/규칙 블록 다음)에 새로 만들고 그 아래에 추가한다.
|
||||||
|
3. Summary는 2줄(최대 3줄)로 짧게 작성한다.
|
||||||
|
4. Files는 컨텍스트의 `FILES`를 그대로 기록한다.
|
||||||
|
|
||||||
|
## 추가 항목 템플릿(이 포맷 유지)
|
||||||
|
|
||||||
|
- [{TASK_ID}] {TASK_TITLE}
|
||||||
|
- Summary: (변경 요약 1줄)
|
||||||
|
- Summary: (변경 요약 1줄)
|
||||||
|
- Summary: (선택)
|
||||||
|
- Files: {FILES}
|
||||||
30
.cli/planner/compile_current.md
Normal file
30
.cli/planner/compile_current.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# .cli/planner/compile_current.md
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
`@.cli/planner/input.md`의 자연어 초안을 분석해, 실행 가능한 작업 티켓을 `@.cli/current.md`로 작성한다.
|
||||||
|
|
||||||
|
## 절차(필수)000
|
||||||
|
|
||||||
|
1. `@.cli/docs/architecture.md`, `@.cli/docs/rules.md`를 읽고 규칙을 준수한다.
|
||||||
|
2. `@.cli/input.md`를 읽고 아래를 추출한다:
|
||||||
|
- 문제(관찰)
|
||||||
|
- 목표(한 줄)
|
||||||
|
- 범위(수정 파일 1~3개 목표)
|
||||||
|
- 요구사항(번호 매김)
|
||||||
|
- Non-scope(금지 범위 명시)
|
||||||
|
- AC(체크리스트)
|
||||||
|
|
||||||
|
3. 모호한 부분이 있으면 **질문 1개만** 하거나,
|
||||||
|
질문 없이도 진행 가능하면 “합리적 가정”을 1~3줄로 current에 명시한다.
|
||||||
|
4. 결과물을 `@.cli/current.md`에 “작업 티켓 포맷(TASK_META 포함)”으로 저장한다.
|
||||||
|
5. `@.cli/input.md`는 그대로 둔다(지우지 않음).
|
||||||
|
|
||||||
|
## 제약
|
||||||
|
|
||||||
|
- Planner 역할: `.cli/**`만 수정 가능(제품 코드 수정 금지).
|
||||||
|
- 출력은 최소: 변경 파일 경로만.
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
|
||||||
|
- `@.cli/current.md` 가 변경되었으면 "current.md done" 한줄 출력
|
||||||
21
.cli/planner/runbook_planner.md
Normal file
21
.cli/planner/runbook_planner.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# .cli/runbook_planner.md
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
기획자(Planner)는 즉시 `@.cli/planner/compile_current.md` 절차를 수행하여
|
||||||
|
`@.cli/input.md`(자연어 초안) → `@.cli/current.md`(실행 가능한 작업 티켓)으로 컴파일한다.
|
||||||
|
|
||||||
|
## 실행 절차(필수)
|
||||||
|
|
||||||
|
1. 아래 문서를 참고(필요 시)한다:
|
||||||
|
- `@.cli/product/spec.md`
|
||||||
|
- `@.cli/docs/architecture.md`
|
||||||
|
- `@.cli/docs/rules.md`
|
||||||
|
|
||||||
|
2. **바로 다음 오퍼레이션을 수행한다(핵심)**
|
||||||
|
- `@.cli/input.md`를 읽고 `@.cli/planner/compile_current.md` 를 수행하여 `@.cli/current.md`를 작성/갱신한다.
|
||||||
|
|
||||||
|
## 제약
|
||||||
|
|
||||||
|
- Planner 역할: `.cli/**`만 수정 가능. 제품 코드(`src/**` 등) 수정 금지.
|
||||||
|
- 출력은 최소: 변경된 파일 경로만 출력.
|
||||||
282
.cli/product/spec.md
Normal file
282
.cli/product/spec.md
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
# 포커스텔라(Focustella) Web MVP 기획문서 (PRD v0.1)
|
||||||
|
|
||||||
|
> 혼자 일하는 사람을 위한 “집중 항해” 서비스.
|
||||||
|
> 별자리 항로(시간)를 선택하고, 출항 전 미션(이번 작업 목표)을 싣고, 항해(집중) 후 도착(짧은 회고)으로 항해일지를 남긴다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목적과 성공 기준
|
||||||
|
|
||||||
|
### 1.1 문제 정의
|
||||||
|
|
||||||
|
- 혼자 일할 때 **집중 시작**이 어렵고, 흐트러지면 다시 **재시작**이 힘들다.
|
||||||
|
- 작업을 마친 뒤 “내가 뭘 했지?”가 남아 **성취감/회고**가 약하다.
|
||||||
|
- 목표 미달성 시 기록이 “실패”로 남으면 **기분이 나쁘고** 서비스 이탈로 이어질 수 있다.
|
||||||
|
|
||||||
|
### 1.2 Web MVP 목표
|
||||||
|
|
||||||
|
- 세계관(우주 항해)을 **방해가 아니라 동기/의식(ritual)**로 사용해
|
||||||
|
1. 출항(시작) 장벽을 낮추고
|
||||||
|
2. 항해(집중) 방해를 최소화하며
|
||||||
|
3. 도착(회고)로 다음 행동을 남긴다.
|
||||||
|
|
||||||
|
### 1.3 성공 지표(초기)
|
||||||
|
|
||||||
|
- Activation: 첫 방문→첫 항해 시작 전환율
|
||||||
|
- Completion: 항해 종료까지 도달률(중단률 포함)
|
||||||
|
- Retention: D1/D7 재항해율
|
||||||
|
- Journal: 항해일지 생성률, 회고 입력률(1줄이라도)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 핵심 컨셉
|
||||||
|
|
||||||
|
### 2.1 핵심 루프(Core Loop)
|
||||||
|
|
||||||
|
1. 로비에서 **항로 선택**
|
||||||
|
2. 출항 전 **미션 체크인(목표 한 줄)**
|
||||||
|
3. 항해(집중)
|
||||||
|
4. 도착(짧은 회고)
|
||||||
|
5. 항해일지 저장
|
||||||
|
6. 다음 항해로 연결
|
||||||
|
|
||||||
|
### 2.2 톤&매너
|
||||||
|
|
||||||
|
- “훈육/채찍” 금지. “관제/도킹/로그” 같은 **부드러운 안내**.
|
||||||
|
- 실패/낙제/미완료 같은 표현 최소화.
|
||||||
|
- 기록은 **성공/실패**가 아니라 **진행/학습/다음 행동** 중심.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. MVP 범위
|
||||||
|
|
||||||
|
### 3.1 포함(Include)
|
||||||
|
|
||||||
|
- 데스크탑 웹 중심(모바일은 최소 대응)
|
||||||
|
- 첫 화면(로비)에서 항로 선택 → 미션 → 항해실 → 도착/일지
|
||||||
|
- 항로: 1시간/3시간(초기 2~6개 카드 이내)
|
||||||
|
- 항해일지: 항로/미션/결과 상태/한 줄 회고/다음 액션
|
||||||
|
- “출발 시각”은 **강제 스케줄이 아닌** *몰입을 돕는 선택지*로 설계
|
||||||
|
|
||||||
|
### 3.2 제외(Exclude) — MVP에서 하지 않기
|
||||||
|
|
||||||
|
- 실시간 채팅, 랭킹, 과한 게임화
|
||||||
|
- 복잡한 애니메이션(항해실에서 특히 금지)
|
||||||
|
- 강한 방해 차단(사이트 차단/앱 차단)
|
||||||
|
- 복잡한 소셜 매칭/파티 시스템
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 출발 시각(강제성) 설계
|
||||||
|
|
||||||
|
### 4.1 원칙
|
||||||
|
|
||||||
|
- “정시 출발만 가능”처럼 **기다림 강제**는 이탈 위험이 큼.
|
||||||
|
- 강제성은 “시간표”보다 “절차/윈도우”가 세계관과 UX 모두에 유리.
|
||||||
|
|
||||||
|
### 4.2 MVP 추천: 혼합형
|
||||||
|
|
||||||
|
- 기본: **즉시 출항(차터)** — 마찰 최소
|
||||||
|
- 옵션: **출항 윈도우(게이트 개방 시간)** — 몰입/의식/약한 강제성
|
||||||
|
- 예: 하루 4~8회, 불규칙 시각(09:08–09:15 같은 7분 창)
|
||||||
|
- 사용자는 “대기”가 아니라 “출항 준비”를 하도록 로비에 체크리스트 제공
|
||||||
|
- 글로벌: 윈도우는 사용자 **로컬 시간대 기준**으로 제공(개인 루틴 중심)
|
||||||
|
- (추후) 이벤트성 “UTC 글로벌 편”은 주 1회 정도로 실험 가능
|
||||||
|
|
||||||
|
### 4.3 놓쳤을 때 UX(중요)
|
||||||
|
|
||||||
|
- “실패”가 아니라 “다음 도킹 창 안내”
|
||||||
|
- 즉시 출항(차터)로 우회 가능하게 유지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 정보 구조(IA) & 화면 구성
|
||||||
|
|
||||||
|
### 5.1 라우팅(예시)
|
||||||
|
|
||||||
|
- `/` 로비(항로 선택)
|
||||||
|
- `/boarding` 미션 체크인(출항 직전)
|
||||||
|
- `/flight` 항해실(집중)
|
||||||
|
- `/debrief` 도착/회고
|
||||||
|
- `/log` 항해일지 목록
|
||||||
|
- `/log/:id` 항해일지 상세
|
||||||
|
- `/settings` 설정(초간소/모션 감소 등)
|
||||||
|
|
||||||
|
### 5.2 화면별 스펙
|
||||||
|
|
||||||
|
#### A) 로비(첫 화면) — “항로 선택이 주인공”
|
||||||
|
|
||||||
|
**목표:** 설명보다 선택→출항까지 1분 안에 도달
|
||||||
|
|
||||||
|
구성(최소):
|
||||||
|
|
||||||
|
- 상단: 로고 | 항해일지 | 설정
|
||||||
|
- 메인 문장: “어느 별자리로 출항할까요?”
|
||||||
|
- (선택) 미션 한 줄 입력(바로 입력해도 되고, 탑승 후 입력해도 됨)
|
||||||
|
- 항로 카드 그리드(최대 6개)
|
||||||
|
- 별자리명 / 소요시간 / 한 줄 용도(딥워크/정리 등) / `탑승`
|
||||||
|
- 출항 방식 표기: `바로 출항` 또는 `다음 게이트 09:08`
|
||||||
|
- 하단 분위기: “정거장에 N명이 대기 중”(실시간이 아니어도 됨)
|
||||||
|
|
||||||
|
카드 예시:
|
||||||
|
|
||||||
|
- 오리온 · 3h · 딥워크(집필/코딩)
|
||||||
|
- 거문고 · 1h · 기획/정리
|
||||||
|
- 백조 · 1h · 회고/리뷰
|
||||||
|
|
||||||
|
#### B) 탑승(미션 체크인)
|
||||||
|
|
||||||
|
**원칙:** 미션은 “결과”가 아니라 “행동/진척” 형태로 유도
|
||||||
|
|
||||||
|
필드:
|
||||||
|
|
||||||
|
- 미션(필수): 한 줄
|
||||||
|
- (선택) 메모: 1줄(오늘의 제약/컨디션)
|
||||||
|
- 출항 버튼: `출항(도킹 완료)`
|
||||||
|
|
||||||
|
가이드 문구(예):
|
||||||
|
|
||||||
|
- “완료보다 진척을 목표로 적어보세요.”
|
||||||
|
- “예: ‘서론 10문장 쓰기’, ‘가설 2개 검증하기’”
|
||||||
|
|
||||||
|
#### C) 항해실(집중 화면) — “초간소 UI”
|
||||||
|
|
||||||
|
**원칙:** 항해실에서 연출/깜빡임/복잡한 UI 금지
|
||||||
|
|
||||||
|
구성(최소):
|
||||||
|
|
||||||
|
- 상단 작은 라벨: `오리온 항로 · 순항`
|
||||||
|
- 중앙: 남은 시간(크게)
|
||||||
|
- 하단: `일시정지` `종료` `메모(선택)`
|
||||||
|
- 옵션: “초 단위 숨기기(분 단위만)”, “모션 감소”
|
||||||
|
|
||||||
|
단계(점화/접근 등)는 **화면 변화 없이**:
|
||||||
|
|
||||||
|
- 상단 얇은 토스트 3초(선택 행동 있을 때만)
|
||||||
|
- 예: “접근 단계: 마무리 5분 — 다음 액션 한 줄 적기”
|
||||||
|
|
||||||
|
#### D) 도착(회고)
|
||||||
|
|
||||||
|
**목표:** 30초~2분 내 회고 완료 → 일지 생성
|
||||||
|
|
||||||
|
질문(추천):
|
||||||
|
|
||||||
|
1. 이번 항해에서 **확보한 것**(한 줄)
|
||||||
|
2. 다음 항해의 **첫 행동**(한 줄)
|
||||||
|
3. (선택) 막힌 이유(칩 선택)
|
||||||
|
|
||||||
|
결과 상태(실패 금지, 3상태):
|
||||||
|
|
||||||
|
- ✅ 계획대로
|
||||||
|
- 🌓 부분 진행
|
||||||
|
- 🧭 방향 재설정(범위 조정/우선순위 변경)
|
||||||
|
|
||||||
|
CTA:
|
||||||
|
|
||||||
|
- `항해일지 저장`
|
||||||
|
- `다음 항로 선택`
|
||||||
|
|
||||||
|
#### E) 항해일지 목록/상세
|
||||||
|
|
||||||
|
- 목록: 카드형(날짜, 항로, 미션, 상태)
|
||||||
|
- 상세: 자동 생성 템플릿 + 사용자 회고
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 데이터 모델(초안)
|
||||||
|
|
||||||
|
### 6.1 Entity: Route
|
||||||
|
|
||||||
|
- id
|
||||||
|
- name (e.g., "오리온")
|
||||||
|
- duration_minutes (60/180)
|
||||||
|
- tag (딥워크/정리/리뷰)
|
||||||
|
- description (1줄)
|
||||||
|
|
||||||
|
### 6.2 Entity: Voyage(항해 세션)
|
||||||
|
|
||||||
|
- id
|
||||||
|
- user_id (MVP에서는 익명 가능)
|
||||||
|
- route_id
|
||||||
|
- scheduled_type: `charter | window`
|
||||||
|
- scheduled_at (optional)
|
||||||
|
- started_at
|
||||||
|
- ended_at
|
||||||
|
- status: `completed | partial | reoriented | aborted`
|
||||||
|
- mission_text
|
||||||
|
- notes (optional)
|
||||||
|
- debrief_progress (한 줄)
|
||||||
|
- next_action (한 줄)
|
||||||
|
- blocker_tag(optional): `scope | interruption | energy | priority_change | other`
|
||||||
|
|
||||||
|
### 6.3 Entity: User(옵션)
|
||||||
|
|
||||||
|
- id
|
||||||
|
- timezone
|
||||||
|
- preferences: minimal_mode, hide_seconds, reduce_motion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UX 규칙(중요)
|
||||||
|
|
||||||
|
### 7.1 “목표 미달성” 감정 설계
|
||||||
|
|
||||||
|
- “실패/미완료”를 저장하지 않는다.
|
||||||
|
- 기록은 항상 “진행/확보/다음 액션”으로 남는다.
|
||||||
|
- 중단(aborted)도 “이탈”이 아니라 “복귀 안내 + 다음 액션” 중심으로 처리.
|
||||||
|
|
||||||
|
### 7.2 항해 중 방해 최소화
|
||||||
|
|
||||||
|
- 알림 최소(단계 토스트는 선택형/짧게)
|
||||||
|
- 애니메이션/깜빡임 금지(특히 항해실)
|
||||||
|
- 소리 기본 OFF(옵션)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 기술/구현 메모(비결정 영역)
|
||||||
|
|
||||||
|
- 프론트: (예) Next.js/React
|
||||||
|
- 상태/타이머: 탭 비활성/절전 대응 고려
|
||||||
|
- 저장: 로컬 우선(익명 MVP), 추후 로그인/동기화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 분석/이벤트(최소)
|
||||||
|
|
||||||
|
- `route_view` (로비 노출)
|
||||||
|
- `route_select` (항로 선택)
|
||||||
|
- `mission_submit` (미션 체크인 완료)
|
||||||
|
- `voyage_start`
|
||||||
|
- `voyage_pause`
|
||||||
|
- `voyage_end` (status 포함)
|
||||||
|
- `debrief_submit` (회고 입력 완료)
|
||||||
|
- `log_view`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 로드맵(후순위)
|
||||||
|
|
||||||
|
1. 계정/동기화/멀티 디바이스
|
||||||
|
2. 출항 윈도우 개인화(사용자 루틴 기반)
|
||||||
|
3. “이벤트성 UTC 글로벌 편”
|
||||||
|
4. 데스크탑 앱(트레이/항상 위/단축키)
|
||||||
|
5. 모바일: 알림/일지 보기 중심
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 오픈 이슈(결정 필요)
|
||||||
|
|
||||||
|
- 항로 개수: MVP는 2~6개 중 최적?
|
||||||
|
- 출항 윈도우: 하루 몇 회가 이탈을 최소화하면서 몰입을 만들까?
|
||||||
|
- 익명 사용 vs 최소 로그인: 초기 전환율/재방문 간 trade-off
|
||||||
|
- 항해 중 중단 정책: 즉시 종료 허용 + 도착 회고 최소 강제 여부
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Acceptance Criteria (MVP 완료 기준)
|
||||||
|
|
||||||
|
- 사용자는 첫 화면에서 3클릭 이내로 항해 시작 가능
|
||||||
|
- 항해실은 5개 이하 UI 요소로 유지(타이머/미션/버튼)
|
||||||
|
- 도착 시 3상태(완료/부분/재설정) 중 하나로 저장 가능
|
||||||
|
- 항해일지 목록에서 과거 항해를 열람 가능
|
||||||
|
- “실패/미완료”라는 표현 없이도 유저가 상황을 기록할 수 있음
|
||||||
82
.cli/runbook.md
Normal file
82
.cli/runbook.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# .cli/runbook.md
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
이 runbook은 CLI 에이전트가 **레포지토리에 변경을 직접 적용**할 때의 표준 절차를 정의한다.
|
||||||
|
모든 작업은 `@.cli/current.md`를 단일 진실(source of truth)로 삼는다.
|
||||||
|
|
||||||
|
또한, 아래 2개 문서를 **항상 존재**시키고 최신 상태로 유지한다.
|
||||||
|
|
||||||
|
- `@.cli/docs/architecture.md`
|
||||||
|
- `@.cli/docs/rules.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실행 절차
|
||||||
|
|
||||||
|
### 0) 기본 문서/규칙 확인(필수)
|
||||||
|
|
||||||
|
작업 시작 전 아래 파일이 없으면 생성한다(템플릿은 레포 내 최신본을 우선, 없으면 기본 템플릿 생성).
|
||||||
|
|
||||||
|
- `@.cli/docs/architecture.md`
|
||||||
|
- `@.cli/docs/rules.md`
|
||||||
|
|
||||||
|
> 이 단계는 기능 변경이 아니라 “운영 문서/규칙”의 존재 보장이다.
|
||||||
|
|
||||||
|
### 1) 작업 적용(필수)
|
||||||
|
|
||||||
|
- `@.cli/current.md`를 읽고, 요구사항/Non-scope/AC를 준수하여 **레포지토리에 직접 적용**한다.
|
||||||
|
- 범위가 명시된 경우(수정 파일 지정 등) 그 범위를 벗어나지 않는다.
|
||||||
|
- 불필요한 리팩토링/포맷 변경/스타일 변경은 하지 않는다.
|
||||||
|
|
||||||
|
### 2) 문서 동기화(필수)
|
||||||
|
|
||||||
|
- 이번 작업이 **구조(FSD), import 규칙, 폴더/엔트리 포인트**에 영향을 줬다면,
|
||||||
|
`docs/architecture.md`에 변경사항을 5~15줄 내로 반영한다.
|
||||||
|
- 이번 작업이 **CLI 운용 규칙**에 영향을 줬다면 `.cli/rules.md`를 반영한다.
|
||||||
|
- 단, 문서 업데이트는 “요약/원칙” 수준으로만 하고 장문 작성 금지.
|
||||||
|
|
||||||
|
### 3) 성공 시 하우스키핑(필수)
|
||||||
|
|
||||||
|
- 작업이 성공적으로 적용되면, 즉시 `@.cli/run_housekeeping.md`를 그대로 수행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 출력 규칙(최소 출력)
|
||||||
|
|
||||||
|
### 작업 적용 결과 출력(필수)
|
||||||
|
|
||||||
|
아래 3가지만 출력한다.
|
||||||
|
|
||||||
|
1. **수정/생성/삭제된 파일 경로 목록**
|
||||||
|
|
||||||
|
- 경로만, 짧게.
|
||||||
|
- 파일 내용 전체 출력 금지.
|
||||||
|
|
||||||
|
2. **AC 체크 결과**
|
||||||
|
|
||||||
|
- `- [ ]` 형식으로 current의 AC를 그대로 나열하고 `OK/NO`로 표기.
|
||||||
|
|
||||||
|
3. **docs/rules 상태(1줄)**
|
||||||
|
|
||||||
|
- `docs/rules: OK` 또는 `docs/rules: NO (<사유>)`
|
||||||
|
|
||||||
|
### 하우스키핑 결과 출력(1줄)
|
||||||
|
|
||||||
|
- `run_housekeeping: OK` 또는 실패 시 `run_housekeeping: NO (<사유 1줄>)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 절대 금지
|
||||||
|
|
||||||
|
- diff만 출력하고 적용하지 않기
|
||||||
|
- 코드 전체를 응답으로 덤프하기
|
||||||
|
- current 범위를 벗어난 수정(특히 Non-scope 위반)
|
||||||
|
- 임의의 신규 기능 추가
|
||||||
|
- 과도한 설명/장문의 로그 출력
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 종료 후 정리(필수)
|
||||||
|
|
||||||
|
- 위 작업이 끝나면 `@.cli/ops/run_housekeeping.md` 절차를 그대로 수행하라.
|
||||||
59
.cli/tasks/0001-lobby-routes-cta-twinkle.md
Normal file
59
.cli/tasks/0001-lobby-routes-cta-twinkle.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# .cli/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Lobby 카드 구성 단순화 + twinkle 강화
|
||||||
|
- TASK_SLUG: lobby-routes-cta-twinkle
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
`@src/app/page.tsx` 메인 로비 화면을 아래 요구사항대로 수정한다.
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 별 반짝임(twinkle) 강화
|
||||||
|
|
||||||
|
- 메인화면에서 각 별자리(카드/배경에 표현된 별자리)의 별(점)들이 주기적으로 빛나는 느낌이 나도록 수정
|
||||||
|
- 별마다 반짝임 타이밍이 약간씩 다르게(랜덤 딜레이/듀레이션) 적용
|
||||||
|
- prefers-reduced-motion: reduce 환경에서는 반짝임을 끄거나 매우 약하게
|
||||||
|
|
||||||
|
2. 메인 구성 변경 (3개만)
|
||||||
|
|
||||||
|
- 메인 화면 카드/버튼 구성은 아래 3개만 존재:
|
||||||
|
- 우주정거장(무제한/무기한 대기 방): 1개 (CTA)
|
||||||
|
- 오리온: 60분 1개
|
||||||
|
- 쌍둥이자리: 30분 1개
|
||||||
|
- 기존에 있던 다른 별자리 항로/카드는 제거
|
||||||
|
|
||||||
|
3. 레이아웃 변경: 2-row 구성
|
||||||
|
|
||||||
|
- 버튼들의 부모 레이아웃을 2행(row 2개)으로 구성:
|
||||||
|
- 상단 row: 우주정거장(CTA) 버튼이 full-width로 단독 차지
|
||||||
|
- 하단 row: 오리온(60분), 쌍둥이자리(30분) 버튼이 2열로 나란히 배치
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 하단 문구(“정거장 3명 대기 중”) 제거는 사용자가 직접 처리한다. 이 작업에서 변경하지 말 것.
|
||||||
|
- /flight 등 다른 페이지 로직/구조 변경 금지
|
||||||
|
- 신규 기능 추가 금지
|
||||||
|
- 과도한 번쩍임/강한 플래시/깜빡임 효과 금지
|
||||||
|
|
||||||
|
## 적용 파일
|
||||||
|
|
||||||
|
- 필수: `@src/app/page.tsx`
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 메인 화면에 카드/버튼이 3개만 보인다: 우주정거장(CTA), 오리온 60분, 쌍둥이자리 30분
|
||||||
|
- [ ] 레이아웃이 2행 구조로 동작한다(상단 CTA full / 하단 2열)
|
||||||
|
- [ ] 별들이 은은하게 반짝이며 별마다 타이밍이 달라 자연스럽다
|
||||||
|
- [ ] prefers-reduced-motion에서 반짝임이 정지 또는 매우 약해진다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정된 파일 경로 목록
|
||||||
|
- AC 항목별 체크 결과(OK/NO)
|
||||||
|
|
||||||
|
## 작업 종료 후 정리(필수)
|
||||||
|
|
||||||
|
- 위 작업이 끝나면 `@.cli/run_housekeeping.md` 절차를 그대로 수행하라.
|
||||||
66
.cli/tasks/0002-lobby-constellation-twinkle-fix.md
Normal file
66
.cli/tasks/0002-lobby-constellation-twinkle-fix.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# .cli/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Lobby 별자리 3개 고정 + 별 반짝임 구현 + 불필요 별/문구 제거
|
||||||
|
- TASK_SLUG: lobby-constellation-twinkle-fix
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
`@src/app/page.tsx` 로비 화면에서 아래 문제를 해결한다:
|
||||||
|
|
||||||
|
- “정거장 3명이 대기 중” 문구가 다시 나타나는 현상 제거(재발 방지)
|
||||||
|
- 배경 별자리는 **3개만 유지**(오리온/마차부/북두칠성)
|
||||||
|
- 각 별자리의 “별(점)”이 주기적으로 은은하게 반짝이도록(twinkle) **실제 구현**
|
||||||
|
- 별자리 외 **추가 별(랜덤 스타필드 등) 생성 금지** 및 기존에 생긴 불필요 별 제거
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 문구 재발 방지(필수)
|
||||||
|
|
||||||
|
- 로비 화면에서 아래 문구가 렌더링되지 않도록 제거하고, 재도입되지 않게 한다:
|
||||||
|
- "정거장 3명 대기 중"
|
||||||
|
- "정거장 3명이 대기중"
|
||||||
|
- "대기 중" 관련 유사 문구(정거장 대기 인원 표현 포함)
|
||||||
|
- 주의: 하드코딩/데이터/컴포넌트 어디에 있든 **최종 UI에 나오면 실패**.
|
||||||
|
|
||||||
|
2. 배경 별자리 3개 고정(필수)
|
||||||
|
|
||||||
|
- 배경에는 아래 3개 별자리만 존재해야 한다(이름/레이블은 내부 코드에서만 사용해도 됨):
|
||||||
|
- 오리온
|
||||||
|
- 마차부(Auriga)
|
||||||
|
- 북두칠성(큰곰자리 일부 / Big Dipper)
|
||||||
|
- 기존에 있던 다른 별자리(거문고 등) 또는 별자리 외 요소는 제거한다.
|
||||||
|
|
||||||
|
3. 별 반짝임(twinkle) 실제 구현(필수)
|
||||||
|
|
||||||
|
- 반짝임은 **오리온/마차부/북두칠성의 별(점) 요소에만** 적용한다.
|
||||||
|
- “별자리 외 별(랜덤 점/스타필드)”에는 반짝임 적용 금지(애초에 생성 금지).
|
||||||
|
- 방식 권장:
|
||||||
|
- opacity 0.6~1.0 사이 미세 변화 + scale 0.98~1.03 미세 변화
|
||||||
|
- duration 2~6초 범위로 분산
|
||||||
|
- delay를 별마다 다르게(랜덤/고정 배열 모두 가능)
|
||||||
|
- `prefers-reduced-motion: reduce` 환경에서는 반짝임을 **정지 또는 매우 약화**.
|
||||||
|
|
||||||
|
4. 불필요 별(추가 생성 요소) 제거(필수)
|
||||||
|
|
||||||
|
- 별자리 3개 외의 별/점이 대량으로 추가되는 코드(랜덤 생성, 루프 생성, Canvas 스타필드 등)가 있다면 제거한다.
|
||||||
|
- 결과적으로 화면에 보이는 “별(점)”은 **3개 별자리를 구성하는 별(점)만**이어야 한다.
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- /flight 등 다른 페이지 로직/구조 변경 금지
|
||||||
|
- 신규 기능 추가 금지(로그인/채팅/윈도우 출항 등)
|
||||||
|
- 과도한 번쩍임/강한 플래시/깜빡임(고주파 점멸) 금지
|
||||||
|
|
||||||
|
## 적용 파일
|
||||||
|
|
||||||
|
- 필수: `@src/app/page.tsx`
|
||||||
|
- 필요 시(최소 변경): 공통 스타일/컴포넌트(별자리 SVG/스타 컴포넌트 분리하는 경우만)
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 로비 화면에서 “정거장 3명 대기 중/대기중” 및 정거장 대기 인원 문구가 **절대 표시되지 않는다**
|
||||||
|
- [ ] 배경 별자리는 **오리온/마차부/북두칠성 3개만** 존재한다
|
||||||
|
- [ ] 반짝임(twinkle)이 **각 별자리의 별(점)에 실제로 적용**되어 보인다(별마다 타이밍이 다름)
|
||||||
|
- [ ] 별자리 외 “추가 별(스타필드/랜덤 점)”이 **생성되지 않는다**
|
||||||
73
.cli/tasks/0003-lobby-twinkle-only-no-move.md
Normal file
73
.cli/tasks/0003-lobby-twinkle-only-no-move.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# .cli/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Lobby twinkle를 “반짝임만”으로 고정(이동 제거) + 별자리 3개 유지
|
||||||
|
- TASK_SLUG: lobby-twinkle-only-no-move
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
`@src/app/page.tsx` 로비 배경에서 “반짝임(twinkle)”이 **이동 없이** 빛만 변하도록 수정한다.
|
||||||
|
또한 배경 별자리는 **오리온/마차부/북두칠성 3개만** 유지하고, 별자리 외 추가 별(스타필드)을 제거/금지한다.
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 별자리 3개 고정(필수)
|
||||||
|
|
||||||
|
- 배경에는 아래 3개 별자리만 존재해야 한다:
|
||||||
|
- 오리온
|
||||||
|
- 마차부(Auriga)
|
||||||
|
- 북두칠성(Big Dipper)
|
||||||
|
- 다른 별자리(또는 별자리 외 장식 요소)가 있으면 제거한다.
|
||||||
|
|
||||||
|
2. “반짝임만” 구현(필수)
|
||||||
|
|
||||||
|
- 반짝임은 **별자리의 별(점) 요소에만** 적용한다.
|
||||||
|
- 반짝임 방식:
|
||||||
|
- `opacity` 변화만 허용(권장 범위: 0.6 ~ 1.0)
|
||||||
|
- (선택) `scale`은 0.99 ~ 1.01 이내에서만 허용(없어도 됨)
|
||||||
|
- 별마다 타이밍이 다르게:
|
||||||
|
- duration 2~6초 분산
|
||||||
|
- delay도 별마다 분산(랜덤 또는 고정 배열)
|
||||||
|
|
||||||
|
3. 이동/드리프트/패럴럭스 금지(최우선)
|
||||||
|
|
||||||
|
- 아래는 **전부 금지**이며, 존재하면 제거한다:
|
||||||
|
- translate / rotate / position 이동(left/top/x/y 변화)
|
||||||
|
- Canvas 스타필드/별 이동 애니메이션
|
||||||
|
- 배경 스크롤링/드리프트/패럴럭스(별이 흐르는 연출)
|
||||||
|
- 별(점)의 좌표는 시간에 따라 **절대 변하지 않아야 한다.**
|
||||||
|
|
||||||
|
4. 추가 별(스타필드) 금지(필수)
|
||||||
|
|
||||||
|
- 별자리 외의 랜덤 점/별을 생성하는 코드가 있으면 제거한다.
|
||||||
|
- 결과적으로 화면에 보이는 별(점)은 **3개 별자리를 구성하는 점들만**이어야 한다.
|
||||||
|
|
||||||
|
5. 모션 접근성(필수)
|
||||||
|
|
||||||
|
- `prefers-reduced-motion: reduce` 환경에서는 반짝임을 **정지**하거나 매우 약하게 만든다(정지 우선).
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 로비의 버튼 구성/CTA/항로 구성 변경 금지(이번 작업은 배경 twinkle 품질/제약 정리 중심)
|
||||||
|
- /flight 등 다른 페이지 변경 금지
|
||||||
|
- 신규 기능 추가 금지
|
||||||
|
|
||||||
|
## 적용 파일
|
||||||
|
|
||||||
|
- 필수: `@src/app/page.tsx`
|
||||||
|
- 필요 시(최소 변경): 공통 스타일/컴포넌트 파일(별자리 SVG/스타 컴포넌트를 분리하는 경우만)
|
||||||
|
- (권장) 별자리 점 요소에는 식별 가능한 class/data-attribute를 부여해 twinkle 대상이 명확하도록 한다.
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 배경 별자리는 오리온/마차부/북두칠성 **3개만** 존재한다
|
||||||
|
- [ ] 별(점)은 **위치 이동이 전혀 없고**, 빛(opacity)만 변한다(반짝임만)
|
||||||
|
- [ ] translate/rotate/드리프트/패럴럭스/스타필드(Canvas 포함) 코드가 존재하지 않는다
|
||||||
|
- [ ] 별자리 외 “추가 별(랜덤 점/스타필드)”이 생성되지 않는다
|
||||||
|
- [ ] prefers-reduced-motion에서 반짝임이 정지(또는 매우 약화)된다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정된 파일 경로 목록
|
||||||
|
- AC 항목별 체크 결과(OK/NO)
|
||||||
90
.cli/tasks/0004-lobby-glint-twinkle-opacity-only.md
Normal file
90
.cli/tasks/0004-lobby-glint-twinkle-opacity-only.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# .cli/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Lobby 별 반짝임을 “빛 번짐(십자/팔각 글린트)”으로 구현(이동 없음)
|
||||||
|
- TASK_SLUG: lobby-glint-twinkle-opacity-only
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
`@src/app/page.tsx` 로비 배경의 별자리(오리온/마차부/북두칠성)에서,
|
||||||
|
별(점)이 단순 점멸이 아니라 **십자(4-point) 또는 팔각(8-point) 형태의 빛 번짐(glint)**으로 반짝이도록 개선한다.
|
||||||
|
단, **어떠한 이동/드리프트/패럴럭스도 발생하면 안 된다.**
|
||||||
|
|
||||||
|
## 전제(현재 상태)
|
||||||
|
|
||||||
|
- 배경 별자리는 3개(오리온/마차부/북두칠성)만 유지한다.
|
||||||
|
- 기존에 랜덤 별(스타필드) 생성 코드가 있다면 이번 작업에서 제거 대상이다(별자리 외 별 0).
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 글린트(빛 번짐) 형태 구현(필수)
|
||||||
|
|
||||||
|
- 각 별은 아래 구성 요소로 표현한다(권장: SVG):
|
||||||
|
- 핵심 별: `circle`
|
||||||
|
- 글린트: `line` 2개(십자) 또는 4개(팔각 = 십자+대각선)
|
||||||
|
- 글린트는 “항상 보이는 선”이 아니라, 반짝일 때만 **opacity가 올라왔다 내려가야** 한다.
|
||||||
|
- 핵심 별(circle)도 약한 opacity 변화는 가능하나, 주역은 글린트가 되어야 한다.
|
||||||
|
|
||||||
|
2. 애니메이션은 opacity만(최우선)
|
||||||
|
|
||||||
|
- 아래는 전부 금지(존재하면 제거):
|
||||||
|
- `transform` 전부(translate/scale/rotate 포함)
|
||||||
|
- `transition-transform`
|
||||||
|
- position 이동(left/top/x/y 변화)
|
||||||
|
- Canvas 스타필드 / requestAnimationFrame 기반 이동
|
||||||
|
- 배경 드리프트/패럴럭스/스크롤링
|
||||||
|
- 반짝임 애니메이션은 **opacity만** 변화하도록 구현한다.
|
||||||
|
- (선택) circle의 opacity는 0.65~1.0 범위, 글린트는 0~0.85 범위 권장.
|
||||||
|
|
||||||
|
3. 별별 랜덤 타이밍 분산(필수)
|
||||||
|
|
||||||
|
- 별마다 애니메이션 타이밍이 약간씩 다르게 보이도록:
|
||||||
|
- duration: 2~6초 범위 분산
|
||||||
|
- delay: 별마다 분산
|
||||||
|
- 랜덤은 “렌더 시 고정”이어야 한다(매 프레임 바뀌는 랜덤 금지).
|
||||||
|
|
||||||
|
4. 모션 접근성(필수)
|
||||||
|
|
||||||
|
- `prefers-reduced-motion: reduce` 환경에서는:
|
||||||
|
- 애니메이션을 끄거나(정지 우선) 아주 약하게
|
||||||
|
- 글린트는 opacity를 매우 낮게 고정(예: 0.1~0.2)
|
||||||
|
|
||||||
|
5. 별자리 3개 고정 & 추가 별 금지(필수)
|
||||||
|
|
||||||
|
- 배경 별자리는 오리온/마차부/북두칠성 3개만 존재해야 한다.
|
||||||
|
- 별자리 외 추가 별(랜덤 점/스타필드)은 생성되지 않아야 한다.
|
||||||
|
|
||||||
|
## 구현 가이드(권장 방향)
|
||||||
|
|
||||||
|
- `page.tsx` 내 별자리 SVG 렌더링에서 “Star” 컴포넌트를 만들고,
|
||||||
|
별 좌표(cx,cy)마다 `circle + glint lines`를 렌더링한다.
|
||||||
|
- CSS는 Tailwind `@layer utilities` 또는 모듈 CSS로 작성하되,
|
||||||
|
keyframes에는 **opacity만** 포함한다.
|
||||||
|
- 글린트 선은 `strokeLinecap="round"`를 사용하고 너무 굵지 않게(0.6~1.0) 유지한다.
|
||||||
|
- 필요하다면 `feGaussianBlur` 기반의 아주 약한 glow 필터를 추가할 수 있으나,
|
||||||
|
성능/가독성에 영향이 없도록 최소화한다(선택).
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 로비의 CTA/항로 카드 구성 변경 금지(이번 작업은 배경 별 반짝임 연출 품질 개선)
|
||||||
|
- /flight 등 다른 페이지 변경 금지
|
||||||
|
- 신규 기능 추가 금지
|
||||||
|
|
||||||
|
## 적용 파일
|
||||||
|
|
||||||
|
- 필수: `@src/app/page.tsx`
|
||||||
|
- 필요 시(최소 변경): 글로벌 CSS(유틸리티 keyframes 추가가 필요한 경우만)
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 글린트(십자/팔각) 형태의 빛 번짐이 별(점)에서 실제로 보인다
|
||||||
|
- [ ] 별자리 관련 어떤 요소에도 transform/translate/scale/rotate가 적용되지 않는다(opactiy only)
|
||||||
|
- [ ] 별자리 외 랜덤 별(스타필드)이 생성되지 않는다(추가 별 0)
|
||||||
|
- [ ] 별마다 반짝임 타이밍이 다르게 느껴진다(duration/delay 분산)
|
||||||
|
- [ ] prefers-reduced-motion에서 애니메이션이 정지 또는 매우 약화된다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정된 파일 경로 목록
|
||||||
|
- AC 항목별 체크 결과(OK/NO)
|
||||||
82
.cli/tasks/0005-lobby-glint-cross-bloom-like-ref.md
Normal file
82
.cli/tasks/0005-lobby-glint-cross-bloom-like-ref.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# .gemini/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Lobby 별 반짝임을 “십자 글린트 + 블룸(이미지 참고)”로 개선(이동/transform 0)
|
||||||
|
- TASK_SLUG: lobby-glint-cross-bloom-like-ref
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
`@src/app/page.tsx` 로비 배경의 별자리(오리온/마차부/북두칠성) 별(점)을
|
||||||
|
첨부 이미지처럼 “십자(4-point) 글린트 + 부드러운 빛 번짐(bloom)” 느낌으로 반짝이게 만든다.
|
||||||
|
|
||||||
|
## 핵심 제약(최우선)
|
||||||
|
|
||||||
|
- 애니메이션은 **opacity만** 허용한다.
|
||||||
|
- `transform`(scale/translate/rotate 포함) 사용 **절대 금지**.
|
||||||
|
- 대각선(×) 글린트 금지. **십자(+)만** 사용.
|
||||||
|
- 별자리 외 랜덤 별/스타필드/Canvas 이동 연출 금지.
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 별자리 3개 고정(필수)
|
||||||
|
|
||||||
|
- 배경에는 오리온/마차부/북두칠성 3개만 존재.
|
||||||
|
- 별자리 외 추가 별(랜덤 점/스타필드)은 0.
|
||||||
|
|
||||||
|
2. “이미지 같은” 글린트 형태(필수, SVG 권장)
|
||||||
|
각 별은 아래 구조로 표현한다:
|
||||||
|
|
||||||
|
- Core: 작은 `circle` (중앙 빛)
|
||||||
|
- Spikes: 수평 line 1개 + 수직 line 1개 (십자)
|
||||||
|
- Spikes는 “끝이 뚝 끊기지 않고” **양 끝으로 갈수록 서서히 투명해져야 함**:
|
||||||
|
- `stroke`를 단색이 아니라 `linearGradient`로 처리(중앙 흰색, 양끝 투명)
|
||||||
|
- Bloom: core/스파이크에 **아주 약한 blur glow**를 1겹만 추가
|
||||||
|
- 방법: (권장) 스파이크를 2번 그리기
|
||||||
|
- 1. blur+낮은 opacity(블룸용)
|
||||||
|
- 2. 선명한 라인(본선)
|
||||||
|
- 또는 SVG filter(feGaussianBlur) 1개를 최소로 사용
|
||||||
|
|
||||||
|
3. 반짝임 리듬(필수)
|
||||||
|
|
||||||
|
- 글린트는 항상 켜져 있지 않고, 대부분은 거의 안 보이다가 잠깐 피크:
|
||||||
|
- 기본 opacity: 0 (또는 0.02~0.05)
|
||||||
|
- 피크 순간만 0.7~0.9로 올라왔다가 빠르게 내려옴
|
||||||
|
- 별마다 duration(2~6s), delay 분산(고정 랜덤 OK, 프레임 랜덤 금지)
|
||||||
|
|
||||||
|
4. 디자인 수치(권장 고정값)
|
||||||
|
|
||||||
|
- StrokeWidth: 0.6 전후(0.5~0.8)
|
||||||
|
- Arm 길이(한쪽): 5px(작은 별) ~ 10px(밝은 별) 정도
|
||||||
|
- Core 반지름: 1.2 ~ 2.0
|
||||||
|
- Bloom(블러 라인) opacity는 본선보다 훨씬 낮게(예: 본선 1.0이면 블룸 0.25 이하)
|
||||||
|
|
||||||
|
5. 모션 접근성(필수)
|
||||||
|
|
||||||
|
- prefers-reduced-motion: reduce 에서는 애니메이션 OFF(정지 우선)
|
||||||
|
- 글린트는 opacity를 낮게 고정(예: 0.1) 또는 0
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 로비 CTA/항로 카드 구성 변경 금지
|
||||||
|
- /flight 등 다른 페이지 변경 금지
|
||||||
|
- 신규 기능 추가 금지
|
||||||
|
|
||||||
|
## 적용 파일
|
||||||
|
|
||||||
|
- 필수: `@src/app/page.tsx`
|
||||||
|
- 필요 시(최소): 글로벌 CSS에 twinkle keyframes/클래스 추가
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 글린트는 십자(+)만 존재하고 대각선(×)이 없다
|
||||||
|
- [ ] 스파이크가 그라데이션으로 자연스럽게 사라져(끝이 부드러움) 이미지와 유사한 느낌이다
|
||||||
|
- [ ] bloom이 과하지 않고 “은은한 번짐” 정도로만 보인다
|
||||||
|
- [ ] 애니메이션은 opacity만 사용하며 transform이 0이다(지터/이동 없음)
|
||||||
|
- [ ] 별자리 3개만 유지되고 추가 별/스타필드가 없다
|
||||||
|
- [ ] prefers-reduced-motion에서 애니메이션이 정지(또는 매우 약화)된다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정된 파일 경로 목록
|
||||||
|
- AC 항목별 체크 결과(OK/NO)
|
||||||
106
.cli/tasks/0006-lobby-star-glint-gradient-bloom-fix.md
Normal file
106
.cli/tasks/0006-lobby-star-glint-gradient-bloom-fix.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# .gemini/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Lobby 별(노드) UI를 “빛나는 별”로 개선(그라데이션 스파이크 + 블룸 + 강약) — 이동 없음
|
||||||
|
- TASK_SLUG: lobby-star-glint-gradient-bloom-fix
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
현재 로비 배경 별자리 결과물이 “UI 노드/플러스 아이콘”처럼 보이는 문제를 해결하고,
|
||||||
|
레퍼런스처럼 **중앙이 밝고, 십자 스파이크가 끝으로 갈수록 사라지는** “빛나는 별” 느낌으로 개선한다.
|
||||||
|
추가로 별마다 강약을 줘서 도식 느낌을 줄인다.
|
||||||
|
|
||||||
|
## 핵심 제약(최우선)
|
||||||
|
|
||||||
|
- 애니메이션은 **opacity만** 허용한다.
|
||||||
|
- `transform`(scale/translate/rotate 포함) 사용 절대 금지.
|
||||||
|
- 십자(+)만 사용(대각선 × 금지).
|
||||||
|
- 별자리 외 랜덤 별/스타필드/Canvas 이동 연출 금지.
|
||||||
|
- 별(점) 위치는 절대 변하지 않아야 한다.
|
||||||
|
|
||||||
|
## 개선 포인트(반드시 반영)
|
||||||
|
|
||||||
|
1. 스파이크가 “딱 끊기는 선”처럼 보임 → **그라데이션 스트로크**로 끝이 서서히 사라지게
|
||||||
|
2. 빛 번짐이 없음 → **블룸(1겹)** 추가(과하지 않게)
|
||||||
|
3. 중심 원이 버튼/노드처럼 큼 → **코어는 더 작게**, 대신 은은한 글로우/하이라이트
|
||||||
|
4. 전체가 균일해 도식 느낌 → 별마다 **강약(크기/스파이크 길이/피크 빈도)** 분산
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
### 1) 스파이크 그라데이션(필수)
|
||||||
|
|
||||||
|
- 각 별의 십자 스파이크(수평/수직)는 단색 stroke가 아니라 다음 형태로 구현:
|
||||||
|
- 중앙(코어 근처)은 밝게
|
||||||
|
- 양 끝으로 갈수록 투명(0)으로 페이드아웃
|
||||||
|
- 구현 방식(권장): SVG `linearGradient`
|
||||||
|
- 수평: x1=0% → x2=100% (중앙이 밝고 양끝 투명)
|
||||||
|
- 수직: y1=0% → y2=100% (중앙이 밝고 양끝 투명)
|
||||||
|
|
||||||
|
### 2) 블룸(빛 번짐) 1겹 추가(필수)
|
||||||
|
|
||||||
|
- 스파이크를 2겹으로 그린다:
|
||||||
|
1. **블룸 레이어**: 더 굵고(strokeWidth +0.6~1.2), opacity 낮게(<= 0.25), 약한 blur(가능하면)
|
||||||
|
2. **본선 레이어**: 얇고 선명(strokeWidth 0.5~0.8), opacity는 피크에서만 올라가게
|
||||||
|
- blur는 성능을 위해 최소:
|
||||||
|
- 가능하면 SVG filter(feGaussianBlur) 1개만 사용하거나,
|
||||||
|
- filter 없이도 “굵고 투명한 1겹”만으로 블룸 느낌을 만들 수 있음(우선 이쪽 권장)
|
||||||
|
|
||||||
|
### 3) 코어(중심 원) 개선(필수)
|
||||||
|
|
||||||
|
- 중심 원을 “큰 단색 원”에서 “작은 밝은 코어 + 은은한 글로우”로 변경:
|
||||||
|
- 코어 circle r: 1.2~1.8
|
||||||
|
- 글로우 circle(선택): r을 더 크게(2.8~4.0) 하고 opacity 매우 낮게(<= 0.12)
|
||||||
|
- 코어는 항상 보이되, 살짝만(0.8~1.0) opacity 변화는 허용
|
||||||
|
|
||||||
|
### 4) 반짝임 리듬(필수, opacity only)
|
||||||
|
|
||||||
|
- 스파이크는 기본 opacity 거의 0(또는 0.02~0.05)로 유지하다가,
|
||||||
|
**짧게 피크를 찍는 형태**로 반짝이게 한다.
|
||||||
|
- 권장 keyframe(예시 개념):
|
||||||
|
- 0%, 35%, 100%: 0
|
||||||
|
- 50%: 0.85
|
||||||
|
- 60%: 0.1
|
||||||
|
- 별마다 duration(2~6초), delay 분산(렌더 시 고정)
|
||||||
|
|
||||||
|
### 5) 별별 강약 분산(필수)
|
||||||
|
|
||||||
|
- 모든 별이 같은 크기/스파이크 길이면 도식처럼 보이므로,
|
||||||
|
별마다 아래 중 2개 이상을 분산 적용:
|
||||||
|
- 코어 r (예: 1.2~1.9)
|
||||||
|
- 스파이크 arm 길이(예: 5~11)
|
||||||
|
- 스파이크 피크 opacity(예: 0.6~0.9)
|
||||||
|
- duration/delay
|
||||||
|
- 단, 과하게 다양하면 산만하니 “2~3단계 티어”로만 나눠도 됨(작은 별/중간 별/밝은 별)
|
||||||
|
|
||||||
|
### 6) 모션 접근성(필수)
|
||||||
|
|
||||||
|
- prefers-reduced-motion: reduce 에서는:
|
||||||
|
- 애니메이션 OFF(정지 우선)
|
||||||
|
- 스파이크는 아주 낮은 opacity(<= 0.08) 또는 0
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 로비 CTA/항로 카드 구성 변경 금지
|
||||||
|
- /flight 등 다른 페이지 변경 금지
|
||||||
|
- 신규 기능 추가 금지
|
||||||
|
|
||||||
|
## 적용 파일
|
||||||
|
|
||||||
|
- 필수: `@src/app/page.tsx`
|
||||||
|
- 필요 시(최소): 글로벌 CSS에 keyframes/유틸리티 클래스 추가
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 스파이크가 그라데이션으로 자연스럽게 사라져 “플러스 아이콘” 느낌이 줄었다
|
||||||
|
- [ ] 블룸(은은한 번짐)으로 ‘빛’ 느낌이 생겼다(과하지 않음)
|
||||||
|
- [ ] 코어가 버튼처럼 크지 않고, 작은 밝은 점 + 은은한 글로우로 보인다
|
||||||
|
- [ ] 별마다 강약이 달라 도식 느낌이 줄었다
|
||||||
|
- [ ] 애니메이션은 opacity만 사용하며 transform이 0이다(이동/지터 없음)
|
||||||
|
- [ ] 별자리 외 추가 별/스타필드가 없다
|
||||||
|
- [ ] prefers-reduced-motion에서 애니메이션이 정지(또는 매우 약화)된다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정된 파일 경로 목록
|
||||||
|
- AC 항목별 체크 결과(OK/NO)
|
||||||
87
.cli/tasks/0007-flight-starfield-diagonal-from-center.md
Normal file
87
.cli/tasks/0007-flight-starfield-diagonal-from-center.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# .gemini/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Flight 스타필드 방향 수정(중심→4모서리 대각) + 중심부 자연화 + 별 개수 축소
|
||||||
|
- TASK_SLUG: flight-starfield-diagonal-from-center
|
||||||
|
|
||||||
|
## 현재 문제(관찰)
|
||||||
|
|
||||||
|
- 현재 flight 배경 별들이 “위→아래”처럼 흐르는 느낌이 강함(좌상→좌하, 우상→우하).
|
||||||
|
- 실제 유영/전진 느낌은 “중심(시선)에서 바깥으로 퍼져나가며 모서리로 흘러가는” 형태가 필요.
|
||||||
|
- 중심을 점 1개로 두면 어색하고 인공적으로 보임.
|
||||||
|
- 별(스트릭) 개수가 너무 많아 산만함.
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
`@src/app/flight/page.tsx`의 배경 스타필드를 다음처럼 변경한다:
|
||||||
|
|
||||||
|
1. 별 이동 방향을 **중심부 → 화면 4개 모서리(대각선) 방향**으로 재구성한다.
|
||||||
|
2. 중심부(소실점)가 어색하지 않도록 “영역”으로 자연스럽게 처리한다.
|
||||||
|
3. 별(스트릭) 수를 줄여 과밀/산만함을 해소한다.
|
||||||
|
4. 타이머/버튼 등 기존 UI는 그대로 유지하고 방해하지 않는다.
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
### 1) 이동 방향(필수)
|
||||||
|
|
||||||
|
- 별은 화면 중심(또는 중심 근처 영역)에서 생성/등장한 뒤,
|
||||||
|
**NE / NW / SE / SW 네 방향(모서리 대각선)**으로 흘러가야 한다.
|
||||||
|
- “위에서 아래로 떨어지는” 인상(수직 낙하/편향)은 금지.
|
||||||
|
- 구현 가이드(권장):
|
||||||
|
- 각 별에 quadrant(NE/NW/SE/SW) 할당
|
||||||
|
- 중심 벡터(해당 모서리 방향) + 작은 각도 노이즈(±5~15도)로 자연스러운 퍼짐
|
||||||
|
- 속도는 랜덤 티어(느림/중간/빠름)로 분산
|
||||||
|
|
||||||
|
### 2) 중심부 처리(필수)
|
||||||
|
|
||||||
|
- 중심이 “점 1개”처럼 보이지 않게 해야 한다.
|
||||||
|
- 구현 방식 중 택1(권장 순):
|
||||||
|
1. 별 생성 위치를 중심 ‘반경 r(예: 20~60px)’ 안에서 랜덤 분포(중심 영역)
|
||||||
|
2. 중심부에 아주 약한 그라데이션/베일(예: 작은 타원형 glow)을 깔아 소실점을 완화
|
||||||
|
- 중심부가 과하게 밝아져 UI를 방해하면 안 됨(은은하게).
|
||||||
|
|
||||||
|
### 3) 별(스트릭) 밀도/개수 축소(필수)
|
||||||
|
|
||||||
|
- 별이 “너무 많다” 문제 해결:
|
||||||
|
- 기본 별 개수를 현재 대비 **명확히 감소** (권장: 40~90개 범위)
|
||||||
|
- 동시에 화면 전체가 텅 비지 않게 “티어+리사이클”로 유지
|
||||||
|
- 별 크기/밝기/길이도 분산하되 과한 번쩍임 금지.
|
||||||
|
|
||||||
|
### 4) 성능 및 구현 방식(권장)
|
||||||
|
|
||||||
|
- Canvas 사용 중이면 Canvas 유지(권장). DOM 요소 다량 생성은 지양.
|
||||||
|
- requestAnimationFrame 루프는 유지하되,
|
||||||
|
- 불필요한 재생성/과도한 오브젝트 수 금지
|
||||||
|
- 리사이클(화면 밖으로 나가면 중심 근처로 재스폰)
|
||||||
|
- 배경 레이어는 `pointer-events: none` 유지.
|
||||||
|
|
||||||
|
### 5) 모션 접근성(필수)
|
||||||
|
|
||||||
|
- `prefers-reduced-motion: reduce`에서는:
|
||||||
|
- 별 이동을 정지하거나(정지 우선) 속도를 크게 낮춘다.
|
||||||
|
- 배경이 정지해도 UI 사용에 문제 없게.
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 타이머 로직/버튼 동작/항해실 UI 요소 추가 금지(기능 유지).
|
||||||
|
- 다른 페이지 수정 금지(필요 시 공통 스타일만 최소 변경 허용).
|
||||||
|
- 신규 기능 추가 금지.
|
||||||
|
|
||||||
|
## 적용 파일
|
||||||
|
|
||||||
|
- 필수: `@src/app/flight/page.tsx`
|
||||||
|
- (허용) 필요 시 최소한의 스타일 파일(모션 접근성/배경 레이어 관련)
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 별 흐름이 “중심부 → 4모서리 대각선 방향”으로 명확히 보인다(수직 낙하 인상 없음)
|
||||||
|
- [ ] 중심부가 점 1개처럼 어색하지 않고 자연스럽다(영역/베일 처리)
|
||||||
|
- [ ] 별(스트릭) 수가 눈에 띄게 줄어 산만함이 감소했다
|
||||||
|
- [ ] UI(타이머/버튼/라벨) 가독성/클릭이 방해받지 않는다(pointer-events none)
|
||||||
|
- [ ] prefers-reduced-motion에서 이동이 정지 또는 크게 약화된다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정된 파일 경로 목록
|
||||||
|
- AC 항목별 체크 결과(OK/NO)
|
||||||
97
.cli/tasks/0008-flight-starfield-calm-forward-low-density.md
Normal file
97
.cli/tasks/0008-flight-starfield-calm-forward-low-density.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# .gemini/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Flight 스타필드(이미지 없이) — “조용한 우주” 점 중심 + 약한 전진감 + 교차 0 (저밀도 튜닝)
|
||||||
|
- TASK_SLUG: flight-starfield-calm-forward-low-density
|
||||||
|
|
||||||
|
## 현재 문제(관찰)
|
||||||
|
|
||||||
|
- 별이 길게 늘어난 스트릭이 많아 산만하고, 교차가 생기면 X자 워프처럼 보인다.
|
||||||
|
- 중앙(타이머) 주변까지 별이 과하게 지나가면 UI 몰입이 깨진다.
|
||||||
|
- 별이 많아 보이는(과밀) 인상이 강하다.
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
`@src/app/flight/page.tsx` 배경 스타필드를 “조용한 우주” 스타일로 재구성한다:
|
||||||
|
|
||||||
|
1. 대부분은 **정적인 점**에 가깝고,
|
||||||
|
2. **아주 약한 전진감(같은 방향의 미세 이동)**만 느껴지며,
|
||||||
|
3. 별(선) 교차/X자 인상이 **절대** 생기지 않도록 한다.
|
||||||
|
4. 별 개수를 “저밀도”로 튜닝해 산만함을 줄인다.
|
||||||
|
5. UI는 변경하지 않는다(배경만).
|
||||||
|
|
||||||
|
## 핵심 스타일 규격(이미지 없이 구현 가능한 스펙)
|
||||||
|
|
||||||
|
### A. 방향/움직임 (교차 0이 최우선)
|
||||||
|
|
||||||
|
- 별 이동은 **하나의 주 방향 벡터(vBase)**만 사용한다.
|
||||||
|
- 예: (dx=+0.15, dy=+1.0) 처럼 “아주 약한 대각 + 아래” 한 방향
|
||||||
|
- 모든 별은 vBase의 **x 부호가 동일**해야 한다(좌/우 반대 섞기 금지)
|
||||||
|
- 별마다 각도 노이즈는 아주 작게(±3~6도)만 허용.
|
||||||
|
- 속도는 “느림 위주”:
|
||||||
|
- 85~95%: 느림
|
||||||
|
- 5~15%: 중간
|
||||||
|
- 빠른 레이어 금지
|
||||||
|
|
||||||
|
### B. 별 모양(점 중심)
|
||||||
|
|
||||||
|
- 90~97%는 “점” 또는 “매우 짧은 꼬리(2~5px)”만
|
||||||
|
- 3~10%만 “중간 꼬리(6~14px)” 허용
|
||||||
|
- 매우 긴 꼬리(> 16px) 금지
|
||||||
|
- 밝기는 절제(대부분 낮게, 소수만 약간 밝게)
|
||||||
|
|
||||||
|
### C. 밀도(저밀도) — 이번 작업의 핵심
|
||||||
|
|
||||||
|
- 별 개수는 기존보다 확실히 줄인다.
|
||||||
|
- 권장 개수(고정값 상한):
|
||||||
|
- 데스크탑: **18 ~ 45**
|
||||||
|
- 모바일: **12 ~ 30**
|
||||||
|
- 가능하면 개수를 화면 면적에 따라 스케일하되, 위 상한을 넘지 말 것:
|
||||||
|
- `count = clamp(round((width*height)/K), min, max)`
|
||||||
|
- K는 “너무 많아 보이지 않는” 수준으로 크게(예: 30,000~60,000 사이에서 조정)
|
||||||
|
- 중앙 UI 보호:
|
||||||
|
- 타이머 중심 기준 반경 R(예: 150~230px) 영역은 스폰 확률↓ 또는 밝기↓
|
||||||
|
|
||||||
|
### D. 재스폰/리사이클(일관된 흐름 유지)
|
||||||
|
|
||||||
|
- 별이 화면 밖으로 나가면 재스폰.
|
||||||
|
- 재스폰 위치는 흐름 시작 가장자리에서 생성(방향 일관 유지).
|
||||||
|
- 재스폰 후에도 방향은 동일(반대 방향 튀기 금지).
|
||||||
|
|
||||||
|
### E. 기술/성능(권장)
|
||||||
|
|
||||||
|
- Canvas 기반이면 Canvas 유지 권장.
|
||||||
|
- requestAnimationFrame 유지.
|
||||||
|
- 배경 레이어 `pointer-events: none` 유지.
|
||||||
|
- 과도한 blur/고비용 필터 금지.
|
||||||
|
|
||||||
|
### F. 모션 접근성(필수)
|
||||||
|
|
||||||
|
- prefers-reduced-motion: reduce:
|
||||||
|
- 이동 정지(우선) 또는 속도 10~20%로 감속
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 타이머/버튼/미션 등 UI 레이아웃 변경 금지(배경만).
|
||||||
|
- 타이머 로직/상태 저장 변경 금지.
|
||||||
|
- 신규 기능 추가 금지.
|
||||||
|
- 다른 페이지 변경 금지.
|
||||||
|
|
||||||
|
## 적용 파일
|
||||||
|
|
||||||
|
- 필수: `@src/app/flight/page.tsx`
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 별 이동이 “한 방향 전진감”으로만 보이며 X자 교차 인상이 없다(좌/우 반대 혼합 0)
|
||||||
|
- [ ] 별의 대부분이 점(또는 매우 짧은 꼬리)로 보이고, 긴 스트릭이 거의 없다
|
||||||
|
- [ ] 별 개수가 데스크탑 18~45 / 모바일 12~30 범위로 유지되어 산만함이 감소했다
|
||||||
|
- [ ] 중앙(타이머) 가독성이 유지된다(중앙 보호 규칙 적용)
|
||||||
|
- [ ] UI는 변경되지 않았다
|
||||||
|
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화된다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정된 파일 경로 목록
|
||||||
|
- AC 항목별 체크 결과(OK/NO)
|
||||||
107
.cli/tasks/0009-flight-starfield-forward-visible-no-dust.md
Normal file
107
.cli/tasks/0009-flight-starfield-forward-visible-no-dust.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# .gemini/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Flight 스타필드 가시성/전진감 개선 — “먼지 낙하” 제거 + 원근 전진(소실점) + 저밀도 유지
|
||||||
|
- TASK_SLUG: flight-starfield-forward-visible-no-dust
|
||||||
|
|
||||||
|
## 현재 문제(관찰)
|
||||||
|
|
||||||
|
- “한 방향 전진감”을 적용했더니 별이 위→아래로 떨어지는 먼지처럼 보인다.
|
||||||
|
- 별이 너무 희미하고 꼬리가 짧아 거의 보이지 않는다.
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
`@src/app/flight/page.tsx` 배경 스타필드를 다음처럼 조정한다:
|
||||||
|
|
||||||
|
1. 낙하(먼지) 느낌을 제거하고 **원근 기반 전진감(소실점/투영)**으로 보이게 한다.
|
||||||
|
2. 저밀도(산만하지 않게)는 유지하되, 별이 **확실히 ‘보일 정도’로 가시성**을 올린다.
|
||||||
|
3. X자 교차/워프 느낌은 만들지 않는다(과한 방사형/대칭 금지).
|
||||||
|
4. UI는 변경하지 않는다(배경만).
|
||||||
|
|
||||||
|
## 핵심 구현 방향(권장: 3D 투영 스타필드)
|
||||||
|
|
||||||
|
- 별을 (x,y,z)로 관리하고 z를 프레임마다 감소시키며 투영:
|
||||||
|
- 소실점(vp)은 화면 정중앙보다 **살짝 위**(예: centerY - 40~90px)
|
||||||
|
- 투영: sx = vpX + (x - vpX) / z, sy = vpY + (y - vpY) / z
|
||||||
|
- z가 작아질수록 별이 바깥으로 “퍼져 나가며” 전진감이 생김
|
||||||
|
- “먼지 낙하”가 생기는 vBase 수직 이동 방식은 제거하거나, 투영 방식으로 전환한다.
|
||||||
|
|
||||||
|
## 스타일 규격(이미지 없이 구현 가능)
|
||||||
|
|
||||||
|
### A) 가시성(필수: ‘안 보임’ 해결)
|
||||||
|
|
||||||
|
- 별 밝기(alpha) 최소치를 둔다:
|
||||||
|
- 일반 별: alpha 0.25 ~ 0.55
|
||||||
|
- 강조 별(10~20%): alpha 0.55 ~ 0.85
|
||||||
|
- 별 크기:
|
||||||
|
- 일반: radius 0.7 ~ 1.2
|
||||||
|
- 강조: radius 1.2 ~ 1.8
|
||||||
|
- 색상은 흰색 계열 유지(과한 색 금지)
|
||||||
|
|
||||||
|
### B) 속도/전진감(필수)
|
||||||
|
|
||||||
|
- 너무 느리면 먼지/정지처럼 보임 → 최소 속도 확보:
|
||||||
|
- z 감소량(또는 speed)은 티어로:
|
||||||
|
- 느림(70~85%): 0.006 ~ 0.012
|
||||||
|
- 중간(15~30%): 0.012 ~ 0.022
|
||||||
|
- 빠름(0~5%): 0.022 ~ 0.030 (과하면 워프 느낌, 최소로)
|
||||||
|
- “한 방향”은 수직 낙하가 아니라 **소실점 기반 투영**으로 만든다.
|
||||||
|
|
||||||
|
### C) 꼬리(스트릭)는 ‘소수만’ + 길이는 보일 정도로(필수)
|
||||||
|
|
||||||
|
- 별의 75~90%는 점(꼬리 없음 또는 1~3px)
|
||||||
|
- 10~25%만 짧은 꼬리:
|
||||||
|
- 길이: 4~12px (현재 “안 보임” 이슈 해결을 위해 하한 4px)
|
||||||
|
- 긴 꼬리(> 16px) 금지
|
||||||
|
- 꼬리는 별 이동 벡터의 반대 방향으로 그린다(선형)
|
||||||
|
|
||||||
|
### D) X자/과한 방사형(워프) 방지(필수)
|
||||||
|
|
||||||
|
- 소실점(vp)을 정중앙에 두지 말고 **살짝 오프셋**(위로 + 약간 좌/우 10~30px 랜덤 고정)하여 완벽한 대칭을 깨라.
|
||||||
|
- FOV(퍼짐 정도)가 과하면 워프처럼 보임 → z 범위와 투영 스케일을 조정해 “부드럽게” 퍼지도록.
|
||||||
|
- 별 개수는 적게 유지해 과한 선 교차 인상을 줄인다.
|
||||||
|
|
||||||
|
### E) 밀도(저밀도 유지)
|
||||||
|
|
||||||
|
- 데스크탑: 18 ~ 45
|
||||||
|
- 모바일: 12 ~ 30
|
||||||
|
- 중앙 UI 보호:
|
||||||
|
- 타이머 중심 반경 R(예: 150~220px)에서는
|
||||||
|
- 별 스폰 확률↓ 또는 alpha 하향(완전 공백 금지)
|
||||||
|
|
||||||
|
### F) 리사이클(필수)
|
||||||
|
|
||||||
|
- 별이 화면 밖으로 나가거나 z가 임계치 이하가 되면:
|
||||||
|
- z를 큰 값으로 재설정하고,
|
||||||
|
- x,y는 소실점 주변 영역에서 재스폰(반경 30~120px) → “중심에서 시작” 느낌 유지
|
||||||
|
|
||||||
|
### G) 모션 접근성(필수)
|
||||||
|
|
||||||
|
- prefers-reduced-motion: reduce:
|
||||||
|
- 애니메이션 정지(우선) 또는 speed 10~20%로 감속
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- UI(A안) 변경 금지
|
||||||
|
- 타이머 로직 변경 금지
|
||||||
|
- 신규 기능 추가 금지
|
||||||
|
- 다른 페이지 변경 금지
|
||||||
|
|
||||||
|
## 적용 파일
|
||||||
|
|
||||||
|
- 필수: `@src/app/flight/page.tsx`
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] “먼지 낙하(위→아래)” 인상이 사라지고, 원근 전진감(소실점 퍼짐)으로 보인다
|
||||||
|
- [ ] 별이 ‘확실히 보이는’ 밝기/크기/꼬리(하한 포함)로 개선되었다
|
||||||
|
- [ ] 과한 대칭 방사형(워프/X자) 인상이 없다(오프셋/스케일로 완화)
|
||||||
|
- [ ] 저밀도(데스크탑 18~45 / 모바일 12~30)가 유지되어 산만하지 않다
|
||||||
|
- [ ] UI는 변경되지 않았다
|
||||||
|
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화된다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정된 파일 경로 목록
|
||||||
|
- AC 항목별 체크 결과(OK/NO)
|
||||||
92
.cli/tasks/0010-flight-starfield-spawn-widen-speed-down.md
Normal file
92
.cli/tasks/0010-flight-starfield-spawn-widen-speed-down.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# .gemini/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Flight 스타필드 튜닝 — 중심 과집중 완화(넓은 스폰) + 속도 감속(유영 느낌)
|
||||||
|
- TASK_SLUG: flight-starfield-spawn-widen-speed-down
|
||||||
|
|
||||||
|
## 현재 문제(관찰)
|
||||||
|
|
||||||
|
- 별이 중심점(소실점)에서만 생성되는 느낌이 강해 인공적이다.
|
||||||
|
- 별 이동 속도가 너무 빠르게 느껴져 “유영”이 아니라 “워프/가속”처럼 보인다.
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
`@src/app/flight/page.tsx` 배경 스타필드를 아래처럼 튜닝한다:
|
||||||
|
|
||||||
|
1. 별 생성 분포를 넓혀 “중심에서만 뿜는 느낌”을 완화한다(자연스럽게).
|
||||||
|
2. 전체 속도를 낮춰 “조용한 전진/유영” 느낌으로 만든다.
|
||||||
|
3. 가시성(안 보임 문제)은 유지한다(너무 희미해지지 않게).
|
||||||
|
4. UI는 변경하지 않는다(배경만).
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
### 1) 스폰 분포 넓히기(필수)
|
||||||
|
|
||||||
|
- 별의 초기 (x,y) 분포를 “소실점 주변 아주 좁은 반경”에서만 뽑지 말 것.
|
||||||
|
- 아래 중 1개 이상을 적용해 “넓은 영역에서 자연스럽게 시작”되게:
|
||||||
|
- A) 스폰 반경 확대: vp 기준 반경 **120~360px** 범위에서 랜덤 분포
|
||||||
|
- B) 링/도넛 분포: 반경 80~320px에서 더 자주 뽑고(중심은 덜),
|
||||||
|
중심 반경 0~60px 영역은 스폰 확률을 낮춤
|
||||||
|
- C) 화면 전체 분포 + z/스케일로 전진감 유지: (x,y)는 화면 랜덤, z로 원근만 제공
|
||||||
|
- 단, “완전 랜덤”으로 바꾸면 전진감이 약해질 수 있으니,
|
||||||
|
소실점(vp)을 기준으로 하는 투영 구조는 유지하되 **초기 분포만 넓혀**라.
|
||||||
|
|
||||||
|
### 2) 속도 감속(필수)
|
||||||
|
|
||||||
|
- 전체 속도를 1차로 20~40% 낮춘다(체감상 확실히 느려져야 함).
|
||||||
|
- 속도 티어를 아래 범위로 조정(권장):
|
||||||
|
- 느림(75~90%): 0.004 ~ 0.009
|
||||||
|
- 중간(10~25%): 0.009 ~ 0.014
|
||||||
|
- 빠름(0~3%): 0.014 ~ 0.018 (가능하면 0~2%로 제한)
|
||||||
|
- “워프처럼 길게 늘어지는” 인상을 줄이기 위해,
|
||||||
|
꼬리 길이도 속도와 함께 약간 줄이되 ‘안 보임’이 생기지 않게 하한 유지:
|
||||||
|
- 꼬리 있는 별(10~25%)의 길이: **4~10px** (하한 4px 유지)
|
||||||
|
|
||||||
|
### 3) 소실점 위치(권장)
|
||||||
|
|
||||||
|
- 소실점(vp)은 화면 정중앙보다 **살짝 위** 유지(예: -40~-90px).
|
||||||
|
- 완전 대칭을 피하기 위해 x도 소폭 오프셋(±10~25px, 고정) 가능.
|
||||||
|
|
||||||
|
### 4) 밀도/가시성 유지(필수)
|
||||||
|
|
||||||
|
- 저밀도 유지:
|
||||||
|
- 데스크탑 18~45 / 모바일 12~30
|
||||||
|
- 가시성 하한 유지:
|
||||||
|
- 일반 alpha 0.25~0.55, 강조 0.55~0.85
|
||||||
|
- radius 일반 0.7~1.2, 강조 1.2~1.8
|
||||||
|
|
||||||
|
### 5) 리사이클(필수)
|
||||||
|
|
||||||
|
- 화면 밖/z 임계치 도달 시 재스폰하되,
|
||||||
|
재스폰도 위 “넓은 스폰 분포” 규칙을 적용한다(중심에만 몰지 말 것).
|
||||||
|
|
||||||
|
### 6) 모션 접근성(필수)
|
||||||
|
|
||||||
|
- prefers-reduced-motion: reduce:
|
||||||
|
- 정지(우선) 또는 speed 10~20%로 감속
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- UI(A안) 변경 금지
|
||||||
|
- 타이머 로직 변경 금지
|
||||||
|
- 신규 기능 추가 금지
|
||||||
|
- 다른 페이지 변경 금지
|
||||||
|
|
||||||
|
## 적용 파일
|
||||||
|
|
||||||
|
- 필수: `@src/app/flight/page.tsx`
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 별 생성이 중심점에 과도하게 몰리지 않고 넓은 영역에서 자연스럽게 시작한다
|
||||||
|
- [ ] 속도가 확실히 느려져 “유영/조용한 전진” 느낌이 난다(워프/가속 인상 없음)
|
||||||
|
- [ ] 가시성은 유지되어 별이 충분히 보인다(너무 희미해지지 않음)
|
||||||
|
- [ ] 저밀도 범위(데스크탑 18~45 / 모바일 12~30)가 유지된다
|
||||||
|
- [ ] UI는 변경되지 않았다
|
||||||
|
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화된다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정된 파일 경로 목록
|
||||||
|
- AC 항목별 체크 결과(OK/NO)
|
||||||
64
.cli/tasks/0011-home-stars-match-flight-keep-glint.md
Normal file
64
.cli/tasks/0011-home-stars-match-flight-keep-glint.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# .gemini/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Home 별 모양을 Flight 스타일로 통일 + glint 유지(십자/그라데이션/블룸, opacity only)
|
||||||
|
- TASK_SLUG: home-stars-match-flight-keep-glint
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
`@src/app/page.tsx`(Home)의 별(점) 모양을 flight에서 사용하는 “별 점 스타일”과 일관되게 맞춘다.
|
||||||
|
단, Home은 기존 요구대로 **glint(십자 빛 번짐)**가 반드시 존재해야 한다.
|
||||||
|
Home 별자리 3개(오리온/마차부/북두칠성) 구조는 유지한다.
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 별 코어 스타일 통일(필수)
|
||||||
|
|
||||||
|
- Home 별의 core(점/원) 크기/밝기 범위를 flight의 별 느낌과 비슷하게 조정:
|
||||||
|
- 일반: radius 0.7~1.2, alpha 0.25~0.55
|
||||||
|
- 강조: radius 1.2~1.8, alpha 0.55~0.85
|
||||||
|
- 과하게 큰 “노드/버튼” 느낌이 나면 실패.
|
||||||
|
|
||||||
|
2. glint 유지(필수)
|
||||||
|
|
||||||
|
- 각 별은 십자(+) 글린트를 가진다(대각선 × 금지).
|
||||||
|
- 스파이크는 끝으로 갈수록 사라지게(그라데이션 stroke) 구현한다.
|
||||||
|
- 블룸은 1겹만(과하지 않게).
|
||||||
|
|
||||||
|
3. 애니메이션 제약(최우선)
|
||||||
|
|
||||||
|
- Home glint/twinkle 애니메이션은 **opacity만** 변화한다.
|
||||||
|
- transform(scale/translate/rotate) 전부 금지.
|
||||||
|
- 별자리/별 위치 이동 금지.
|
||||||
|
|
||||||
|
4. 타이밍 분산(필수)
|
||||||
|
|
||||||
|
- 별마다 duration(2~6s)과 delay 분산(고정 랜덤 OK).
|
||||||
|
|
||||||
|
5. 모션 접근성(필수)
|
||||||
|
|
||||||
|
- prefers-reduced-motion: reduce 에서는 애니메이션 OFF(정지 우선) 또는 매우 약화.
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- Home의 카드/CTA/레이아웃 변경 금지(별 스타일만).
|
||||||
|
- 다른 페이지 변경 금지.
|
||||||
|
- 신규 기능 추가 금지.
|
||||||
|
|
||||||
|
## 적용 파일
|
||||||
|
|
||||||
|
- 필수: `@src/app/page.tsx`
|
||||||
|
- 필요 시(최소): 글로벌 CSS 유틸리티
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] Home 별의 core 크기/밝기가 flight의 별 느낌과 유사해졌다(노드 느낌 감소)
|
||||||
|
- [ ] Home 별에 십자(+) glint가 유지되며, 끝이 그라데이션으로 사라진다
|
||||||
|
- [ ] 애니메이션은 opacity only, transform 0, 위치 이동 0
|
||||||
|
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화된다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정된 파일 경로 목록
|
||||||
|
- AC 항목별 체크 결과(OK/NO)
|
||||||
70
.cli/tasks/0012-lobby-stars-smaller-darker-cores.md
Normal file
70
.cli/tasks/0012-lobby-stars-smaller-darker-cores.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# .gemini/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Lobby 배경 별(코어) 더 작고 더 진하게 튜닝
|
||||||
|
- TASK_SLUG: lobby-stars-smaller-darker-cores
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
`@src/app/page.tsx` 로비 배경 별자리(오리온/마차부/북두칠성)의 별(코어)이
|
||||||
|
지금보다 **반지름이 더 작고**, **색/불투명도가 더 진하게** 보이도록 조정한다.
|
||||||
|
glint(십자 빛 번짐) 및 그라데이션/블룸 구조는 유지한다.
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 코어(중심 점) 크기 축소(필수)
|
||||||
|
|
||||||
|
- 별 코어(circle) radius를 전체적으로 15~35% 축소한다.
|
||||||
|
- 권장 목표 범위:
|
||||||
|
- 일반 별: r 0.6 ~ 1.0
|
||||||
|
- 강조 별: r 1.0 ~ 1.4
|
||||||
|
- “노드/버튼”처럼 보이면 실패(작고 선명하게).
|
||||||
|
|
||||||
|
2. 코어 색/진하기 강화(필수)
|
||||||
|
|
||||||
|
- 코어가 더 “진하게” 보이도록 alpha/색을 조정한다.
|
||||||
|
- 권장 목표 범위(배경이 어두운 기준):
|
||||||
|
- 일반 별: opacity 0.55 ~ 0.85
|
||||||
|
- 강조 별: opacity 0.85 ~ 1.0
|
||||||
|
- 단, 과도한 눈부심/번쩍임 금지(특히 중앙에 몰려 보이면 실패).
|
||||||
|
|
||||||
|
3. glint/블룸은 유지하되 과하지 않게(필수)
|
||||||
|
|
||||||
|
- glint(십자)와 그라데이션 stroke는 유지한다.
|
||||||
|
- 코어를 진하게 만들면서 glint가 더 튀면 아래 중 하나 적용:
|
||||||
|
- glint 피크 opacity를 10~20% 낮춤
|
||||||
|
- 블룸 레이어 opacity를 소폭 낮춤(<= 0.20 권장)
|
||||||
|
- 목표: “작고 진한 별 + 은은한 글린트”
|
||||||
|
|
||||||
|
4. 애니메이션 제약 유지(필수)
|
||||||
|
|
||||||
|
- twinkle/glint 애니메이션은 opacity only 유지
|
||||||
|
- transform(scale/translate/rotate) 금지
|
||||||
|
- 별/별자리 위치 이동 금지
|
||||||
|
- prefers-reduced-motion: reduce 시 정지 또는 크게 약화
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 로비 카드/CTA/레이아웃 변경 금지
|
||||||
|
- 별자리 종류/개수 변경 금지(3개 유지)
|
||||||
|
- 다른 페이지 변경 금지
|
||||||
|
- 신규 기능 추가 금지
|
||||||
|
|
||||||
|
## 적용 파일
|
||||||
|
|
||||||
|
- 필수: `@src/app/page.tsx`
|
||||||
|
- (허용) 필요 시 최소 스타일 조정 파일
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 별 코어가 확실히 더 작아졌다(전체적으로 15~35% 감소 체감)
|
||||||
|
- [ ] 별 코어가 확실히 더 진하게 보인다(대비 상승)
|
||||||
|
- [ ] glint는 유지되며 과하게 튀지 않는다(필요 시 소폭 감쇠)
|
||||||
|
- [ ] opacity-only 애니메이션, transform 0, 위치 이동 0
|
||||||
|
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정된 파일 경로 목록
|
||||||
|
- AC 항목별 체크 결과(OK/NO)
|
||||||
131
.cli/tasks/0013-home-stars-smaller-and-flight-slower.md
Normal file
131
.cli/tasks/0013-home-stars-smaller-and-flight-slower.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# .gemini/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Home 별자리 별(코어) 더 작게 + Flight 스타필드 속도 추가 감속
|
||||||
|
- TASK_SLUG: home-stars-smaller-and-flight-slower
|
||||||
|
|
||||||
|
## 작업 우선순위(중요)
|
||||||
|
|
||||||
|
1. Home(`@src/app/page.tsx`) 별자리 별(코어) 크기 축소
|
||||||
|
2. Flight(`@src/app/flight/page.tsx`) 별 이동 속도 추가 감속
|
||||||
|
|
||||||
|
- 두 작업은 서로 로직을 섞지 말 것(Home SVG/glint 유지, Flight 캔버스/스타필드 유지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# A. Home 작업(필수)
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
Home의 각 별자리(오리온/마차부/북두칠성)에서 별(코어)이 지금보다 **더 작게** 보이도록 조정한다.
|
||||||
|
glint(십자)와 그라데이션/블룸, opacity-only 애니메이션 제약은 그대로 유지한다.
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 코어 크기 축소(필수)
|
||||||
|
|
||||||
|
- 별 코어(circle) radius를 전체적으로 추가로 15~30% 축소한다.
|
||||||
|
- 권장 목표 범위:
|
||||||
|
- 일반 별: r 0.5 ~ 0.9
|
||||||
|
- 강조 별: r 0.9 ~ 1.2
|
||||||
|
- “너무 희미해져 안 보임”이 생기면, 크기는 유지하고 opacity를 소폭 올려 해결한다(크기 되돌리기 금지 우선).
|
||||||
|
|
||||||
|
2. glint 유지(필수)
|
||||||
|
|
||||||
|
- 십자(+) glint 유지(대각선 × 금지)
|
||||||
|
- 그라데이션 stroke + 블룸 1겹 유지(과하지 않게)
|
||||||
|
- 코어가 작아지면서 glint가 상대적으로 커 보이면:
|
||||||
|
- glint arm 길이를 10~20% 축소 또는
|
||||||
|
- glint 피크 opacity를 10~20% 낮춘다
|
||||||
|
|
||||||
|
3. 애니메이션/모션 제약(필수)
|
||||||
|
|
||||||
|
- 애니메이션은 opacity only
|
||||||
|
- transform(scale/translate/rotate) 금지
|
||||||
|
- 별/별자리 위치 이동 금지
|
||||||
|
- prefers-reduced-motion: reduce에서 정지 또는 크게 약화
|
||||||
|
|
||||||
|
## Home Non-scope
|
||||||
|
|
||||||
|
- 로비 카드/CTA/레이아웃 변경 금지
|
||||||
|
- 별자리 3개 구성 변경 금지
|
||||||
|
- 신규 기능 추가 금지
|
||||||
|
|
||||||
|
## Home 적용 파일
|
||||||
|
|
||||||
|
- `@src/app/page.tsx`
|
||||||
|
|
||||||
|
## Home 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 별 코어가 눈에 띄게 더 작아졌다(추가 15~30% 감소 체감)
|
||||||
|
- [ ] glint(십자) 유지, 과하게 튀지 않음(필요 시 길이/피크 감쇠)
|
||||||
|
- [ ] opacity-only, transform 0, 위치 이동 0
|
||||||
|
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# B. Flight 작업(필수)
|
||||||
|
|
||||||
|
## 현재 문제(관찰)
|
||||||
|
|
||||||
|
- Flight 스타필드가 여전히 속도가 빠르게 느껴져 유영이 아니라 가속/워프처럼 보인다.
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
`@src/app/flight/page.tsx` 배경 스타필드의 체감 속도를 **추가로 감속**하여
|
||||||
|
“조용한 전진/유영” 느낌을 강화한다. (가시성은 유지)
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 속도 추가 감속(필수)
|
||||||
|
|
||||||
|
- 현재 속도 대비 체감상 15~30% 추가 감속한다.
|
||||||
|
- 권장 speed 티어(이 범위 안으로 재조정):
|
||||||
|
- 느림(80~92%): 0.003 ~ 0.007
|
||||||
|
- 중간(8~18%): 0.007 ~ 0.011
|
||||||
|
- 빠름(0~2%): 0.011 ~ 0.014
|
||||||
|
- “빠름 티어”는 가능하면 0~1%로 더 줄여도 됨.
|
||||||
|
|
||||||
|
2. 꼬리/가시성 유지(필수)
|
||||||
|
|
||||||
|
- 속도를 줄이면 꼬리가 더 안 보일 수 있으므로,
|
||||||
|
꼬리 있는 별(10~25%)의 길이 하한은 유지:
|
||||||
|
- 4~10px 범위 유지(하한 4px)
|
||||||
|
- 너무 안 보이면 “속도를 다시 올리지 말고”
|
||||||
|
alpha(밝기) 또는 꼬리 비율(점:꼬리)을 소폭 조정해 해결한다.
|
||||||
|
|
||||||
|
3. 중앙 보호/저밀도 유지(필수)
|
||||||
|
|
||||||
|
- 저밀도 범위 유지: 데스크탑 18~45 / 모바일 12~30
|
||||||
|
- 중앙 UI 가독성 유지(중앙 보호 규칙 유지)
|
||||||
|
|
||||||
|
4. 모션 접근성(필수)
|
||||||
|
|
||||||
|
- prefers-reduced-motion: reduce에서 정지(우선) 또는 크게 감속
|
||||||
|
|
||||||
|
## Flight Non-scope
|
||||||
|
|
||||||
|
- Flight UI(A안) 레이아웃/버튼/미션 변경 금지(배경만)
|
||||||
|
- 타이머 로직 변경 금지
|
||||||
|
- 신규 기능 추가 금지
|
||||||
|
|
||||||
|
## Flight 적용 파일
|
||||||
|
|
||||||
|
- `@src/app/flight/page.tsx`
|
||||||
|
|
||||||
|
## Flight 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 체감 속도가 확실히 느려져 유영/조용한 전진 느낌이 강화되었다
|
||||||
|
- [ ] 가시성이 유지된다(별이 충분히 보임)
|
||||||
|
- [ ] 저밀도 범위/중앙 가독성 유지
|
||||||
|
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화
|
||||||
|
- [ ] UI 변경 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정된 파일 경로 목록
|
||||||
|
- Home AC 체크(OK/NO)
|
||||||
|
- Flight AC 체크(OK/NO)
|
||||||
102
.cli/tasks/0014-fsd-refactor-phase1-no-behavior-change.md
Normal file
102
.cli/tasks/0014-fsd-refactor-phase1-no-behavior-change.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# .gemini/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: FSD 1차 구조로 리팩토링(동작 동일) — lobby/flight 시각 컴포넌트 및 로직 분리
|
||||||
|
- TASK_SLUG: fsd-refactor-phase1-no-behavior-change
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
현재 `@src/app/page.tsx`(Home/Lobby)와 `@src/app/flight/page.tsx`에 섞여있는
|
||||||
|
UI/스타필드/글린트/타이머 관련 코드를 **FSD 구조로 분리**하여 읽기 쉽게 만든다.
|
||||||
|
|
||||||
|
- 핵심: **동작/디자인/출력 결과는 변경하지 않는다.**
|
||||||
|
- 변경은 “파일 분리 + import 정리 + 타입 분리” 중심으로 한다.
|
||||||
|
|
||||||
|
## 리팩토링 원칙(필수)
|
||||||
|
|
||||||
|
- 기능 추가 금지 / UI 변경 금지 / 애니메이션 변경 금지
|
||||||
|
- 기존 동작과 시각 결과를 유지(가능하면 스냅샷/수동 확인)
|
||||||
|
- 변수명/함수명은 의미가 더 명확해지면 최소한으로 개선 가능(대규모 리네이밍 금지)
|
||||||
|
- CSS 유틸리티/글로벌 스타일은 이동하더라도 동일하게 동작해야 함
|
||||||
|
|
||||||
|
## 목표 FSD 디렉토리(제안, Next App Router 호환)
|
||||||
|
|
||||||
|
아래 구조로 생성/이동하라:
|
||||||
|
|
||||||
|
- `src/shared/`
|
||||||
|
- `ui/` : 재사용 UI(버튼/칩/베일 등)
|
||||||
|
- `lib/` : 유틸(랜덤 시드, clamp, prefersReducedMotion, resize observer 등)
|
||||||
|
- `config/` : 상수/튜닝 값(별 개수 범위, speed 티어 등)
|
||||||
|
|
||||||
|
- `src/features/`
|
||||||
|
- `lobby-starfield/`
|
||||||
|
- `ui/` : ConstellationScene, Star, GlintCross 등(로비 배경 전용)
|
||||||
|
- `model/` : 별자리 좌표/별 티어 데이터(오리온/마차부/북두칠성)
|
||||||
|
- `flight-starfield/`
|
||||||
|
- `ui/` : FlightStarfieldCanvas (배경 캔버스 레이어)
|
||||||
|
- `model/` : Star 객체 타입, spawn/recycle, speed tiers, vp 설정 등
|
||||||
|
- `lib/` : 투영/렌더 루프 헬퍼(순수 함수)
|
||||||
|
|
||||||
|
- `src/widgets/` (페이지에 꽂는 단위)
|
||||||
|
- `lobby-background/` : 로비 배경 위젯(장면 전체)
|
||||||
|
- `flight-background/` : flight 배경 위젯
|
||||||
|
- `flight-hud/` : flight 타이머/미션/버튼 UI 위젯(있다면)
|
||||||
|
|
||||||
|
- `src/pages/` 는 App Router라 사용하지 말 것(생성 금지)
|
||||||
|
|
||||||
|
## 구체 작업 범위
|
||||||
|
|
||||||
|
### A) Home/Lobby 분리(필수)
|
||||||
|
|
||||||
|
- `src/app/page.tsx`에서 로비 배경(별자리+glint) 관련 JSX/CSS 의존을 분리:
|
||||||
|
- 새로운 위젯/피처 컴포넌트로 이동
|
||||||
|
- page.tsx는 “레이아웃 + CTA + 배경 위젯 호출”만 남긴다
|
||||||
|
- 별자리 좌표/티어/스타 크기/opacity 등의 상수는 `shared/config` 또는 `features/lobby-starfield/model`로 이동
|
||||||
|
|
||||||
|
### B) Flight 분리(필수)
|
||||||
|
|
||||||
|
- `src/app/flight/page.tsx`에서 스타필드 캔버스 로직을 분리:
|
||||||
|
- rAF 루프, star state, spawn/recycle, speed tiers 등은 `features/flight-starfield`로 이동
|
||||||
|
- page.tsx는 “HUD(UI) + 배경 위젯 호출”만 남긴다
|
||||||
|
- prefers-reduced-motion 처리도 shared/lib로 이동(중복 제거)
|
||||||
|
|
||||||
|
### C) import 정리(필수)
|
||||||
|
|
||||||
|
- 절대경로/alias 사용 규칙이 있으면 그것에 맞추고, 없으면 통일(한 방식만)
|
||||||
|
- barrel export(index.ts)는 필요 최소로만(과도한 index 남발 금지)
|
||||||
|
|
||||||
|
### D) 타입/상수 정리(권장)
|
||||||
|
|
||||||
|
- star 타입, 티어 타입, 좌표 타입 분리
|
||||||
|
- “튜닝 값”을 한 파일에 모아 관리 가능하게(예: `shared/config/starfield.ts`)
|
||||||
|
- 단, 값 자체는 바꾸지 말 것(동작 유지)
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 디자인 변경, 애니메이션 변경, 별 개수/속도 변경 금지
|
||||||
|
- 라우팅 변경 금지
|
||||||
|
- 신규 기능 추가 금지
|
||||||
|
|
||||||
|
## 적용 파일(예상)
|
||||||
|
|
||||||
|
- `@src/app/page.tsx`
|
||||||
|
- `@src/app/flight/page.tsx`
|
||||||
|
- 신규 생성:
|
||||||
|
- `src/features/lobby-starfield/**`
|
||||||
|
- `src/features/flight-starfield/**`
|
||||||
|
- `src/widgets/**`
|
||||||
|
- `src/shared/lib/**`, `src/shared/config/**`
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] page.tsx / flight/page.tsx의 코드가 “페이지 조합” 수준으로 짧아졌다(핵심 로직 제거)
|
||||||
|
- [ ] 로비 별자리+glint는 features/widgets로 분리되었고 동작 동일
|
||||||
|
- [ ] flight 스타필드 캔버스는 features/widgets로 분리되었고 동작 동일
|
||||||
|
- [ ] 빌드/타입 에러 없이 동작한다
|
||||||
|
- [ ] 기능/디자인 변경 없이 리팩토링만 수행했다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 변경/생성된 파일 경로 목록
|
||||||
|
- AC 체크(OK/NO)
|
||||||
168
.cli/tasks/0015-fsd-settle-phase2-cleanup-and-public-api.md
Normal file
168
.cli/tasks/0015-fsd-settle-phase2-cleanup-and-public-api.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# .gemini/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: FSD 정착 2차 — 잔재 import 제거/중복 정리 + widgets public API 고정 + 분리작업 준비
|
||||||
|
- TASK_SLUG: fsd-settle-phase2-cleanup-and-public-api
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
1차 FSD 이관 이후 남아있는 “잔재(import/중복/브릿지)”를 정리하고,
|
||||||
|
페이지(app/\*)가 **widgets의 public API만** import 하도록 고정해
|
||||||
|
향후 기능/스타필드/UI 분리 작업을 안전하게 진행할 수 있는 상태로 만든다.
|
||||||
|
|
||||||
|
**중요:** 동작/디자인 변경 없이 리팩토링(정리/분리)만 수행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0) 작업 순서(반드시 이 순서로)
|
||||||
|
|
||||||
|
1. 잔재 import 탐지 → 2) 잔재 제거/이관 → 3) 중복 소거(단일 소스) →
|
||||||
|
2. widgets public API(index.ts) 구축 → 5) 페이지 import를 widgets로 통일 →
|
||||||
|
3. 브릿지 파일 제거 → 7) 빌드 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1) 잔재(import) 탐지 및 제거(필수)
|
||||||
|
|
||||||
|
## 탐지(필수)
|
||||||
|
|
||||||
|
레포에서 아래 경로를 직접 참조하는 import가 남아있는지 모두 확인하고 제거한다:
|
||||||
|
|
||||||
|
- `components/*`
|
||||||
|
- `lib/*`
|
||||||
|
- `types/*` (가능하면)
|
||||||
|
- `features/*/ui/*` 를 page가 직접 import 하는 케이스(페이지는 widgets만)
|
||||||
|
|
||||||
|
> 탐지는 CLI에서 ripgrep을 사용하거나(권장), 코드 검색으로 동일하게 수행한다.
|
||||||
|
|
||||||
|
## 정리 규칙(필수)
|
||||||
|
|
||||||
|
- `components/*`를 직접 import하는 코드가 있다면:
|
||||||
|
- 해당 컴포넌트의 “정식 위치”(shared/ui, features/**/ui, widgets/**/ui)로 import를 바꾼다.
|
||||||
|
- `lib/*` import가 남아있다면:
|
||||||
|
- `shared/lib/**` 또는 `shared/config/**` 또는 `entities/**/model`/`features/**/model`로 교체한다.
|
||||||
|
- `types/*` import가 남아있다면:
|
||||||
|
- 가능 범위에서 `shared/types/**` 또는 도메인 model로 이동/교체한다.
|
||||||
|
- 단, 타입 대수술은 금지(경로 정리 중심).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2) 중복 구현 정리(필수)
|
||||||
|
|
||||||
|
## 대상(필수)
|
||||||
|
|
||||||
|
특히 아래 성격의 파일은 중복이 생기기 쉬우므로 “단일 소스”로 통일한다:
|
||||||
|
|
||||||
|
- `LobbyBackground` 계열
|
||||||
|
- `FlightBackground` 계열
|
||||||
|
- Star/Glint/Constellation 컴포넌트
|
||||||
|
- Starfield Canvas/Loop 관련 유틸
|
||||||
|
|
||||||
|
## 규칙(필수)
|
||||||
|
|
||||||
|
- 동일 기능이 두 위치에 존재하면, 하나를 “정식 소스”로 결정하고 나머지는 제거한다.
|
||||||
|
- 정식 소스 위치 우선순위:
|
||||||
|
1. widgets/\*\* (페이지에 꽂는 단위)
|
||||||
|
2. features/\*\* (기능 단위)
|
||||||
|
3. shared/\*\* (완전 범용)
|
||||||
|
- 제거가 리스크면 임시 re-export 브릿지를 1회만 허용하되,
|
||||||
|
이번 작업에서 최종적으로는 브릿지를 제거하는 방향을 우선한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3) Widgets Public API 고정(핵심)
|
||||||
|
|
||||||
|
## 목표(필수)
|
||||||
|
|
||||||
|
페이지(app/\*)는 앞으로 아래만 import 한다:
|
||||||
|
|
||||||
|
- `widgets/**` 의 `index.ts` (public API)
|
||||||
|
|
||||||
|
## 수행(필수)
|
||||||
|
|
||||||
|
- 각 widget 폴더에 `index.ts`를 만들고 public export를 한 곳으로 모은다.
|
||||||
|
예:
|
||||||
|
- `src/widgets/lobby-background/index.ts`
|
||||||
|
- `src/widgets/flight-background/index.ts`
|
||||||
|
- `src/widgets/flight-hud/index.ts`
|
||||||
|
- `src/widgets/lobby-routes/index.ts`
|
||||||
|
- page에서 `widgets/**/ui/*` 또는 `features/**/ui/*` 를 직접 import하는 코드를 전부 제거하고,
|
||||||
|
`widgets/**`의 index.ts에서만 가져오게 수정한다.
|
||||||
|
|
||||||
|
## 금지(필수)
|
||||||
|
|
||||||
|
- page에서 features/entities/shared를 직접 import하지 말 것(예외: 정말 공용 상수 1~2개 정도만, 가능하면 위젯이 먹도록 감춘다).
|
||||||
|
- widget 내부에서만 features/entities/shared를 조합한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4) 페이지(app/\*) 정리(필수)
|
||||||
|
|
||||||
|
## 목표(필수)
|
||||||
|
|
||||||
|
- `src/app/page.tsx` 및 `src/app/flight/page.tsx`는 “조합자” 역할만 한다:
|
||||||
|
- 위젯 렌더링 + 라우팅/레이아웃
|
||||||
|
- 별자리/스타필드/글린트/타이머 코어 로직은 페이지에서 제거되어야 한다(이미 분리되어 있다면 유지).
|
||||||
|
|
||||||
|
## 수행(필수)
|
||||||
|
|
||||||
|
- `app/page.tsx`는 `LobbyBackground`(widget)와 라우트/CTA만 남긴다.
|
||||||
|
- `app/flight/page.tsx`는 `FlightBackground` + `FlightHUD`(widget)만 남긴다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5) 브릿지 파일 제거(필수)
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
- 1차 이관에서 남겨둔 임시 re-export/브릿지 파일이 있다면 제거한다.
|
||||||
|
- `components/` 또는 `lib/` 폴더가 남아있다면:
|
||||||
|
- 비어있게 만들고 제거하거나,
|
||||||
|
- 최소한 “더 이상 import되지 않음” 상태로 만든다(최종적으로 삭제 권장).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6) 빌드/런타임 확인(필수)
|
||||||
|
|
||||||
|
- 타입 체크/빌드가 깨지지 않아야 한다.
|
||||||
|
- Home 로비 배경(별자리+glint) 정상 표시
|
||||||
|
- Flight 배경 스타필드 정상 표시
|
||||||
|
- 주요 라우트 이동(/, /boarding, /flight, /debrief, /log, /settings) 깨짐 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 기능 추가/변경 금지
|
||||||
|
- UI/애니메이션/스타필드 튜닝 금지(이번 작업은 구조 고정만)
|
||||||
|
- 라우트/디자인 변경 금지
|
||||||
|
- 스타일 값 조정 금지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 적용 범위(예상)
|
||||||
|
|
||||||
|
- `src/app/page.tsx`
|
||||||
|
- `src/app/flight/page.tsx`
|
||||||
|
- `src/widgets/**` (index.ts 생성/수정)
|
||||||
|
- `src/features/**` (중복 제거/정식 소스 확정)
|
||||||
|
- `src/shared/**` (유틸/설정 경로 정리)
|
||||||
|
- 잔재 폴더(`components`, `lib`, `types`) 정리/삭제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] `components/*`, `lib/*`, `types/*` 직접 import가 레포에서 0이거나(최소화), 남아있다면 정당한 사유가 주석으로 명시됨
|
||||||
|
- [ ] 페이지(app/\*)는 widgets public API만 import한다(직접 features/ui 참조 없음)
|
||||||
|
- [ ] 각 widget은 index.ts(public API)를 가진다
|
||||||
|
- [ ] 중복 구현이 제거되어 단일 소스가 확정되었다
|
||||||
|
- [ ] 빌드/타입 에러 없이 동작이 유지된다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 변경/생성/삭제된 파일 경로 목록
|
||||||
|
- AC 체크(OK/NO)
|
||||||
88
.cli/tasks/0016-lobby-boarding-modal-remove-memo.md
Normal file
88
.cli/tasks/0016-lobby-boarding-modal-remove-memo.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# .cli/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Lobby 목표 설정 화면을 모달로 전환 + 메모 기능 제거(동작 유지)
|
||||||
|
- TASK_SLUG: lobby-boarding-modal-remove-memo
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
- 우주정거장(로비)에서 항로 선택 후 **“바로 출항”**을 눌렀을 때 이동하던 목표 설정 화면을
|
||||||
|
**페이지 이동 대신 모달로 띄우는 방식**으로 변경한다.
|
||||||
|
- 목표 설정 화면의 **메모 기능은 완전히 삭제**한다.
|
||||||
|
- 모달에서 **“도킹 완료(출항)”**를 누르면 기존과 동일하게 다음 플로우(출항/flight 진입)가 진행되도록 한다.
|
||||||
|
|
||||||
|
## 사용자 플로우(필수)
|
||||||
|
|
||||||
|
1. 로비(/)에서 항로 카드의 “바로 출항” 클릭
|
||||||
|
2. (페이지 이동 없음) 목표 설정 모달 오픈
|
||||||
|
3. 사용자가 목표(미션) 입력
|
||||||
|
4. “도킹 완료(출항)” 클릭
|
||||||
|
5. 기존과 동일한 방식으로 세션 시작 및 flight 화면으로 이동(또는 기존 출항 로직 호출)
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
### A) 모달 전환(필수)
|
||||||
|
|
||||||
|
- “바로 출항” 클릭 시 `/boarding`로 라우팅하지 말고,
|
||||||
|
로비 페이지에서 목표 설정 모달을 연다.
|
||||||
|
- 모달 닫기(취소/배경 클릭/ESC 등) 시 로비로 그대로 돌아온다(세션 시작 금지).
|
||||||
|
- 모달 오픈 시 미션 입력 필드에 자동 포커스.
|
||||||
|
|
||||||
|
### B) 메모 기능 삭제(필수)
|
||||||
|
|
||||||
|
- 목표 설정 UI에서 메모 입력(컨디션 메모 등) 관련:
|
||||||
|
- UI 요소 제거
|
||||||
|
- 상태(state) 제거
|
||||||
|
- 저장/로그 스키마에 기록하던 필드가 있다면 더 이상 생성하지 않음(기존 데이터 읽기는 깨지지 않게)
|
||||||
|
- 관련 placeholder/문구도 제거.
|
||||||
|
|
||||||
|
### C) “도킹 완료(출항)” 동작 동일(필수)
|
||||||
|
|
||||||
|
- 모달에서 “도킹 완료(출항)” 클릭 시:
|
||||||
|
- 기존 boarding 페이지에서 하던 세션 생성/저장 로직을 그대로 재사용한다
|
||||||
|
- 성공하면 기존과 동일하게 flight로 이동한다
|
||||||
|
- 유효성:
|
||||||
|
- 미션 입력은 필수(빈 값이면 출항 불가 + 기존과 동일한 방식의 에러 처리)
|
||||||
|
|
||||||
|
### D) /boarding 라우트 처리(권장)
|
||||||
|
|
||||||
|
- “바로 출항” 플로우는 모달로 전환하되,
|
||||||
|
`/boarding` 페이지는 당장 제거하지 말고(링크/북마크 대비),
|
||||||
|
동작을 유지하거나 로비로 리다이렉트하는 방식 중 하나로 정리한다.
|
||||||
|
- 단, 새로운 기능 범위를 넘어 UI/흐름을 크게 바꾸지 말 것.
|
||||||
|
|
||||||
|
## 구현 가이드(권장)
|
||||||
|
|
||||||
|
- 모달 컴포넌트는 이미 있는 공용 Dialog/Modal을 사용한다(`shared/ui/dialog` 또는 동일 역할 컴포넌트).
|
||||||
|
- 모달 내부 폼은 기존 boarding 폼을 재사용 가능한 컴포넌트로 분리해도 됨(동작 동일 유지).
|
||||||
|
- 세션 시작 로직은 `features/*-session/model` 등 기존 모델을 그대로 호출(중복 구현 금지).
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- flight/debrief/log/settings UI 변경 금지
|
||||||
|
- 스타필드/애니메이션 튜닝 금지
|
||||||
|
- 신규 기능 추가 금지(메모 삭제 + 모달 전환만)
|
||||||
|
|
||||||
|
## 적용 파일(예상)
|
||||||
|
|
||||||
|
- `@src/app/page.tsx` (로비에서 모달 제어)
|
||||||
|
- `@src/app/boarding/page.tsx` (필요 시 리다이렉트/호환 처리)
|
||||||
|
- 관련 위젯/피처:
|
||||||
|
- `widgets/lobby-routes` 또는 로비 카드/출항 핸들러 위치
|
||||||
|
- `widgets/boarding-screen` 또는 목표 설정 UI 컴포넌트
|
||||||
|
- 공용 모달/다이얼로그 컴포넌트(이미 있으면 재사용)
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 로비에서 “바로 출항” 클릭 시 /boarding로 이동하지 않고 목표 설정 모달이 열린다
|
||||||
|
- [ ] 모달 닫으면 아무 것도 시작되지 않고 로비에 그대로 남는다
|
||||||
|
- [ ] 메모 입력/UI/상태/저장 로직이 모두 제거되었다
|
||||||
|
- [ ] 미션이 비어있으면 출항 불가(기존과 동일한 유효성 처리)
|
||||||
|
- [ ] “도킹 완료(출항)” 클릭 시 기존과 동일하게 세션이 시작되고 flight로 이동한다
|
||||||
|
- [ ] 기존 로그/세션 데이터 읽기 흐름이 깨지지 않는다(기존 데이터 호환)
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정/생성/삭제된 파일 경로 목록
|
||||||
|
- AC 체크 결과(OK/NO)
|
||||||
79
.cli/tasks/0017-enter-submit-boarding-and-debrief.md
Normal file
79
.cli/tasks/0017-enter-submit-boarding-and-debrief.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# .cli/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Enter로 진행 — 탑승(미션 설정) & 회고(항해일지) 폼을 form submit로 처리
|
||||||
|
- TASK_SLUG: enter-submit-boarding-and-debrief
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
1. 탑승(미션 설정) 화면/모달에서 사용자가 **Enter**를 누르면 “도킹 완료(출항)”과 동일하게 다음 흐름으로 진행되게 한다.
|
||||||
|
2. 도착/회고(항해일지 작성) 화면/모달에서도 **Enter**를 누르면 “저장”과 동일하게 제출되어 다음 흐름으로 진행되게 한다.
|
||||||
|
3. 멀티라인 입력(textarea)이 있다면 Enter 제출로 인해 입력이 불편해지지 않게 예외 처리를 한다.
|
||||||
|
|
||||||
|
## 적용 범위
|
||||||
|
|
||||||
|
- 탑승(미션 설정) UI: 페이지(`/boarding`) 및/또는 로비에서 띄우는 탑승 모달(현재 구현 기준)
|
||||||
|
- 회고(항해일지) UI: 페이지(`/debrief`) 및/또는 flight 종료 시 띄우는 회고 모달(현재 구현 기준)
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
### A) 탑승(미션 설정) — Enter 제출(필수)
|
||||||
|
|
||||||
|
- 미션 입력 UI를 `<form>`으로 감싼다.
|
||||||
|
- “도킹 완료(출항)” 버튼은 `type="submit"`으로 처리한다.
|
||||||
|
- Enter 키 입력 시 `onSubmit`이 실행되어 기존 출항 로직이 동일하게 호출되도록 한다.
|
||||||
|
- 유효성(미션 필수)은 기존과 동일하게 유지한다.
|
||||||
|
- 제출 성공 시 기존과 동일한 다음 화면(보통 flight)으로 이동한다.
|
||||||
|
|
||||||
|
### B) 회고(항해일지) — Enter 제출(필수)
|
||||||
|
|
||||||
|
- 회고 입력 UI를 `<form>`으로 감싼다.
|
||||||
|
- “저장” 버튼은 `type="submit"`으로 처리한다.
|
||||||
|
- Enter 키 입력 시 `onSubmit`이 실행되어 기존 저장 로직이 동일하게 호출되도록 한다.
|
||||||
|
- 제출 성공 후 흐름은 기존과 동일하게 유지한다(로그 상세/목록/로비 등 현재 구현 따름).
|
||||||
|
|
||||||
|
### C) textarea/멀티라인 입력 예외(중요)
|
||||||
|
|
||||||
|
- 회고 질문이나 입력칸이 textarea(멀티라인)라면:
|
||||||
|
- 기본 Enter는 줄바꿈이 되어야 한다(제출로 가로채지 않음).
|
||||||
|
- 대신 **Cmd/Ctrl + Enter**로 제출되게 하거나,
|
||||||
|
- textarea는 Enter 줄바꿈 유지 + 제출은 버튼/단축키로만(둘 중 하나 선택)
|
||||||
|
- 단, 현재 입력이 전부 단일라인 input이면 Enter=submit로 단순 처리한다.
|
||||||
|
|
||||||
|
### D) 모달 환경(필수)
|
||||||
|
|
||||||
|
- 모달(탑승/회고)에서 Enter 제출이 동작하되,
|
||||||
|
포커스 트랩/닫기 정책과 충돌하지 않게 한다.
|
||||||
|
- 제출 중 중복 submit 방지(버튼 disabled 또는 guard) 적용(권장).
|
||||||
|
|
||||||
|
### E) 접근성(권장)
|
||||||
|
|
||||||
|
- form submit 시 에러가 있으면 입력 필드에 포커스 이동 또는 에러 메시지 노출(현재 방식 유지)
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 문구/UX 대개편 금지
|
||||||
|
- 데이터 스키마 변경 금지
|
||||||
|
- 다른 페이지 UI 변경 금지
|
||||||
|
|
||||||
|
## 적용 파일(예상)
|
||||||
|
|
||||||
|
- 탑승 UI 위치:
|
||||||
|
- `@src/app/boarding/page.tsx` 또는 `widgets/boarding-screen/**` 또는 로비 탑승 모달 컴포넌트
|
||||||
|
- 회고 UI 위치:
|
||||||
|
- `@src/app/debrief/page.tsx` 또는 `widgets/debrief-screen/**` 또는 flight 회고 모달 컴포넌트
|
||||||
|
- flight 모달 트리거가 있는 경우:
|
||||||
|
- `@src/app/flight/page.tsx` (필요 시 submit 핸들러 연결만)
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 탑승 화면/모달에서 Enter를 누르면 “도킹 완료(출항)”과 동일하게 진행된다
|
||||||
|
- [ ] 회고 화면/모달에서 Enter를 누르면 “저장”과 동일하게 진행된다
|
||||||
|
- [ ] textarea가 있으면 Enter 줄바꿈이 보장되며(또는 Ctrl/Cmd+Enter 제출), 입력 경험이 깨지지 않는다
|
||||||
|
- [ ] 유효성/저장/이동 로직은 기존과 동일하게 동작한다(중복 구현 없음)
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정/생성/삭제된 파일 경로 목록
|
||||||
|
- AC 체크 결과(OK/NO)
|
||||||
56
.cli/tasks/0018-station-timer-countup-hhmmss.md
Normal file
56
.cli/tasks/0018-station-timer-countup-hhmmss.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# .cli/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: 우주정거장(무제한 체류) 타이머 count-up 복구 및 HH:MM:SS 표시
|
||||||
|
- TASK_SLUG: station-timer-countup-hhmmss
|
||||||
|
|
||||||
|
## 문제(관찰)
|
||||||
|
|
||||||
|
- 오리온/쌍둥이자리처럼 카운트다운이 있는 항로는 정상 동작한다.
|
||||||
|
- 우주정거장처럼 0초부터 제한 없이 머무는 항로는 타이머가 `00:00`에 고정되어 증가하지 않는다.
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
- 우주정거장 항로에서 경과 시간이 `00:00`부터 정상적으로 증가하고, 1시간 이상 체류를 위해 `HH:MM:SS` 포맷으로 표시되게 한다.
|
||||||
|
|
||||||
|
## 합리적 가정
|
||||||
|
|
||||||
|
- 우주정거장 타이머는 카운트다운이 아닌 경과 시간(count-up) 기준으로 동작한다.
|
||||||
|
- 표시 포맷은 항상 `HH:MM:SS`(예: `00:00:05`, `01:02:03`)로 통일한다.
|
||||||
|
|
||||||
|
## 적용 범위
|
||||||
|
|
||||||
|
- 우주정거장 항로의 타이머 계산 로직
|
||||||
|
- 우주정거장 항로의 타이머 렌더링 포맷
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 우주정거장(무제한 체류) 모드에서 타이머가 `00:00`에 멈추지 않고 1초 단위로 증가해야 한다.
|
||||||
|
2. 타이머 표시는 분까지만이 아니라 초까지 포함한 `HH:MM:SS` 형식을 사용해야 한다.
|
||||||
|
3. 1시간 미만일 때도 자리수를 유지해 `00:MM:SS` 형식으로 표시해야 한다.
|
||||||
|
4. 기존 카운트다운 항로(30분/60분)의 진행 로직은 회귀 없이 유지해야 한다.
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 항로별 제한 시간 정책 변경 금지
|
||||||
|
- 타이머 외 UI/문구/레이아웃 변경 금지
|
||||||
|
- 로그 저장 스키마/세션 모델 확장 금지
|
||||||
|
|
||||||
|
## 적용 파일(예상)
|
||||||
|
|
||||||
|
- `src/app/flight/page.tsx`
|
||||||
|
- `src/features/flight-session/**` (타이머 계산 훅/모델)
|
||||||
|
- `src/shared/lib/**` (시간 포맷 유틸, 필요 시)
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 우주정거장 항로 진입 시 타이머가 `00:00:00`에서 시작해 지속 증가한다
|
||||||
|
- [ ] 우주정거장 타이머가 `HH:MM:SS` 형식으로 렌더링된다
|
||||||
|
- [ ] 1시간 이상 체류 시 `01:00:00` 이상으로 정상 표기된다
|
||||||
|
- [ ] 오리온/쌍둥이자리 카운트다운 동작은 기존과 동일하게 유지된다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정/생성/삭제된 파일 경로 목록
|
||||||
|
- AC 체크 결과(OK/NO)
|
||||||
55
.cli/tasks/0019-countdown-routes-timer-hhmmss-display.md
Normal file
55
.cli/tasks/0019-countdown-routes-timer-hhmmss-display.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# .cli/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: 오리온/쌍둥이자리 카운트다운 타이머를 HH:MM:SS로 표시
|
||||||
|
- TASK_SLUG: countdown-routes-timer-hhmmss-display
|
||||||
|
|
||||||
|
## 문제(관찰)
|
||||||
|
|
||||||
|
- 오리온, 쌍둥이자리 항로의 타이머 표시는 현재 시:분:초 형태가 아니다.
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
- 오리온/쌍둥이자리 타이머 표시를 `HH:MM:SS` 형식(예: `00:30:00`, `01:00:00`)으로 통일한다.
|
||||||
|
|
||||||
|
## 합리적 가정
|
||||||
|
|
||||||
|
- 이번 작업은 표시 포맷만 변경하며, 카운트다운 동작/제한 시간(30분, 60분)은 유지한다.
|
||||||
|
- 우주정거장(무제한 체류) 타이머 정책 변경은 이번 범위에 포함하지 않는다.
|
||||||
|
|
||||||
|
## 적용 범위
|
||||||
|
|
||||||
|
- 오리온/쌍둥이자리 항로의 타이머 렌더링 포맷 로직
|
||||||
|
- 시간 문자열 포맷 유틸(필요 시)
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 오리온(30분) 타이머 초기값이 `00:30:00` 형태로 표시되어야 한다.
|
||||||
|
2. 쌍둥이자리(60분) 타이머 초기값이 `01:00:00` 형태로 표시되어야 한다.
|
||||||
|
3. 카운트다운 진행 중에도 항상 `HH:MM:SS` 자리수를 유지해야 한다.
|
||||||
|
4. 타이머의 감소 속도/종료 조건 등 기존 카운트다운 로직은 변경하지 않는다.
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 우주정거장 타이머 로직/표시 변경 금지
|
||||||
|
- 항로별 제한 시간 정책 변경 금지
|
||||||
|
- 타이머 외 UI/문구/레이아웃 변경 금지
|
||||||
|
|
||||||
|
## 적용 파일(예상)
|
||||||
|
|
||||||
|
- `src/app/flight/page.tsx`
|
||||||
|
- `src/features/flight-session/**` (카운트다운 표시 계산 위치)
|
||||||
|
- `src/shared/lib/**` (시간 포맷 유틸, 필요 시)
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 오리온 타이머가 `HH:MM:SS` 형식으로 표시된다
|
||||||
|
- [ ] 쌍둥이자리 타이머가 `HH:MM:SS` 형식으로 표시된다
|
||||||
|
- [ ] 카운트다운 동작(감소/종료)은 기존과 동일하게 유지된다
|
||||||
|
- [ ] 우주정거장 동작에는 영향이 없다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정/생성/삭제된 파일 경로 목록
|
||||||
|
- AC 체크 결과(OK/NO)
|
||||||
56
.cli/tasks/0020-flight-mission-view-ux-polish.md
Normal file
56
.cli/tasks/0020-flight-mission-view-ux-polish.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# .cli/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Flight 화면 "이번 항해 목표" 뷰 UI/UX 개선
|
||||||
|
- TASK_SLUG: flight-mission-view-ux-polish
|
||||||
|
|
||||||
|
## 문제(관찰)
|
||||||
|
|
||||||
|
- Flight 화면에서 목표 문구가 단순 인용문 형태로 노출되어 정보 위계가 약하고, 가독성과 완성도가 떨어진다.
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
- Flight HUD 내 목표 영역을 카드형 정보 블록으로 개선해, 목표 인지성/가독성/시각적 완성도를 높인다.
|
||||||
|
|
||||||
|
## 합리적 가정
|
||||||
|
|
||||||
|
- 이번 작업은 목표 뷰의 UI/UX 개선에 한정하며, 타이머/일시정지/종료 같은 항해 동작 로직은 변경하지 않는다.
|
||||||
|
- 기존 비주얼 톤(우주 배경, 다크 기반)은 유지한다.
|
||||||
|
|
||||||
|
## 적용 범위
|
||||||
|
|
||||||
|
- 목표 표시 UI 컴포넌트: `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
|
||||||
|
- 필요 시 공용 스타일 유틸/프리미티브: `src/shared/ui/**` (최소 변경)
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 목표 문구 영역을 단순 인용문에서 카드형 블록으로 변경한다.
|
||||||
|
2. 카드 내부에 라벨(예: `이번 항해 목표`)과 본문 텍스트를 분리해 정보 위계를 명확히 한다.
|
||||||
|
3. 긴 목표 문구가 레이아웃을 깨지 않도록 줄바꿈/최대 높이/말줄임 처리 중 하나를 적용해 모바일/데스크톱 모두 안정적으로 보이게 한다.
|
||||||
|
4. 텍스트 대비(contrast)와 간격(spacing)을 개선해 현재보다 읽기 쉬운 상태를 만든다.
|
||||||
|
5. 기존 상단 상태 배지, 중앙 타이머, 하단 버튼 동작은 그대로 유지한다.
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 타이머 계산/표시 포맷 로직 변경 금지
|
||||||
|
- 항해 제어 버튼(일시정지/종료) 기능 변경 금지
|
||||||
|
- 배경 이펙트/스타필드 로직 변경 금지
|
||||||
|
- 신규 라우트/상태 모델 추가 금지
|
||||||
|
|
||||||
|
## 적용 파일(예상)
|
||||||
|
|
||||||
|
- `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
|
||||||
|
- `src/shared/ui/**` (필요한 경우에만)
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] Flight 화면 목표 영역이 카드형 UI로 변경되어 라벨과 본문이 분리되어 보인다
|
||||||
|
- [ ] 긴 목표 문구 입력 시에도 레이아웃 깨짐 없이 반응형으로 표시된다
|
||||||
|
- [ ] 목표 텍스트 가독성(대비/행간/여백)이 기존 대비 개선된다
|
||||||
|
- [ ] 타이머/버튼 기능과 항해 흐름은 기존과 동일하게 동작한다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정/생성/삭제된 파일 경로 목록
|
||||||
|
- AC 체크 결과(OK/NO)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# .cli/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Flight 종료 후 회고를 모달로 전환하고 회고 폼 항목/아이콘 정리
|
||||||
|
- TASK_SLUG: flight-finish-debrief-modal-and-copy-cleanup
|
||||||
|
|
||||||
|
## 문제(관찰)
|
||||||
|
|
||||||
|
- Flight 화면에서 `항해 종료` 클릭 시 별도 `/debrief` 페이지로 이동한다.
|
||||||
|
- 회고 폼에 `다음 항해의 첫 행동 (Next)` 항목이 포함되어 있다.
|
||||||
|
- 회고 상태 옵션 라벨에 이모지(✅, 🌓, 🧭)가 사용되어 톤 일관성이 떨어진다.
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
- Flight 내에서 회고 작성이 모달로 완료되도록 전환하고, 회고 폼을 간결하게 정리한다.
|
||||||
|
|
||||||
|
## 합리적 가정
|
||||||
|
|
||||||
|
- 모달 회고 저장 완료 후 최종 이동 경로는 기존과 동일하게 `/log`를 유지한다.
|
||||||
|
- 기존 `/debrief` 라우트는 즉시 삭제하지 않고 호환 경로로 남긴다(리다이렉트 또는 동일 폼 재사용).
|
||||||
|
- 이모지 제거는 텍스트 라벨 정리 또는 아이콘 컴포넌트 대체 중 하나로 처리한다.
|
||||||
|
|
||||||
|
## 적용 범위
|
||||||
|
|
||||||
|
- flight 종료 액션 트리거/상태 연결: `src/features/flight-session/model/useFlightSession.ts`
|
||||||
|
- flight HUD(모달 오픈/렌더): `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
|
||||||
|
- 회고 폼(UI/필드/옵션 라벨): `src/app/debrief/page.tsx` (필요 시 공용 컴포넌트로 분리)
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. Flight 화면에서 `항해 종료` 클릭 시 페이지 전환 대신 회고 모달이 열려야 한다.
|
||||||
|
2. 회고 모달에서 저장/취소 흐름이 정상 동작해야 하며, 저장 시 기존과 동일하게 항해일지 저장 후 `/log`로 이동해야 한다.
|
||||||
|
3. 회고 폼에서 `다음 항해의 첫 행동 (Next)` 입력 항목을 제거한다.
|
||||||
|
4. 회고 상태 옵션의 이모지(✅, 🌓, 🧭, 🚨 등)를 제거하고, 문구 중심 또는 아이콘 컴포넌트 방식으로 일관되게 정리한다.
|
||||||
|
5. 기존 데이터 구조와의 호환을 깨지 않도록 저장 로직을 유지한다(불필요 필드만 제거/미기록 처리).
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- Flight 타이머/배경/조작 버튼(일시정지 등) 동작 변경 금지
|
||||||
|
- 로그 목록/상세 페이지의 레이아웃 개편 금지
|
||||||
|
- 항해 시작(boarding) 플로우 변경 금지
|
||||||
|
|
||||||
|
## 적용 파일(예상)
|
||||||
|
|
||||||
|
- `src/features/flight-session/model/useFlightSession.ts`
|
||||||
|
- `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
|
||||||
|
- `src/app/debrief/page.tsx`
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] Flight에서 `항해 종료` 클릭 시 `/debrief` 페이지 이동 없이 회고 모달이 열린다
|
||||||
|
- [ ] 회고 저장 시 기존과 동일하게 저장되고 `/log`로 이동한다
|
||||||
|
- [ ] 회고 폼에서 `다음 항해의 첫 행동 (Next)` 필드가 제거된다
|
||||||
|
- [ ] 회고 상태 옵션에서 이모지가 제거되어 톤이 정리된다
|
||||||
|
- [ ] 기존 항해 저장/히스토리 읽기 흐름에 회귀가 없다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정/생성/삭제된 파일 경로 목록
|
||||||
|
- AC 체크 결과(OK/NO)
|
||||||
53
.cli/tasks/0022-flight-finish-hold-2s-ring-and-modal.md
Normal file
53
.cli/tasks/0022-flight-finish-hold-2s-ring-and-modal.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# .cli/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: Flight 종료 버튼 2초 길게 누르기(hold-to-confirm) + 원형 진행선 + 종료 모달 오픈
|
||||||
|
- TASK_SLUG: flight-finish-hold-2s-ring-and-modal
|
||||||
|
|
||||||
|
## 문제(관찰)
|
||||||
|
|
||||||
|
- 현재는 종료 버튼 클릭 직후 종료 플로우가 진행되어 오작동(실수 클릭) 여지가 있다.
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
- 종료 버튼을 2초간 길게 눌렀을 때만 종료 모달이 열리도록 변경하고, 누르는 동안 버튼 테두리 진행선을 시각적으로 제공한다.
|
||||||
|
|
||||||
|
## 합리적 가정
|
||||||
|
|
||||||
|
- 종료 모달이 열리기 전까지는 항해 상태 저장/라우팅 같은 실제 종료 처리는 발생하지 않는다.
|
||||||
|
- 길게 누르기 완료 후에는 기존 종료 모달/종료 확정 흐름을 그대로 재사용한다.
|
||||||
|
|
||||||
|
## 적용 범위
|
||||||
|
|
||||||
|
- flight HUD 종료 버튼 UI/interaction: `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
|
||||||
|
- flight 종료 트리거 상태/핸들러: `src/features/flight-session/model/useFlightSession.ts`
|
||||||
|
- 종료 모달 렌더/연결 위치(필요 시): `src/app/flight/page.tsx`
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 종료 버튼은 단순 클릭으로는 종료되지 않고, 최소 2초 이상 길게 누른 경우에만 종료 모달이 열린다.
|
||||||
|
2. 길게 누르는 동안 버튼 테두리를 따라 0%→100%로 진행되는 원형(또는 버튼 외곽형) 진행선이 표시된다.
|
||||||
|
3. 2초 이전에 누름을 해제하면 진행선은 즉시 취소되고 초기 상태로 복귀한다.
|
||||||
|
4. 진행선이 100%에 도달하는 시점(2초 경과)에 종료 모달이 자동 오픈된다.
|
||||||
|
5. 모달 오픈 후 실제 종료 확정/취소 동작은 기존 정책과 동일하게 유지된다.
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 타이머 계산/표시 로직 변경 금지
|
||||||
|
- 일시정지 버튼 동작 변경 금지
|
||||||
|
- 배경/스타필드/다른 페이지 UI 개편 금지
|
||||||
|
- 신규 라우트 추가 금지
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 종료 버튼 단순 클릭(짧은 터치/클릭)으로는 종료 모달이 열리지 않는다
|
||||||
|
- [ ] 2초 길게 누르면 종료 모달이 열린다
|
||||||
|
- [ ] 누르는 동안 버튼 테두리 진행선이 시간 경과에 맞춰 표시된다
|
||||||
|
- [ ] 2초 이전 해제 시 진행선이 초기화되고 아무 종료 동작도 발생하지 않는다
|
||||||
|
- [ ] 모달 이후 확정/취소 흐름은 기존과 동일하게 동작한다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정/생성/삭제된 파일 경로 목록
|
||||||
|
- AC 체크 결과(OK/NO)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# .cli/current.md
|
||||||
|
|
||||||
|
## TASK_META (수정 금지)
|
||||||
|
|
||||||
|
- TASK_TITLE: 종료 버튼 길게 누르기 진행선 비주얼 어색함 개선(원형 대안 적용)
|
||||||
|
- TASK_SLUG: finish-hold-visual-alt-for-non-circle-button
|
||||||
|
|
||||||
|
## 문제(관찰)
|
||||||
|
|
||||||
|
- 종료 버튼은 원형이 아닌데, 원형 진행선(또는 원형에 가까운 테두리 애니메이션) 표현이 버튼 형태와 맞지 않아 어색하다.
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
|
||||||
|
- 버튼 형태와 일치하는 진행 피드백으로 교체해, 길게 누르기(2초) 인터랙션을 자연스럽고 명확하게 보이게 한다.
|
||||||
|
|
||||||
|
## 합리적 가정
|
||||||
|
|
||||||
|
- 이번 작업은 시각 피드백 방식 개선이 핵심이며, `2초 길게 누르면 종료 모달 오픈` 동작 규칙은 유지한다.
|
||||||
|
- 원형 진행선 대신 버튼 내부 수평 진행(fill) 또는 버튼 외곽 라운드 사각 진행선 중 하나를 채택한다.
|
||||||
|
|
||||||
|
## 적용 범위
|
||||||
|
|
||||||
|
- 종료 버튼 UI/hold 시각 피드백: `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
|
||||||
|
- hold 진행 상태 계산/노출(필요 시): `src/features/flight-session/model/useFlightSession.ts`
|
||||||
|
|
||||||
|
## 작업 요구사항
|
||||||
|
|
||||||
|
1. 원형 진행선 기반 표현을 제거하고, 버튼 형태와 일치하는 진행 피드백으로 교체한다.
|
||||||
|
2. 진행 피드백은 0%~100%가 2초 동안 선형으로 증가하며, 사용자가 즉시 인지 가능해야 한다.
|
||||||
|
3. 눌렀다 떼면 진행률은 즉시 0으로 리셋되어야 한다.
|
||||||
|
4. 100% 도달 시 기존과 동일하게 종료 모달이 열린다.
|
||||||
|
5. 모바일/데스크톱 모두에서 텍스트 가독성과 버튼 상태(기본/누름/완료 직전)가 명확해야 한다.
|
||||||
|
|
||||||
|
## Non-scope
|
||||||
|
|
||||||
|
- 종료 모달 내부 UI/카피 개편 금지
|
||||||
|
- 타이머/일시정지/배경 이펙트 변경 금지
|
||||||
|
- 항해 저장/라우팅 정책 변경 금지
|
||||||
|
|
||||||
|
## 적용 파일(예상)
|
||||||
|
|
||||||
|
- `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
|
||||||
|
- `src/features/flight-session/model/useFlightSession.ts` (필요 시)
|
||||||
|
|
||||||
|
## 완료 조건(AC)
|
||||||
|
|
||||||
|
- [ ] 종료 버튼에서 원형 진행선이 제거되고 버튼 형태와 일치한 진행 피드백이 적용된다
|
||||||
|
- [ ] 2초 길게 누르는 동안 진행 상태가 자연스럽게 증가해 보인다
|
||||||
|
- [ ] 2초 이전 해제 시 진행 상태가 즉시 초기화된다
|
||||||
|
- [ ] 2초 완료 시 종료 모달이 기존 규칙대로 열린다
|
||||||
|
- [ ] 기존 종료 플로우(모달 이후 동작)에 회귀가 없다
|
||||||
|
|
||||||
|
## 완료 후 출력(최소)
|
||||||
|
|
||||||
|
- 수정/생성/삭제된 파일 경로 목록
|
||||||
|
- AC 체크 결과(OK/NO)
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -40,3 +40,7 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.idea
|
.idea
|
||||||
|
.cli/tasks
|
||||||
|
.cli/_task_context.md
|
||||||
|
.cli/changelog.md
|
||||||
|
.cli/planner/input.md
|
||||||
|
|||||||
29
AGENTS.md
Normal file
29
AGENTS.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# AGENTS.md (개발자/Builder 기본)
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
|
||||||
|
당신은 개발자(Builder)입니다. 작업 결과는 **레포지토리에 직접 적용**해야 합니다.
|
||||||
|
|
||||||
|
## 단일 진실(필수)
|
||||||
|
|
||||||
|
- 항상 `@.cli/runbook.md`를 따른다.
|
||||||
|
- 현재 작업 지시는 `@.cli/current.md`가 단일 진실이다.
|
||||||
|
- 구조/규칙 문서: `@.cli/docs/architecture.md`, `@.cli/docs/rules.md`
|
||||||
|
|
||||||
|
## 범위 규칙
|
||||||
|
|
||||||
|
- `@.cli/current.md`에 명시된 범위/파일만 수정한다.
|
||||||
|
- 불필요한 리팩토링/포맷 변경/스타일 변경 금지.
|
||||||
|
- `.cli/**`는 housekeeping에서 요구될 때만 수정한다.
|
||||||
|
|
||||||
|
## 빌드/테스트
|
||||||
|
|
||||||
|
- `npm run build` 등 명령 실행은 `@.cli/ops/run_housekeeping.md`가 지시할 때만 수행한다.
|
||||||
|
|
||||||
|
## 출력(최소)
|
||||||
|
|
||||||
|
- 출력은 최소로 한다:
|
||||||
|
1. 수정/생성/삭제된 파일 경로 목록
|
||||||
|
2. AC 체크리스트(OK/NO)
|
||||||
|
3. housekeeping 결과 1줄
|
||||||
|
- 코드 전체 덤프/긴 로그 출력 금지.
|
||||||
52
src/app/boarding/page.tsx
Normal file
52
src/app/boarding/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { ROUTES } from '@/shared/config/routes';
|
||||||
|
import { BoardingMissionForm, startVoyage } from '@/features/boarding';
|
||||||
|
import { useI18n } from '@/features/i18n/model/useI18n';
|
||||||
|
|
||||||
|
function BoardingContent() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const routeId = searchParams.get('routeId');
|
||||||
|
const route = ROUTES.find(r => r.id === routeId) || ROUTES[0];
|
||||||
|
const routeName = t(route.nameKey, undefined, route.id);
|
||||||
|
|
||||||
|
const handleDocking = (mission: string) => {
|
||||||
|
const started = startVoyage({ route, mission, routeName });
|
||||||
|
if (!started) return;
|
||||||
|
router.push('/flight');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-6 animate-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-sm text-indigo-400 font-semibold mb-1 uppercase tracking-widest">
|
||||||
|
{t('boarding.check')}
|
||||||
|
</h2>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
{t('boarding.routeBoarding', { routeName })}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8 flex-1">
|
||||||
|
<BoardingMissionForm
|
||||||
|
onDock={handleDocking}
|
||||||
|
autoFocus={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BoardingPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="p-6">{t('common.loading')}</div>}>
|
||||||
|
<BoardingContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/app/debrief/page.tsx
Normal file
115
src/app/debrief/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useI18n } from '@/features/i18n/model/useI18n';
|
||||||
|
import { DEBRIEF_STATUS_OPTIONS } from '@/shared/config/i18n';
|
||||||
|
import { getCurrentVoyage, saveToHistory, saveCurrentVoyage } from '@/shared/lib/store';
|
||||||
|
import { Voyage, VoyageStatus } from '@/shared/types';
|
||||||
|
|
||||||
|
export default function DebriefPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const [voyage, setVoyage] = useState<Voyage | null>(null);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<VoyageStatus | null>(null);
|
||||||
|
const [progress, setProgress] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const current = getCurrentVoyage();
|
||||||
|
if (!current) {
|
||||||
|
router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVoyage(current);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!voyage || !status) return;
|
||||||
|
|
||||||
|
const finalVoyage: Voyage = {
|
||||||
|
...voyage,
|
||||||
|
status: status,
|
||||||
|
debriefProgress: progress,
|
||||||
|
endedAt: voyage.endedAt || Date.now(), // Fallback if missed in flight
|
||||||
|
};
|
||||||
|
|
||||||
|
saveToHistory(finalVoyage);
|
||||||
|
saveCurrentVoyage(null);
|
||||||
|
router.push('/log');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!voyage) return null;
|
||||||
|
|
||||||
|
const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({
|
||||||
|
value: option.value as VoyageStatus,
|
||||||
|
label: t(option.labelKey),
|
||||||
|
desc: t(option.descKey),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-6 max-w-2xl mx-auto w-full animate-in zoom-in-95 duration-500">
|
||||||
|
<header className="mb-8 text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">{t('debrief.page.title')}</h1>
|
||||||
|
<p className="text-slate-400">{t('debrief.page.description')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8 flex-1">
|
||||||
|
{/* Question 1: Status */}
|
||||||
|
<section>
|
||||||
|
<label className="block text-sm font-medium text-slate-300 mb-3">
|
||||||
|
{t('debrief.status.label')}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
{statusOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatus(opt.value)}
|
||||||
|
className={`flex min-h-[116px] flex-col justify-between rounded-xl border px-4 py-3.5 text-left transition-all ${
|
||||||
|
status === opt.value
|
||||||
|
? 'bg-indigo-900/40 border-indigo-500 ring-1 ring-indigo-500'
|
||||||
|
: 'bg-slate-900/50 border-slate-800 hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm leading-snug font-bold text-slate-200 break-keep">
|
||||||
|
{opt.label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] leading-relaxed text-slate-500">
|
||||||
|
{opt.desc}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Question 2: Reflection */}
|
||||||
|
<section>
|
||||||
|
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||||
|
{t('debrief.reflection.label')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={progress}
|
||||||
|
onChange={(e) => setProgress(e.target.value)}
|
||||||
|
placeholder={t('debrief.reflection.placeholder')}
|
||||||
|
className="w-full bg-slate-900/30 border border-slate-800 rounded-lg px-4 py-3 text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!status}
|
||||||
|
className="w-full mt-10 py-4 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-900/20"
|
||||||
|
>
|
||||||
|
{t('debrief.save')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/flight/page.tsx
Normal file
16
src/app/flight/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useFlightSession } from '@/features/flight-session/model/useFlightSession';
|
||||||
|
import { FlightBackgroundWidget } from '@/widgets/flight-background';
|
||||||
|
import { FlightHudWidget } from '@/widgets/flight-hud';
|
||||||
|
|
||||||
|
export default function FlightPage() {
|
||||||
|
const session = useFlightSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-[calc(100vh-64px)] flex-1 flex-col items-center justify-center overflow-hidden p-6 text-white">
|
||||||
|
<FlightBackgroundWidget isPaused={session.isPaused} />
|
||||||
|
<FlightHudWidget {...session} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -123,3 +123,122 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
@keyframes twinkle {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes star-core-pulse {
|
||||||
|
0%,
|
||||||
|
38%,
|
||||||
|
100% {
|
||||||
|
opacity: var(--core-low, 0.25);
|
||||||
|
}
|
||||||
|
52% {
|
||||||
|
opacity: var(--core-high, 0.55);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes star-glint-pulse {
|
||||||
|
0%,
|
||||||
|
35%,
|
||||||
|
100% {
|
||||||
|
opacity: var(--glint-base, 0.02);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: var(--glint-peak, 0.82);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
opacity: calc(var(--glint-peak, 0.82) * 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes star-bloom-pulse {
|
||||||
|
0%,
|
||||||
|
35%,
|
||||||
|
100% {
|
||||||
|
opacity: var(--bloom-base, 0.01);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: var(--bloom-peak, 0.18);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
opacity: calc(var(--bloom-peak, 0.18) * 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-twinkle {
|
||||||
|
animation: twinkle 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-core {
|
||||||
|
animation-name: star-core-pulse;
|
||||||
|
animation-timing-function: ease-in-out;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
opacity: var(--core-low, 0.25);
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-core-bloom {
|
||||||
|
animation-name: star-bloom-pulse;
|
||||||
|
animation-timing-function: ease-in-out;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
opacity: var(--bloom-base, 0.01);
|
||||||
|
fill: currentColor;
|
||||||
|
filter: blur(0.7px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-glint {
|
||||||
|
animation-name: star-glint-pulse;
|
||||||
|
animation-timing-function: ease-in-out;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
opacity: var(--glint-base, 0.02);
|
||||||
|
stroke-width: 0.62;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-glint-bloom {
|
||||||
|
animation-name: star-bloom-pulse;
|
||||||
|
animation-timing-function: ease-in-out;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
opacity: var(--bloom-base, 0.01);
|
||||||
|
stroke-width: 0.9;
|
||||||
|
stroke-linecap: round;
|
||||||
|
filter: blur(0.65px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-twinkle {
|
||||||
|
animation: none;
|
||||||
|
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-core {
|
||||||
|
animation: none;
|
||||||
|
opacity: var(--core-reduced, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-core-bloom {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-glint {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-glint-bloom {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.04;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { I18nLayoutShell } from "@/features/i18n/ui/I18nLayoutShell";
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Focustella",
|
||||||
description: "Generated by create next app",
|
description: "Space-themed focus timer",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,11 +13,9 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className="dark">
|
||||||
<body
|
<body className="bg-slate-950 text-slate-100 min-h-screen font-sans selection:bg-indigo-500/30 overflow-x-hidden">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<I18nLayoutShell>{children}</I18nLayoutShell>
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
79
src/app/log/[id]/page.tsx
Normal file
79
src/app/log/[id]/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, use } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useI18n } from '@/features/i18n/model/useI18n';
|
||||||
|
import { VOYAGE_STATUS_LABEL_KEYS } from '@/shared/config/i18n';
|
||||||
|
import { findRouteById } from '@/shared/config/routes';
|
||||||
|
import { getHistory } from '@/shared/lib/store';
|
||||||
|
import { Voyage } from '@/shared/types';
|
||||||
|
|
||||||
|
export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
// Next.js 15: params is a Promise
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const [log, setLog] = useState<Voyage | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const history = getHistory();
|
||||||
|
const found = history.find(item => item.id === resolvedParams.id);
|
||||||
|
if (found) setLog(found);
|
||||||
|
}, [resolvedParams.id]);
|
||||||
|
|
||||||
|
if (!log) {
|
||||||
|
return <div className="p-6 text-slate-500">{t('log.detail.loadingOrNotFound')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = findRouteById(log.routeId);
|
||||||
|
const routeName = route ? t(route.nameKey, undefined, log.routeName) : log.routeName;
|
||||||
|
const statusLabel = t(VOYAGE_STATUS_LABEL_KEYS[log.status], undefined, t('status.in_progress'));
|
||||||
|
const missionLabel = (() => {
|
||||||
|
const trimmed = log.missionText.trim();
|
||||||
|
if (!trimmed || trimmed === '미입력') {
|
||||||
|
return t('log.mission.empty');
|
||||||
|
}
|
||||||
|
return log.missionText;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-6 space-y-8 animate-in fade-in duration-300">
|
||||||
|
<header className="border-b border-slate-800 pb-4">
|
||||||
|
<div className="mb-4 flex items-center gap-4 text-sm">
|
||||||
|
<Link href="/log" className="text-indigo-400 transition-colors hover:text-indigo-300">
|
||||||
|
← {t('log.detail.back')}
|
||||||
|
</Link>
|
||||||
|
<Link href="/" className="text-indigo-400 transition-colors hover:text-indigo-300">
|
||||||
|
← Lobby
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">{missionLabel}</h1>
|
||||||
|
<div className="flex gap-3 mt-2 text-sm text-slate-500">
|
||||||
|
<span>{new Date(log.startedAt).toLocaleString(locale)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{routeName} ({log.durationMinutes}{t('common.minuteShort')})</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-slate-900/30 p-4 rounded-lg border border-slate-800/50">
|
||||||
|
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.statusTitle')}</h3>
|
||||||
|
<p className="text-lg text-indigo-100">{statusLabel}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.progressTitle')}</h3>
|
||||||
|
<p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.debriefProgress || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{log.notes && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.initialNoteTitle')}</h3>
|
||||||
|
<p className="text-slate-400 text-sm italic">"{log.notes}"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/app/log/page.tsx
Normal file
86
src/app/log/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useI18n } from '@/features/i18n/model/useI18n';
|
||||||
|
import { VOYAGE_STATUS_LABEL_KEYS } from '@/shared/config/i18n';
|
||||||
|
import { findRouteById } from '@/shared/config/routes';
|
||||||
|
import { getHistory } from '@/shared/lib/store';
|
||||||
|
import { Voyage, VoyageStatus } from '@/shared/types';
|
||||||
|
|
||||||
|
export default function LogListPage() {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const [logs, setLogs] = useState<Voyage[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLogs(getHistory());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getStatusLabel = (s: VoyageStatus) => {
|
||||||
|
return t(VOYAGE_STATUS_LABEL_KEYS[s], undefined, t('status.in_progress'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMissionLabel = (missionText: string) => {
|
||||||
|
const trimmed = missionText.trim();
|
||||||
|
if (!trimmed || trimmed === '미입력') {
|
||||||
|
return t('log.mission.empty');
|
||||||
|
}
|
||||||
|
return missionText;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-6">
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-3">
|
||||||
|
<h1 className="text-xl font-bold text-slate-100">{t('log.title')}</h1>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm text-indigo-400 transition-colors hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
← Lobby
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1 py-20 text-slate-500 border border-dashed border-slate-800 rounded-xl">
|
||||||
|
<p className="mb-4">{t('log.empty')}</p>
|
||||||
|
<Link href="/" className="text-indigo-400 hover:text-indigo-300 underline">
|
||||||
|
{t('log.firstVoyage')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{logs.map((log) => {
|
||||||
|
const route = findRouteById(log.routeId);
|
||||||
|
const routeName = route
|
||||||
|
? t(route.nameKey, undefined, log.routeName)
|
||||||
|
: log.routeName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={log.id}
|
||||||
|
href={`/log/${log.id}`}
|
||||||
|
className="block bg-slate-900/50 border border-slate-800 hover:border-slate-600 rounded-lg p-4 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{new Date(log.startedAt).toLocaleDateString(locale)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded">
|
||||||
|
{getStatusLabel(log.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-slate-200 truncate mb-1">
|
||||||
|
{getMissionLabel(log.missionText)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{routeName} · {log.durationMinutes}
|
||||||
|
{t('common.minuteShort')}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
src/app/page.tsx
267
src/app/page.tsx
@@ -1,266 +1,13 @@
|
|||||||
// app/page.tsx
|
'use client';
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { LobbyBackgroundWidget } from '@/widgets/lobby-background';
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { LobbyRoutesPanel } from '@/widgets/lobby-routes';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
|
|
||||||
type Mode = "freeflow" | "sprint" | "deepwork";
|
|
||||||
|
|
||||||
const PRIMARY = "#2F6FED";
|
|
||||||
const PRIMARY_HOVER = "#295FD1";
|
|
||||||
|
|
||||||
function modeLabel(mode: Mode) {
|
|
||||||
switch (mode) {
|
|
||||||
case "freeflow":
|
|
||||||
return "프리플로우";
|
|
||||||
case "sprint":
|
|
||||||
return "스프린트";
|
|
||||||
case "deepwork":
|
|
||||||
return "딥워크";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeMeta(mode: Mode) {
|
|
||||||
if (mode === "freeflow") return "무제한";
|
|
||||||
if (mode === "sprint") return "25분";
|
|
||||||
return "90분";
|
|
||||||
}
|
|
||||||
|
|
||||||
function startLabel(mode: Mode) {
|
|
||||||
if (mode === "freeflow") return "집중 시작";
|
|
||||||
return `집중 시작 (${modeMeta(mode)})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [mode, setMode] = useState<Mode | null>(null);
|
|
||||||
const [goal, setGoal] = useState("");
|
|
||||||
|
|
||||||
const meta = useMemo(() => (mode ? modeMeta(mode) : ""), [mode]);
|
|
||||||
|
|
||||||
const go = useCallback(
|
|
||||||
(m: Mode, g?: string) => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set("mode", m);
|
|
||||||
|
|
||||||
if (g && g.trim())
|
|
||||||
localStorage.setItem("hushroom:session-goal", g.trim());
|
|
||||||
else localStorage.removeItem("hushroom:session-goal");
|
|
||||||
|
|
||||||
// nextAction은 사용하지 않음
|
|
||||||
localStorage.removeItem("hushroom:session-nextAction");
|
|
||||||
|
|
||||||
localStorage.setItem("hushroom:last-mode", m);
|
|
||||||
router.push(`/session?${params.toString()}`);
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
const openDialog = (m: Mode) => {
|
|
||||||
setMode(m);
|
|
||||||
setGoal("");
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const start = () => {
|
|
||||||
if (!mode) return;
|
|
||||||
setOpen(false);
|
|
||||||
go(mode, goal);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen w-full bg-[#E9EEF6]">
|
<div className="relative left-1/2 right-1/2 -mx-[50vw] flex w-screen flex-1 flex-col animate-in fade-in duration-500">
|
||||||
<header className="px-5 pt-6">
|
<LobbyBackgroundWidget />
|
||||||
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
<LobbyRoutesPanel />
|
||||||
hushroom
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm font-semibold text-slate-600">
|
|
||||||
딱 한 가지 목표. 바로 시작.
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10">
|
|
||||||
{/* ✅ 파란 CTA = 프리플로우 */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="text-sm font-semibold text-slate-600">자유 세션</div>
|
|
||||||
<div className="mt-1 text-base leading-relaxed text-slate-700">
|
|
||||||
시간 제한 없이, 원할 때 종료 (60분마다 가볍게 노크)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openDialog("freeflow")}
|
|
||||||
className="h-auto w-full items-start justify-start whitespace-normal rounded-3xl bg-[#2F6FED]
|
|
||||||
px-8 py-6 text-left text-white shadow-sm transition active:scale-[0.99] hover:bg-[#295FD1]"
|
|
||||||
>
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="text-2xl font-semibold leading-none">
|
|
||||||
프리플로우
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-lg opacity-90">무제한</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="my-8 flex items-center gap-3">
|
|
||||||
<Separator className="flex-1 bg-[#D7E0EE]" />
|
|
||||||
<div className="text-sm font-semibold text-slate-600">몰입 블록</div>
|
|
||||||
<Separator className="flex-1 bg-[#D7E0EE]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ✅ 버튼 위에 설명 (버튼 안에 설명 X) */}
|
|
||||||
<div className="-mt-2 mb-5">
|
|
||||||
<div className="text-sm font-semibold text-slate-600">
|
|
||||||
시간 고정 세션
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-base leading-relaxed text-slate-700">
|
|
||||||
한 번 실행되고 끝나면 요약으로 이동
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ✅ row(2열) */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<ModeTile
|
|
||||||
title="스프린트"
|
|
||||||
meta="25분"
|
|
||||||
onClick={() => openDialog("sprint")}
|
|
||||||
/>
|
|
||||||
<ModeTile
|
|
||||||
title="딥워크"
|
|
||||||
meta="90분"
|
|
||||||
onClick={() => openDialog("deepwork")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<SessionGoalDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
mode={mode}
|
|
||||||
meta={meta}
|
|
||||||
goal={goal}
|
|
||||||
setGoal={setGoal}
|
|
||||||
onStart={start}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModeTile({
|
|
||||||
title,
|
|
||||||
meta,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
meta: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="rounded-3xl border border-[#C9D7F5] bg-white shadow-sm transition hover:bg-[#F1F5FF]">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onClick}
|
|
||||||
className="h-auto w-full rounded-3xl px-7 py-5 text-left active:scale-[0.99] hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-baseline justify-between">
|
|
||||||
<div className="text-xl font-semibold text-slate-900">{title}</div>
|
|
||||||
<div className="text-lg font-semibold text-blue-700">{meta}</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionGoalDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
mode,
|
|
||||||
meta,
|
|
||||||
goal,
|
|
||||||
setGoal,
|
|
||||||
onStart,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (v: boolean) => void;
|
|
||||||
mode: Mode | null;
|
|
||||||
meta: string;
|
|
||||||
goal: string;
|
|
||||||
setGoal: (v: string) => void;
|
|
||||||
onStart: () => void;
|
|
||||||
}) {
|
|
||||||
const title = mode ? `${modeLabel(mode)} · ${meta}` : "세션";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-md rounded-2xl">
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onStart();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl">세션 목표 설정</DialogTitle>
|
|
||||||
<div className="text-sm text-slate-600">{title}</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
<Input
|
|
||||||
value={goal}
|
|
||||||
onChange={(e) => setGoal(e.target.value)}
|
|
||||||
placeholder="지금 할 한 가지를 한 줄로 적어주세요 (선택)"
|
|
||||||
className="text-lg focus-visible:ring-2"
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && (e.nativeEvent as any).isComposing) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="text-xs text-slate-500">
|
|
||||||
짧게 적을수록 좋아요. 끝이 보이게.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-6 gap-2 sm:gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-xl"
|
|
||||||
style={{ backgroundColor: PRIMARY }}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = PRIMARY)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{mode ? startLabel(mode) : "집중 시작"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
// app/session/end/page.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { Suspense, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
type Mode = "freeflow" | "sprint" | "deepwork";
|
|
||||||
|
|
||||||
const BG = "#E9EEF6";
|
|
||||||
const BORDER = "#C9D7F5";
|
|
||||||
const PRIMARY = "#2F6FED";
|
|
||||||
const PRIMARY_HOVER = "#295FD1";
|
|
||||||
|
|
||||||
function clampMode(v: string | null): Mode {
|
|
||||||
if (v === "sprint" || v === "deepwork" || v === "freeflow") return v;
|
|
||||||
return "freeflow";
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeLabel(mode: Mode) {
|
|
||||||
if (mode === "sprint") return "스프린트";
|
|
||||||
if (mode === "deepwork") return "딥워크";
|
|
||||||
return "프리플로우";
|
|
||||||
}
|
|
||||||
|
|
||||||
function hhmmss(total: number) {
|
|
||||||
const s = Math.max(0, Math.floor(total));
|
|
||||||
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
|
|
||||||
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
|
|
||||||
const ss = String(s % 60).padStart(2, "0");
|
|
||||||
return `${hh}:${mm}:${ss}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SessionEndInner />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionEndInner() {
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useSearchParams();
|
|
||||||
|
|
||||||
const [elapsed, setElapsed] = useState(() => {
|
|
||||||
const v = Number(localStorage.getItem("hushroom:session-elapsed") ?? "0");
|
|
||||||
return Number.isFinite(v) ? v : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [goal, setGoal] = useState(() => {
|
|
||||||
const goal = localStorage.getItem("hushroom:session-goal");
|
|
||||||
return goal;
|
|
||||||
});
|
|
||||||
|
|
||||||
const mode = useMemo(() => clampMode(params.get("mode")), [params]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }}>
|
|
||||||
<header className="px-5 pt-6">
|
|
||||||
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
|
||||||
hushroom
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10 pt-6">
|
|
||||||
<div
|
|
||||||
className="rounded-3xl border bg-white px-6 py-6 shadow-sm"
|
|
||||||
style={{ borderColor: BORDER }}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-semibold text-slate-600">
|
|
||||||
{modeLabel(mode)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
|
|
||||||
{hhmmss(elapsed)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Goal */}
|
|
||||||
{goal && (
|
|
||||||
<div
|
|
||||||
className="mt-4 rounded-2xl border bg-[#F1F5FF] px-4 py-3"
|
|
||||||
style={{ borderColor: BORDER }}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-semibold text-slate-600">
|
|
||||||
이번 목표
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xl font-semibold text-slate-900 line-clamp-2">
|
|
||||||
{goal}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 text-base text-slate-700">세션이 종료됐어요</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="rounded-3xl border bg-white px-5 py-4 text-base font-semibold text-slate-800 shadow-sm transition active:scale-[0.99]"
|
|
||||||
style={{ borderColor: BORDER }}
|
|
||||||
>
|
|
||||||
홈
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push(`/session?mode=${mode}`)}
|
|
||||||
className="rounded-3xl px-5 py-4 text-base font-semibold text-white shadow-sm transition active:scale-[0.99]"
|
|
||||||
style={{ backgroundColor: PRIMARY }}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = PRIMARY)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
다시
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,648 +0,0 @@
|
|||||||
// app/session/page.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
|
|
||||||
type Mode = "freeflow" | "sprint" | "deepwork";
|
|
||||||
type PresenceStatus = "focus" | "away";
|
|
||||||
|
|
||||||
type Participant = {
|
|
||||||
id: string;
|
|
||||||
status: PresenceStatus;
|
|
||||||
lastSeen: number; // ms
|
|
||||||
isSelf: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RecenterReason = "distracted" | "stuck" | "tired" | "overwhelmed";
|
|
||||||
|
|
||||||
type RecenterOption = {
|
|
||||||
id: string;
|
|
||||||
label: string; // 화면 문구(2분 미션)
|
|
||||||
pinText: string; // "다음 단계로 고정" 시 저장될 짧은 문구
|
|
||||||
};
|
|
||||||
|
|
||||||
const BG = "#E9EEF6";
|
|
||||||
const BORDER = "#C9D7F5";
|
|
||||||
const PRIMARY = "#2F6FED";
|
|
||||||
const PRIMARY_HOVER = "#295FD1";
|
|
||||||
const HOVER = "#F1F5FF";
|
|
||||||
|
|
||||||
// localStorage keys (세션 시간/자리비움 복구용)
|
|
||||||
const LS = {
|
|
||||||
active: "hushroom:session-active",
|
|
||||||
mode: "hushroom:session-mode",
|
|
||||||
id: "hushroom:session-id",
|
|
||||||
startedAt: "hushroom:session-startedAt",
|
|
||||||
awayTotalMs: "hushroom:session-awayTotalMs",
|
|
||||||
awayStartedAt: "hushroom:session-awayStartedAt",
|
|
||||||
isAway: "hushroom:session-isAway",
|
|
||||||
|
|
||||||
goal: "hushroom:session-goal",
|
|
||||||
nextAction: "hushroom:session-nextAction",
|
|
||||||
|
|
||||||
endElapsed: "hushroom:session-elapsed",
|
|
||||||
endAway: "hushroom:session-away",
|
|
||||||
endReason: "hushroom:session-end-reason",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function clampMode(v: string | null): Mode {
|
|
||||||
if (v === "sprint" || v === "deepwork" || v === "freeflow") return v;
|
|
||||||
return "freeflow";
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeLabel(mode: Mode) {
|
|
||||||
if (mode === "sprint") return "스프린트";
|
|
||||||
if (mode === "deepwork") return "딥워크";
|
|
||||||
return "프리플로우";
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeDurationSeconds(mode: Mode) {
|
|
||||||
if (mode === "sprint") return 25 * 60;
|
|
||||||
if (mode === "deepwork") return 90 * 60;
|
|
||||||
return null; // freeflow
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHHMMSS(totalSeconds: number) {
|
|
||||||
const s = Math.max(0, Math.floor(totalSeconds));
|
|
||||||
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
|
|
||||||
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
|
|
||||||
const ss = String(s % 60).padStart(2, "0");
|
|
||||||
return `${hh}:${mm}:${ss}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeNumber(v: string | null, fallback = 0) {
|
|
||||||
const n = Number(v ?? "");
|
|
||||||
return Number.isFinite(n) ? n : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function newId() {
|
|
||||||
return (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
|
|
||||||
.replace(/[^a-zA-Z0-9]/g, "")
|
|
||||||
.slice(0, 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
|
||||||
const selfId = useMemo(() => {
|
|
||||||
const key = `hushroom:selfId:${roomKey}`;
|
|
||||||
const existing = sessionStorage.getItem(key);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const id = (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
|
|
||||||
.replace(/[^a-zA-Z0-9]/g, "")
|
|
||||||
.slice(0, 16);
|
|
||||||
|
|
||||||
sessionStorage.setItem(key, id);
|
|
||||||
return id;
|
|
||||||
}, [roomKey]);
|
|
||||||
|
|
||||||
const [participants, setParticipants] = useState<Participant[]>(() => [
|
|
||||||
{ id: selfId, status, lastSeen: Date.now(), isSelf: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const participantsRef = useRef<Map<string, Omit<Participant, "isSelf">>>(
|
|
||||||
new Map([[selfId, { id: selfId, status, lastSeen: Date.now() }]]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const channelRef = useRef<BroadcastChannel | null>(null);
|
|
||||||
const heartbeatRef = useRef<number | null>(null);
|
|
||||||
const cleanupRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const publish = (payload: any) => {
|
|
||||||
if (channelRef.current) {
|
|
||||||
channelRef.current.postMessage(payload);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
`hushroom:presence:${roomKey}`,
|
|
||||||
JSON.stringify({ ...payload, _nonce: Math.random() }),
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncStateToReact = () => {
|
|
||||||
const now = Date.now();
|
|
||||||
const arr: Participant[] = [];
|
|
||||||
participantsRef.current.forEach((p, id) => {
|
|
||||||
arr.push({
|
|
||||||
id,
|
|
||||||
status: p.status,
|
|
||||||
lastSeen: p.lastSeen,
|
|
||||||
isSelf: id === selfId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
arr.sort((a, b) => {
|
|
||||||
if (a.isSelf && !b.isSelf) return -1;
|
|
||||||
if (!a.isSelf && b.isSelf) return 1;
|
|
||||||
return b.lastSeen - a.lastSeen;
|
|
||||||
});
|
|
||||||
|
|
||||||
const self = participantsRef.current.get(selfId);
|
|
||||||
if (self) self.lastSeen = now;
|
|
||||||
|
|
||||||
setParticipants(arr);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if ("BroadcastChannel" in window) {
|
|
||||||
const bc = new BroadcastChannel(`hushroom-presence:${roomKey}`);
|
|
||||||
channelRef.current = bc;
|
|
||||||
bc.onmessage = (ev) => {
|
|
||||||
const msg = ev.data;
|
|
||||||
if (!msg || msg.roomKey !== roomKey) return;
|
|
||||||
if (!msg.from) return;
|
|
||||||
|
|
||||||
if (msg.type === "ping") {
|
|
||||||
participantsRef.current.set(msg.from, {
|
|
||||||
id: msg.from,
|
|
||||||
status: msg.status as PresenceStatus,
|
|
||||||
lastSeen: msg.ts ?? Date.now(),
|
|
||||||
});
|
|
||||||
syncStateToReact();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === "leave") {
|
|
||||||
participantsRef.current.delete(msg.from);
|
|
||||||
syncStateToReact();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const onStorage = (e: StorageEvent) => {
|
|
||||||
if (e.key !== `hushroom:presence:${roomKey}` || !e.newValue) return;
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(e.newValue);
|
|
||||||
if (!msg || msg.roomKey !== roomKey) return;
|
|
||||||
|
|
||||||
if (msg.type === "ping") {
|
|
||||||
participantsRef.current.set(msg.from, {
|
|
||||||
id: msg.from,
|
|
||||||
status: msg.status as PresenceStatus,
|
|
||||||
lastSeen: msg.ts ?? Date.now(),
|
|
||||||
});
|
|
||||||
syncStateToReact();
|
|
||||||
}
|
|
||||||
if (msg.type === "leave") {
|
|
||||||
participantsRef.current.delete(msg.from);
|
|
||||||
syncStateToReact();
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
return () => window.removeEventListener("storage", onStorage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
channelRef.current?.close();
|
|
||||||
channelRef.current = null;
|
|
||||||
};
|
|
||||||
}, [roomKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
participantsRef.current.set(selfId, { id: selfId, status, lastSeen: now });
|
|
||||||
publish({ type: "ping", roomKey, from: selfId, status, ts: now });
|
|
||||||
syncStateToReact();
|
|
||||||
}, [status, roomKey, selfId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
heartbeatRef.current = window.setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
participantsRef.current.set(selfId, {
|
|
||||||
id: selfId,
|
|
||||||
status,
|
|
||||||
lastSeen: now,
|
|
||||||
});
|
|
||||||
publish({ type: "ping", roomKey, from: selfId, status, ts: now });
|
|
||||||
syncStateToReact();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
cleanupRef.current = window.setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const STALE_MS = 9000;
|
|
||||||
let changed = false;
|
|
||||||
|
|
||||||
participantsRef.current.forEach((p, id) => {
|
|
||||||
if (id === selfId) return;
|
|
||||||
if (now - p.lastSeen > STALE_MS) {
|
|
||||||
participantsRef.current.delete(id);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (changed) syncStateToReact();
|
|
||||||
}, 4000);
|
|
||||||
|
|
||||||
const onBeforeUnload = () => {
|
|
||||||
publish({ type: "leave", roomKey, from: selfId, ts: Date.now() });
|
|
||||||
};
|
|
||||||
window.addEventListener("beforeunload", onBeforeUnload);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (heartbeatRef.current) window.clearInterval(heartbeatRef.current);
|
|
||||||
if (cleanupRef.current) window.clearInterval(cleanupRef.current);
|
|
||||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
||||||
publish({ type: "leave", roomKey, from: selfId, ts: Date.now() });
|
|
||||||
};
|
|
||||||
}, [roomKey, selfId, status]);
|
|
||||||
|
|
||||||
return { participants, selfId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SessionInner />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionInner() {
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useSearchParams();
|
|
||||||
const mode = useMemo(() => clampMode(params.get("mode")), [params]);
|
|
||||||
const duration = useMemo(() => modeDurationSeconds(mode), [mode]);
|
|
||||||
|
|
||||||
const [goal, setGoal] = useState("");
|
|
||||||
const [nextAction, setNextAction] = useState("");
|
|
||||||
|
|
||||||
// ✅ startedAt/now 기반 시간
|
|
||||||
const [startedAt, setStartedAt] = useState<number>(() => Date.now());
|
|
||||||
const [now, setNow] = useState<number>(() => Date.now());
|
|
||||||
const endedRef = useRef(false);
|
|
||||||
|
|
||||||
// ✅ away: 누적 ms + (현재 away면 now-startedAt)
|
|
||||||
const [isAway, setIsAway] = useState(false);
|
|
||||||
const awayTotalMsRef = useRef(0);
|
|
||||||
const awayStartedAtRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
// presence (로컬)
|
|
||||||
const presenceStatus: PresenceStatus = isAway ? "away" : "focus";
|
|
||||||
const roomKey = "lounge";
|
|
||||||
const { participants } = useLocalPresence(roomKey, presenceStatus);
|
|
||||||
|
|
||||||
// toast
|
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
|
||||||
const toastTimerRef = useRef<number | null>(null);
|
|
||||||
const showToast = (msg: string) => {
|
|
||||||
setToast(msg);
|
|
||||||
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
|
|
||||||
toastTimerRef.current = window.setTimeout(() => setToast(null), 8000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// freeflow checkpoint every 60 minutes
|
|
||||||
const lastCheckpointRef = useRef<number>(0);
|
|
||||||
|
|
||||||
// check-in (배너)
|
|
||||||
const [checkinOpen, setCheckinOpen] = useState(false);
|
|
||||||
const [checkinStep, setCheckinStep] = useState<
|
|
||||||
"ask" | "reason" | "pick" | "running" | "done"
|
|
||||||
>("ask");
|
|
||||||
const shownCheckinsRef = useRef<Set<number>>(new Set());
|
|
||||||
|
|
||||||
const [recenterReason, setRecenterReason] = useState<RecenterReason | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [mission, setMission] = useState<{
|
|
||||||
option: RecenterOption;
|
|
||||||
endsAt: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const [missionLeft, setMissionLeft] = useState<number>(0);
|
|
||||||
|
|
||||||
const checkinTimes = useMemo(() => {
|
|
||||||
if (mode === "sprint") return [12 * 60];
|
|
||||||
if (mode === "deepwork") return [30 * 60, 60 * 60];
|
|
||||||
return [];
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
const resetRecenter = () => {
|
|
||||||
setRecenterReason(null);
|
|
||||||
setMission(null);
|
|
||||||
setMissionLeft(0);
|
|
||||||
setCheckinStep("ask");
|
|
||||||
setCheckinOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 세션 시작/복구 (mode가 바뀌면 새 세션)
|
|
||||||
useEffect(() => {
|
|
||||||
endedRef.current = false;
|
|
||||||
|
|
||||||
const active = localStorage.getItem(LS.active) === "1";
|
|
||||||
const storedMode = localStorage.getItem(LS.mode);
|
|
||||||
const storedStartedAt = safeNumber(localStorage.getItem(LS.startedAt), 0);
|
|
||||||
|
|
||||||
const shouldResume = active && storedMode === mode && storedStartedAt > 0;
|
|
||||||
|
|
||||||
if (shouldResume) {
|
|
||||||
setStartedAt(storedStartedAt);
|
|
||||||
setNow(Date.now());
|
|
||||||
|
|
||||||
const isAwayStored = localStorage.getItem(LS.isAway) === "1";
|
|
||||||
const awayTotal = safeNumber(localStorage.getItem(LS.awayTotalMs), 0);
|
|
||||||
const awayStartedAt = safeNumber(
|
|
||||||
localStorage.getItem(LS.awayStartedAt),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
awayTotalMsRef.current = Math.max(0, awayTotal);
|
|
||||||
awayStartedAtRef.current =
|
|
||||||
isAwayStored && awayStartedAt > 0 ? awayStartedAt : null;
|
|
||||||
setIsAway(isAwayStored);
|
|
||||||
} else {
|
|
||||||
const t = Date.now();
|
|
||||||
localStorage.setItem(LS.active, "1");
|
|
||||||
localStorage.setItem(LS.mode, mode);
|
|
||||||
localStorage.setItem(LS.id, newId());
|
|
||||||
localStorage.setItem(LS.startedAt, String(t));
|
|
||||||
localStorage.setItem(LS.awayTotalMs, "0");
|
|
||||||
localStorage.removeItem(LS.awayStartedAt);
|
|
||||||
localStorage.setItem(LS.isAway, "0");
|
|
||||||
|
|
||||||
setStartedAt(t);
|
|
||||||
setNow(t);
|
|
||||||
|
|
||||||
awayTotalMsRef.current = 0;
|
|
||||||
awayStartedAtRef.current = null;
|
|
||||||
setIsAway(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI 관련 리셋(기존 로직 유지)
|
|
||||||
setToast(null);
|
|
||||||
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
|
|
||||||
lastCheckpointRef.current = 0;
|
|
||||||
shownCheckinsRef.current = new Set();
|
|
||||||
resetRecenter();
|
|
||||||
setCheckinOpen(false);
|
|
||||||
}, [mode, duration]);
|
|
||||||
|
|
||||||
// 목표/다음 단계(고정) 로드
|
|
||||||
useEffect(() => {
|
|
||||||
setGoal(localStorage.getItem(LS.goal) ?? "");
|
|
||||||
setNextAction(localStorage.getItem(LS.nextAction) ?? "");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// now 갱신(탭 전환/잠자기 복귀 시에도 보정)
|
|
||||||
useEffect(() => {
|
|
||||||
const tick = () => setNow(Date.now());
|
|
||||||
|
|
||||||
tick();
|
|
||||||
const id = window.setInterval(tick, 250);
|
|
||||||
|
|
||||||
const onVis = () => tick();
|
|
||||||
const onFocus = () => tick();
|
|
||||||
const onPageShow = () => tick();
|
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", onVis);
|
|
||||||
window.addEventListener("focus", onFocus);
|
|
||||||
window.addEventListener("pageshow", onPageShow);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(id);
|
|
||||||
document.removeEventListener("visibilitychange", onVis);
|
|
||||||
window.removeEventListener("focus", onFocus);
|
|
||||||
window.removeEventListener("pageshow", onPageShow);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const elapsed = useMemo(() => {
|
|
||||||
const d = now - startedAt;
|
|
||||||
return Math.max(0, Math.floor(d / 1000));
|
|
||||||
}, [now, startedAt]);
|
|
||||||
|
|
||||||
const remaining = useMemo(() => {
|
|
||||||
if (!duration) return 0;
|
|
||||||
return Math.max(0, duration - elapsed);
|
|
||||||
}, [duration, elapsed]);
|
|
||||||
|
|
||||||
const awayMs = useMemo(() => {
|
|
||||||
const base = awayTotalMsRef.current;
|
|
||||||
const extra = awayStartedAtRef.current ? now - awayStartedAtRef.current : 0;
|
|
||||||
return Math.max(0, base + extra);
|
|
||||||
}, [now, isAway]); // isAway 변화 시 즉시 반영
|
|
||||||
|
|
||||||
const awaySeconds = useMemo(() => Math.floor(awayMs / 1000), [awayMs]);
|
|
||||||
|
|
||||||
const focusSeconds = Math.max(0, elapsed - awaySeconds);
|
|
||||||
|
|
||||||
const timeMain = useMemo(() => {
|
|
||||||
if (mode === "freeflow") return formatHHMMSS(elapsed);
|
|
||||||
return formatHHMMSS(remaining);
|
|
||||||
}, [elapsed, remaining, mode]);
|
|
||||||
|
|
||||||
// ✅ 타임아웃 종료(점프 포함)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!duration) return;
|
|
||||||
if (endedRef.current) return;
|
|
||||||
if (elapsed < duration) return;
|
|
||||||
|
|
||||||
endedRef.current = true;
|
|
||||||
|
|
||||||
localStorage.setItem(LS.endElapsed, String(duration));
|
|
||||||
localStorage.setItem(LS.endAway, String(awaySeconds));
|
|
||||||
localStorage.setItem(LS.endReason, "timed_out");
|
|
||||||
|
|
||||||
localStorage.setItem(LS.active, "0");
|
|
||||||
router.push(`/session/end?mode=${mode}`);
|
|
||||||
}, [elapsed, duration, mode, router, awaySeconds]);
|
|
||||||
|
|
||||||
// freeflow 60분 토스트(점프 시에도 “현재 배수” 기준으로 1회만)
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== "freeflow") return;
|
|
||||||
const mins = Math.floor(elapsed / 60);
|
|
||||||
if (mins > 0 && mins % 60 === 0 && mins !== lastCheckpointRef.current) {
|
|
||||||
lastCheckpointRef.current = mins;
|
|
||||||
showToast(`${mins}분 지났어요`);
|
|
||||||
}
|
|
||||||
}, [elapsed, mode]);
|
|
||||||
|
|
||||||
// 체크인(ask) 트리거: freeflow 제외 / 자리비움 제외 / 이미 열림 제외 / 미션 중 제외
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode === "freeflow") return;
|
|
||||||
if (isAway) return;
|
|
||||||
if (checkinOpen) return;
|
|
||||||
if (mission) return;
|
|
||||||
|
|
||||||
for (const t of checkinTimes) {
|
|
||||||
if (elapsed >= t && !shownCheckinsRef.current.has(t)) {
|
|
||||||
shownCheckinsRef.current.add(t);
|
|
||||||
setCheckinStep("ask");
|
|
||||||
setCheckinOpen(true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [elapsed, mode, isAway, checkinOpen, mission, checkinTimes]);
|
|
||||||
|
|
||||||
// ✅ 자리비움 토글(누적 ms 기반)
|
|
||||||
const toggleAway = () => {
|
|
||||||
const t = Date.now();
|
|
||||||
const next = !isAway;
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
// away 시작
|
|
||||||
awayStartedAtRef.current = t;
|
|
||||||
localStorage.setItem(LS.awayStartedAt, String(t));
|
|
||||||
localStorage.setItem(LS.isAway, "1");
|
|
||||||
setIsAway(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// away 종료
|
|
||||||
if (awayStartedAtRef.current) {
|
|
||||||
awayTotalMsRef.current += t - awayStartedAtRef.current;
|
|
||||||
localStorage.setItem(LS.awayTotalMs, String(awayTotalMsRef.current));
|
|
||||||
}
|
|
||||||
awayStartedAtRef.current = null;
|
|
||||||
localStorage.removeItem(LS.awayStartedAt);
|
|
||||||
localStorage.setItem(LS.isAway, "0");
|
|
||||||
setIsAway(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEnd = () => {
|
|
||||||
if (endedRef.current) return;
|
|
||||||
endedRef.current = true;
|
|
||||||
|
|
||||||
localStorage.setItem(LS.endElapsed, String(elapsed));
|
|
||||||
localStorage.setItem(LS.endAway, String(awaySeconds));
|
|
||||||
localStorage.setItem(LS.endReason, "manual");
|
|
||||||
|
|
||||||
localStorage.setItem(LS.active, "0");
|
|
||||||
router.push(`/session/end?mode=${mode}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }}>
|
|
||||||
<header className="px-5 pt-6">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
|
||||||
hushroom
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs font-semibold text-slate-600">
|
|
||||||
집중 {Math.floor(focusSeconds / 60)}분 · 자리비움{" "}
|
|
||||||
{Math.floor(awaySeconds / 60)}분
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PresenceDots participants={participants} />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10 pt-6">
|
|
||||||
{toast && (
|
|
||||||
<div className="mb-4 rounded-2xl border border-[#D7E0EE] bg-white px-4 py-3 text-sm text-slate-700 shadow-sm">
|
|
||||||
{toast}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timer */}
|
|
||||||
<div
|
|
||||||
className="rounded-3xl border bg-white px-6 py-6 shadow-sm"
|
|
||||||
style={{ borderColor: BORDER }}
|
|
||||||
>
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-slate-600">
|
|
||||||
{modeLabel(mode)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-base leading-relaxed text-slate-700">
|
|
||||||
{mode === "freeflow"
|
|
||||||
? "원할 때 종료"
|
|
||||||
: "한 번 실행되고 끝나면 요약으로 이동"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Goal */}
|
|
||||||
{goal && (
|
|
||||||
<div
|
|
||||||
className="mt-4 rounded-2xl border bg-[#F1F5FF] px-4 py-3"
|
|
||||||
style={{ borderColor: BORDER }}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-semibold text-slate-600">
|
|
||||||
이번 목표
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xl font-semibold text-slate-900 line-clamp-2 break-words">
|
|
||||||
{goal}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
|
|
||||||
{timeMain}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleAway}
|
|
||||||
className="rounded-3xl border bg-white px-4 py-4 text-base font-semibold text-slate-800 shadow-sm transition active:scale-[0.99]"
|
|
||||||
style={{ borderColor: BORDER }}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = HOVER)
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = "#FFFFFF")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isAway ? "복귀" : "자리비움"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEnd}
|
|
||||||
className="rounded-3xl px-4 py-4 text-base font-semibold text-white shadow-sm transition active:scale-[0.99]"
|
|
||||||
style={{ backgroundColor: PRIMARY }}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = PRIMARY)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
종료
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PresenceDots({ participants }: { participants: Participant[] }) {
|
|
||||||
const MAX = 12;
|
|
||||||
const visible = participants.slice(0, MAX);
|
|
||||||
const extra = Math.max(0, participants.length - visible.length);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{visible.map((p) => (
|
|
||||||
<Dot key={p.id} status={p.status} isSelf={p.isSelf} />
|
|
||||||
))}
|
|
||||||
{extra > 0 && (
|
|
||||||
<div className="text-sm font-semibold text-slate-600">+{extra}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Dot({ status, isSelf }: { status: PresenceStatus; isSelf: boolean }) {
|
|
||||||
const base = status === "away" ? "bg-slate-900/20" : "bg-slate-900/60";
|
|
||||||
const ring = isSelf ? "ring-2 ring-[#2F6FED]" : "ring-0";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`h-3 w-3 rounded-full ${base} ${ring}`}
|
|
||||||
aria-label={isSelf ? "나" : status === "away" ? "자리비움" : "집중"}
|
|
||||||
title={isSelf ? "나" : status === "away" ? "자리비움" : "집중"}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
42
src/app/settings/page.tsx
Normal file
42
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useI18n } from '@/features/i18n/model/useI18n';
|
||||||
|
import { getPreferences, savePreferences } from '@/shared/lib/store';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [hideSeconds, setHideSeconds] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prefs = getPreferences();
|
||||||
|
setHideSeconds(prefs.hideSeconds);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const newValue = !hideSeconds;
|
||||||
|
setHideSeconds(newValue);
|
||||||
|
savePreferences({ hideSeconds: newValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-xl font-bold text-slate-100 mb-8">{t('settings.title')}</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between bg-slate-900/40 p-4 rounded-xl border border-slate-800">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-slate-200">{t('settings.hideSeconds.title')}</h3>
|
||||||
|
<p className="text-xs text-slate-500">{t('settings.hideSeconds.description')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
className={`w-12 h-6 rounded-full transition-colors relative ${hideSeconds ? 'bg-indigo-600' : 'bg-slate-700'}`}
|
||||||
|
>
|
||||||
|
<div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${hideSeconds ? 'left-7' : 'left-1'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { Slot } from "radix-ui";
|
import { Slot } from "radix-ui";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/shared/lib/cn"
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { XIcon } from "lucide-react";
|
|||||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "./button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/shared/lib/cn"
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
|
|||||||
2
src/features/boarding/index.ts
Normal file
2
src/features/boarding/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { BoardingMissionForm } from './ui/BoardingMissionForm';
|
||||||
|
export { startVoyage } from './model/startVoyage';
|
||||||
33
src/features/boarding/model/startVoyage.ts
Normal file
33
src/features/boarding/model/startVoyage.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Route, Voyage } from '@/shared/types';
|
||||||
|
import { saveCurrentVoyage } from '@/shared/lib/store';
|
||||||
|
|
||||||
|
const createVoyageId = () =>
|
||||||
|
(crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, '')
|
||||||
|
.slice(0, 16);
|
||||||
|
|
||||||
|
export const startVoyage = ({
|
||||||
|
route,
|
||||||
|
mission,
|
||||||
|
routeName,
|
||||||
|
}: {
|
||||||
|
route: Route;
|
||||||
|
mission: string;
|
||||||
|
routeName: string;
|
||||||
|
}) => {
|
||||||
|
const missionText = mission.trim();
|
||||||
|
const normalizedMissionText = missionText || '미입력';
|
||||||
|
|
||||||
|
const newVoyage: Voyage = {
|
||||||
|
id: createVoyageId(),
|
||||||
|
routeId: route.id,
|
||||||
|
routeName,
|
||||||
|
durationMinutes: route.durationMinutes,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
status: 'in_progress',
|
||||||
|
missionText: normalizedMissionText,
|
||||||
|
};
|
||||||
|
|
||||||
|
saveCurrentVoyage(newVoyage);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
86
src/features/boarding/ui/BoardingMissionForm.tsx
Normal file
86
src/features/boarding/ui/BoardingMissionForm.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import { useI18n } from '@/features/i18n/model/useI18n';
|
||||||
|
|
||||||
|
const OPTIONAL_PREFIX_PATTERN = /^(?:선택|optional|任意|facultatif)\s*[::]\s*/i;
|
||||||
|
const EXAMPLE_PREFIX_PATTERN =
|
||||||
|
/^(?:예|例|e\.?\s?g\.?|eg\.?|example|exemple|beispiel|p\.?\s?ex\.?|z\.?\s?b\.?)\s*[::)\.]\s*/i;
|
||||||
|
|
||||||
|
const buildMissionPlaceholder = (raw: string, optionalLabel: string) => {
|
||||||
|
const sanitized = raw
|
||||||
|
.trim()
|
||||||
|
.replace(OPTIONAL_PREFIX_PATTERN, '')
|
||||||
|
.replace(EXAMPLE_PREFIX_PATTERN, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!sanitized) return '';
|
||||||
|
const normalizedOptionalLabel = optionalLabel.trim();
|
||||||
|
if (!normalizedOptionalLabel) return sanitized;
|
||||||
|
|
||||||
|
return `${sanitized} (${normalizedOptionalLabel})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BoardingMissionForm({
|
||||||
|
onDock,
|
||||||
|
onCancel,
|
||||||
|
autoFocus = false,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
onDock: (mission: string) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [mission, setMission] = useState('');
|
||||||
|
const trimmedMission = mission.trim();
|
||||||
|
const missionPlaceholder = buildMissionPlaceholder(
|
||||||
|
t('boarding.missionPlaceholder'),
|
||||||
|
t('boarding.optionalLabel'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onDock(trimmedMission);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className={`flex flex-col ${compact ? 'gap-6' : 'space-y-8 flex-1'}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-medium text-slate-300">
|
||||||
|
{t('boarding.missionLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mission}
|
||||||
|
onChange={(event) => setMission(event.target.value)}
|
||||||
|
placeholder={missionPlaceholder}
|
||||||
|
className="w-full border-b-2 border-slate-700 bg-slate-900/50 px-0 py-3 text-lg outline-none transition-colors placeholder:text-slate-600 focus:border-indigo-500"
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex ${compact ? 'justify-end gap-3' : 'mt-8 flex-col gap-3'} w-full`}>
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded-xl border border-slate-700 bg-transparent px-4 py-2 font-semibold text-slate-300 transition-colors hover:bg-slate-900/60 hover:text-white"
|
||||||
|
>
|
||||||
|
{t('boarding.cancel')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`rounded-xl bg-indigo-600 font-bold text-white transition-all shadow-lg shadow-indigo-900/30 hover:bg-indigo-500 ${compact ? 'px-6 py-2' : 'w-full py-4 text-lg'}`}
|
||||||
|
>
|
||||||
|
{t('boarding.submit')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/features/flight-session/model/useFlightSession.ts
Normal file
141
src/features/flight-session/model/useFlightSession.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getCurrentVoyage, saveCurrentVoyage } from '@/shared/lib/store';
|
||||||
|
import { Voyage } from '@/shared/types';
|
||||||
|
|
||||||
|
const getVoyageFromStore = () => {
|
||||||
|
const current = getCurrentVoyage();
|
||||||
|
if (!current || current.status !== 'in_progress') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEndTime = (voyage: Voyage | null) => {
|
||||||
|
if (!voyage) return 0;
|
||||||
|
return voyage.startedAt + voyage.durationMinutes * 60 * 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitialTimerSeconds = (voyage: Voyage | null) => {
|
||||||
|
if (!voyage) return 0;
|
||||||
|
|
||||||
|
if (voyage.durationMinutes === 0) {
|
||||||
|
return Math.max(0, Math.floor((Date.now() - voyage.startedAt) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = getEndTime(voyage);
|
||||||
|
if (!endTime) return 0;
|
||||||
|
return Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatHHMMSS = (totalSeconds: number) => {
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
return `${hours.toString().padStart(2, '0')}:${minutes
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useFlightSession() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [voyage, setVoyage] = useState<Voyage | null>(null);
|
||||||
|
const [timeLeft, setTimeLeft] = useState<number>(0);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const [isSessionReady, setIsSessionReady] = useState(false);
|
||||||
|
const endTimeRef = useRef<number>(0);
|
||||||
|
const pausedElapsedMsRef = useRef<number>(0);
|
||||||
|
const pausedAtMsRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedVoyage = getVoyageFromStore();
|
||||||
|
if (storedVoyage) {
|
||||||
|
setVoyage(storedVoyage);
|
||||||
|
setTimeLeft(getInitialTimerSeconds(storedVoyage));
|
||||||
|
endTimeRef.current = getEndTime(storedVoyage);
|
||||||
|
}
|
||||||
|
setIsSessionReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSessionReady) return;
|
||||||
|
if (voyage) return;
|
||||||
|
|
||||||
|
router.replace('/');
|
||||||
|
}, [isSessionReady, voyage, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSessionReady || !voyage || isPaused) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (voyage.durationMinutes === 0) {
|
||||||
|
const elapsedMs = Date.now() - voyage.startedAt - pausedElapsedMsRef.current;
|
||||||
|
setTimeLeft(Math.max(0, Math.floor(elapsedMs / 1000)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = endTimeRef.current - Date.now();
|
||||||
|
|
||||||
|
if (diff <= 0) {
|
||||||
|
setTimeLeft(0);
|
||||||
|
clearInterval(interval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeLeft(Math.ceil(diff / 1000));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isSessionReady, voyage, isPaused]);
|
||||||
|
|
||||||
|
const handlePauseToggle = () => {
|
||||||
|
if (voyage?.durationMinutes === 0) {
|
||||||
|
if (isPaused) {
|
||||||
|
if (pausedAtMsRef.current !== null) {
|
||||||
|
pausedElapsedMsRef.current += Date.now() - pausedAtMsRef.current;
|
||||||
|
}
|
||||||
|
pausedAtMsRef.current = null;
|
||||||
|
setIsPaused(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pausedAtMsRef.current = Date.now();
|
||||||
|
setIsPaused(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPaused) {
|
||||||
|
endTimeRef.current = Date.now() + timeLeft * 1000;
|
||||||
|
setIsPaused(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPaused(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinish = () => {
|
||||||
|
if (!voyage) return null;
|
||||||
|
|
||||||
|
const endedVoyage: Voyage = {
|
||||||
|
...voyage,
|
||||||
|
endedAt: voyage.endedAt || Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
saveCurrentVoyage(endedVoyage);
|
||||||
|
return endedVoyage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => formatHHMMSS(seconds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
voyage,
|
||||||
|
timeLeft,
|
||||||
|
isCountdownCompleted: Boolean(voyage && voyage.durationMinutes > 0 && timeLeft === 0),
|
||||||
|
isPaused,
|
||||||
|
formattedTime: formatTime(timeLeft),
|
||||||
|
handlePauseToggle,
|
||||||
|
handleFinish,
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/features/flight-starfield/index.ts
Normal file
1
src/features/flight-starfield/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FlightStarfieldCanvas } from './ui/FlightStarfieldCanvas';
|
||||||
21
src/features/flight-starfield/lib/projection.ts
Normal file
21
src/features/flight-starfield/lib/projection.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { FlightStar, VanishingPoint } from '@/features/flight-starfield/model/types';
|
||||||
|
|
||||||
|
export const createVanishingPoint = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
xJitter,
|
||||||
|
yOffset,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
xJitter: number;
|
||||||
|
yOffset: number;
|
||||||
|
}): VanishingPoint => ({
|
||||||
|
x: width / 2 + xJitter,
|
||||||
|
y: height / 2 + yOffset,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectFlightStar = (star: FlightStar, vp: VanishingPoint, zValue: number) => ({
|
||||||
|
x: vp.x + star.wx / zValue,
|
||||||
|
y: vp.y + star.wy / zValue,
|
||||||
|
});
|
||||||
180
src/features/flight-starfield/model/starfieldModel.ts
Normal file
180
src/features/flight-starfield/model/starfieldModel.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { FLIGHT_STARFIELD_TUNING } from "@/shared/config/starfield";
|
||||||
|
import { clamp, randomInRange } from "@/shared/lib/math/number";
|
||||||
|
|
||||||
|
import { FlightStar } from "@/features/flight-starfield/model/types";
|
||||||
|
|
||||||
|
export const getFlightStarCount = (width: number, height: number) => {
|
||||||
|
const isMobile = width < FLIGHT_STARFIELD_TUNING.mobileBreakpoint;
|
||||||
|
const min = isMobile
|
||||||
|
? FLIGHT_STARFIELD_TUNING.starCount.mobile.min
|
||||||
|
: FLIGHT_STARFIELD_TUNING.starCount.desktop.min;
|
||||||
|
const max = isMobile
|
||||||
|
? FLIGHT_STARFIELD_TUNING.maxStars.mobile
|
||||||
|
: FLIGHT_STARFIELD_TUNING.maxStars.desktop;
|
||||||
|
const byArea = Math.round(
|
||||||
|
((width * height) / FLIGHT_STARFIELD_TUNING.densityDivisor) *
|
||||||
|
FLIGHT_STARFIELD_TUNING.densityMultiplier,
|
||||||
|
);
|
||||||
|
|
||||||
|
return clamp(byArea, min, max);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFlightVanishXJitter = () => {
|
||||||
|
const sign = Math.random() < 0.5 ? -1 : 1;
|
||||||
|
return (
|
||||||
|
sign *
|
||||||
|
randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.vanishXJitter.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.vanishXJitter.max,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFlightSpeed = () => {
|
||||||
|
const tier = Math.random();
|
||||||
|
|
||||||
|
if (tier < FLIGHT_STARFIELD_TUNING.speedTiers.slow.chance) {
|
||||||
|
return randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.speedTiers.slow.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.speedTiers.slow.max,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tier < FLIGHT_STARFIELD_TUNING.speedTiers.medium.chance) {
|
||||||
|
return randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.speedTiers.medium.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.speedTiers.medium.max,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.speedTiers.fast.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.speedTiers.fast.max,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFlightVisualTier = () => {
|
||||||
|
const highlight =
|
||||||
|
Math.random() < FLIGHT_STARFIELD_TUNING.radius.highlightChance;
|
||||||
|
const tailRoll = Math.random();
|
||||||
|
const tailLength =
|
||||||
|
tailRoll < FLIGHT_STARFIELD_TUNING.tail.pointChance
|
||||||
|
? randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.tail.pointRange.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.tail.pointRange.max,
|
||||||
|
)
|
||||||
|
: tailRoll < FLIGHT_STARFIELD_TUNING.tail.shortChance
|
||||||
|
? randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.tail.shortRange.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.tail.shortRange.max,
|
||||||
|
)
|
||||||
|
: randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.tail.longRange.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.tail.longRange.max,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
radius: highlight
|
||||||
|
? randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.radius.highlight.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.radius.highlight.max,
|
||||||
|
)
|
||||||
|
: randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.radius.normal.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.radius.normal.max,
|
||||||
|
),
|
||||||
|
alpha: highlight
|
||||||
|
? randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.alpha.highlight.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.alpha.highlight.max,
|
||||||
|
)
|
||||||
|
: randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.alpha.normal.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.alpha.normal.max,
|
||||||
|
),
|
||||||
|
tailLength,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFlightSpawnRadius = (width: number, height: number) => {
|
||||||
|
const roll = Math.random();
|
||||||
|
const maxWideRadius = Math.min(
|
||||||
|
Math.max(width, height) *
|
||||||
|
FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.maxScaleOfViewport,
|
||||||
|
FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.maxAbsolute,
|
||||||
|
);
|
||||||
|
const ringOuter = Math.min(
|
||||||
|
FLIGHT_STARFIELD_TUNING.spawnRadius.ringRange.max,
|
||||||
|
maxWideRadius,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (roll < FLIGHT_STARFIELD_TUNING.spawnRadius.centerChance) {
|
||||||
|
return randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.spawnRadius.centerRange.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.spawnRadius.centerRange.max,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roll < FLIGHT_STARFIELD_TUNING.spawnRadius.ringChance) {
|
||||||
|
return randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.spawnRadius.ringRange.min,
|
||||||
|
Math.max(FLIGHT_STARFIELD_TUNING.spawnRadius.ringRange.min, ringOuter),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.min,
|
||||||
|
Math.max(FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.min, maxWideRadius),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFlightStar = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
isRespawn,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
isRespawn: boolean;
|
||||||
|
}): FlightStar => {
|
||||||
|
const radius = createFlightSpawnRadius(width, height);
|
||||||
|
const angle = randomInRange(0, Math.PI * 2);
|
||||||
|
const visuals = createFlightVisualTier();
|
||||||
|
|
||||||
|
return {
|
||||||
|
wx: Math.cos(angle) * radius,
|
||||||
|
wy: Math.sin(angle) * radius,
|
||||||
|
z: isRespawn
|
||||||
|
? randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.zRange.respawn.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.zRange.respawn.max,
|
||||||
|
)
|
||||||
|
: randomInRange(
|
||||||
|
FLIGHT_STARFIELD_TUNING.zRange.initial.min,
|
||||||
|
FLIGHT_STARFIELD_TUNING.zRange.initial.max,
|
||||||
|
),
|
||||||
|
speed: createFlightSpeed(),
|
||||||
|
radius: visuals.radius,
|
||||||
|
alpha: visuals.alpha,
|
||||||
|
tailLength: visuals.tailLength,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldRecycleFlightStar = ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}) =>
|
||||||
|
z <= FLIGHT_STARFIELD_TUNING.zRange.recycleThreshold ||
|
||||||
|
x < -50 ||
|
||||||
|
x > width + 50 ||
|
||||||
|
y < -50 ||
|
||||||
|
y > height + 50;
|
||||||
14
src/features/flight-starfield/model/types.ts
Normal file
14
src/features/flight-starfield/model/types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export type FlightStar = {
|
||||||
|
wx: number;
|
||||||
|
wy: number;
|
||||||
|
z: number;
|
||||||
|
speed: number;
|
||||||
|
radius: number;
|
||||||
|
alpha: number;
|
||||||
|
tailLength: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VanishingPoint = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
325
src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx
Normal file
325
src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createVanishingPoint,
|
||||||
|
projectFlightStar,
|
||||||
|
} from "@/features/flight-starfield/lib/projection";
|
||||||
|
import {
|
||||||
|
createFlightStar,
|
||||||
|
createFlightVanishXJitter,
|
||||||
|
getFlightStarCount,
|
||||||
|
shouldRecycleFlightStar,
|
||||||
|
} from "@/features/flight-starfield/model/starfieldModel";
|
||||||
|
import { FlightStar } from "@/features/flight-starfield/model/types";
|
||||||
|
import { FLIGHT_STARFIELD_TUNING } from "@/shared/config/starfield";
|
||||||
|
import { clamp } from "@/shared/lib/math/number";
|
||||||
|
import { getPrefersReducedMotionMediaQuery } from "@/shared/lib/motion/prefersReducedMotion";
|
||||||
|
|
||||||
|
export function FlightStarfieldCanvas({
|
||||||
|
vanishYOffset = -68,
|
||||||
|
centerProtectRadius = 200,
|
||||||
|
isPaused = false,
|
||||||
|
}: {
|
||||||
|
vanishYOffset?: number;
|
||||||
|
centerProtectRadius?: number;
|
||||||
|
isPaused?: boolean;
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const starsRef = useRef<FlightStar[]>([]);
|
||||||
|
const vanishXJitterRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
let width = window.innerWidth;
|
||||||
|
let height = window.innerHeight;
|
||||||
|
let animationFrameId = 0;
|
||||||
|
let dpr = 1;
|
||||||
|
|
||||||
|
const motionQuery = getPrefersReducedMotionMediaQuery();
|
||||||
|
let prefersReducedMotion = motionQuery.matches;
|
||||||
|
if (vanishXJitterRef.current === null) {
|
||||||
|
vanishXJitterRef.current = createFlightVanishXJitter();
|
||||||
|
}
|
||||||
|
const vanishXJitter = vanishXJitterRef.current ?? 0;
|
||||||
|
|
||||||
|
const setCanvasSize = () => {
|
||||||
|
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||||
|
const viewportHeight =
|
||||||
|
window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
width = Math.max(1, Math.floor(viewportWidth));
|
||||||
|
height = Math.max(1, Math.floor(viewportHeight));
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = Math.max(1, Math.floor(width * dpr));
|
||||||
|
canvas.height = Math.max(1, Math.floor(height * dpr));
|
||||||
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
context.imageSmoothingEnabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapToDevicePixel = (value: number) => Math.round(value * dpr) / dpr;
|
||||||
|
|
||||||
|
const getVanishingPoint = () =>
|
||||||
|
createVanishingPoint({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
xJitter: vanishXJitter,
|
||||||
|
yOffset: vanishYOffset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStars = () =>
|
||||||
|
Array.from({ length: getFlightStarCount(width, height) }, () =>
|
||||||
|
createFlightStar({ width, height, isRespawn: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
setCanvasSize();
|
||||||
|
if (starsRef.current.length === 0) {
|
||||||
|
starsRef.current = createStars();
|
||||||
|
}
|
||||||
|
let stars = starsRef.current;
|
||||||
|
|
||||||
|
const applyCenterProtection = (x: number, y: number, alpha: number) => {
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height / 2;
|
||||||
|
const distance = Math.hypot(x - centerX, y - centerY);
|
||||||
|
|
||||||
|
if (distance >= centerProtectRadius) return alpha;
|
||||||
|
|
||||||
|
const ratio = clamp(distance / centerProtectRadius, 0, 1);
|
||||||
|
const attenuation = 0.35 + ratio * 0.65;
|
||||||
|
return alpha * attenuation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawStar = ({
|
||||||
|
star,
|
||||||
|
fromX,
|
||||||
|
fromY,
|
||||||
|
toX,
|
||||||
|
toY,
|
||||||
|
}: {
|
||||||
|
star: FlightStar;
|
||||||
|
fromX: number;
|
||||||
|
fromY: number;
|
||||||
|
toX: number;
|
||||||
|
toY: number;
|
||||||
|
}) => {
|
||||||
|
const visibleAlpha = applyCenterProtection(toX, toY, star.alpha);
|
||||||
|
const deltaX = toX - fromX;
|
||||||
|
const deltaY = toY - fromY;
|
||||||
|
const movementLength = Math.hypot(deltaX, deltaY);
|
||||||
|
const tailLineWidth = clamp(star.radius * 0.9, 0.78, 1.6);
|
||||||
|
const canDrawTail =
|
||||||
|
star.tailLength >=
|
||||||
|
FLIGHT_STARFIELD_TUNING.tail.cleanup.minTailLengthToDraw &&
|
||||||
|
movementLength >=
|
||||||
|
FLIGHT_STARFIELD_TUNING.tail.cleanup.minMovementToDraw &&
|
||||||
|
visibleAlpha >= FLIGHT_STARFIELD_TUNING.tail.cleanup.minAlphaToDraw &&
|
||||||
|
tailLineWidth >=
|
||||||
|
FLIGHT_STARFIELD_TUNING.tail.cleanup.minLineWidthToDraw;
|
||||||
|
|
||||||
|
if (!canDrawTail) {
|
||||||
|
const snappedToX = snapToDevicePixel(toX);
|
||||||
|
const snappedToY = snapToDevicePixel(toY);
|
||||||
|
context.globalAlpha = visibleAlpha;
|
||||||
|
context.fillStyle = "#f8fbff";
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(snappedToX, snappedToY, star.radius, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionX = deltaX / movementLength;
|
||||||
|
const directionY = deltaY / movementLength;
|
||||||
|
const tailX = snapToDevicePixel(toX - directionX * star.tailLength);
|
||||||
|
const tailY = snapToDevicePixel(toY - directionY * star.tailLength);
|
||||||
|
const snappedToX = snapToDevicePixel(toX);
|
||||||
|
const snappedToY = snapToDevicePixel(toY);
|
||||||
|
|
||||||
|
const gradient = context.createLinearGradient(
|
||||||
|
tailX,
|
||||||
|
tailY,
|
||||||
|
snappedToX,
|
||||||
|
snappedToY,
|
||||||
|
);
|
||||||
|
gradient.addColorStop(0, "rgba(248, 251, 255, 0)");
|
||||||
|
gradient.addColorStop(1, `rgba(248, 251, 255, ${visibleAlpha})`);
|
||||||
|
|
||||||
|
context.strokeStyle = gradient;
|
||||||
|
context.lineWidth = tailLineWidth;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(tailX, tailY);
|
||||||
|
context.lineTo(snappedToX, snappedToY);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.globalAlpha = Math.min(1, visibleAlpha + 0.08);
|
||||||
|
context.fillStyle = "#f8fbff";
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(
|
||||||
|
snappedToX,
|
||||||
|
snappedToY,
|
||||||
|
clamp(star.radius * 0.72, 0.6, 1.45),
|
||||||
|
0,
|
||||||
|
Math.PI * 2,
|
||||||
|
);
|
||||||
|
context.fill();
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawCenterVeil = () => {
|
||||||
|
const vp = getVanishingPoint();
|
||||||
|
const veil = context.createRadialGradient(
|
||||||
|
vp.x,
|
||||||
|
vp.y,
|
||||||
|
centerProtectRadius * 0.12,
|
||||||
|
vp.x,
|
||||||
|
vp.y,
|
||||||
|
centerProtectRadius * 1.35,
|
||||||
|
);
|
||||||
|
veil.addColorStop(0, "rgba(176, 201, 242, 0.11)");
|
||||||
|
veil.addColorStop(0.22, "rgba(138, 164, 216, 0.06)");
|
||||||
|
veil.addColorStop(0.55, "rgba(90, 114, 170, 0.025)");
|
||||||
|
veil.addColorStop(1, "rgba(0, 0, 0, 0)");
|
||||||
|
|
||||||
|
context.fillStyle = veil;
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawVignette = () => {
|
||||||
|
const vignette = context.createRadialGradient(
|
||||||
|
width / 2,
|
||||||
|
height / 2,
|
||||||
|
Math.min(width, height) * 0.25,
|
||||||
|
width / 2,
|
||||||
|
height / 2,
|
||||||
|
Math.max(width, height) * 0.95,
|
||||||
|
);
|
||||||
|
vignette.addColorStop(0, "rgba(0, 0, 0, 0)");
|
||||||
|
vignette.addColorStop(1, "rgba(0, 0, 0, 0.82)");
|
||||||
|
context.fillStyle = vignette;
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawFrame = (moveStars: boolean) => {
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.globalCompositeOperation = "source-over";
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
context.fillStyle = "rgb(2, 5, 10)";
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
drawCenterVeil();
|
||||||
|
|
||||||
|
stars.forEach((star, index) => {
|
||||||
|
const vp = getVanishingPoint();
|
||||||
|
const from = projectFlightStar(star, vp, star.z);
|
||||||
|
|
||||||
|
if (moveStars) {
|
||||||
|
star.z -= star.speed * FLIGHT_STARFIELD_TUNING.speedScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = projectFlightStar(star, vp, star.z);
|
||||||
|
|
||||||
|
if (
|
||||||
|
shouldRecycleFlightStar({
|
||||||
|
x: to.x,
|
||||||
|
y: to.y,
|
||||||
|
z: star.z,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
stars[index] = createFlightStar({ width, height, isRespawn: true });
|
||||||
|
starsRef.current = stars;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawStar({
|
||||||
|
star,
|
||||||
|
fromX: from.x,
|
||||||
|
fromY: from.y,
|
||||||
|
toX: to.x,
|
||||||
|
toY: to.y,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
drawVignette();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAnimation = () => {
|
||||||
|
if (!animationFrameId) return;
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
animationFrameId = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
if (prefersReducedMotion || isPaused) {
|
||||||
|
animationFrameId = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFrame(true);
|
||||||
|
animationFrameId = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStatic = () => {
|
||||||
|
drawFrame(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
setCanvasSize();
|
||||||
|
stars = createStars();
|
||||||
|
starsRef.current = stars;
|
||||||
|
|
||||||
|
if (prefersReducedMotion || isPaused) {
|
||||||
|
renderStatic();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMotionChange = (event: MediaQueryListEvent) => {
|
||||||
|
prefersReducedMotion = event.matches;
|
||||||
|
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
stopAnimation();
|
||||||
|
renderStatic();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPaused && !animationFrameId) {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
window.visualViewport?.addEventListener("resize", handleResize);
|
||||||
|
motionQuery.addEventListener("change", handleMotionChange);
|
||||||
|
|
||||||
|
if (prefersReducedMotion || isPaused) {
|
||||||
|
renderStatic();
|
||||||
|
} else {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
window.visualViewport?.removeEventListener("resize", handleResize);
|
||||||
|
motionQuery.removeEventListener("change", handleMotionChange);
|
||||||
|
stopAnimation();
|
||||||
|
};
|
||||||
|
}, [vanishYOffset, centerProtectRadius, isPaused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="fixed inset-0 z-0 h-screen w-screen bg-black pointer-events-none"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/features/i18n/model/resolveInitialLocale.ts
Normal file
42
src/features/i18n/model/resolveInitialLocale.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
Locale,
|
||||||
|
MANUAL_LOCALE_STORAGE_KEY,
|
||||||
|
SUPPORTED_LOCALES,
|
||||||
|
} from "@/shared/config/i18n";
|
||||||
|
|
||||||
|
const normalizeLocale = (raw: string | null | undefined): Locale | null => {
|
||||||
|
if (!raw) return null;
|
||||||
|
const base = raw.trim().toLowerCase().split("-")[0];
|
||||||
|
|
||||||
|
return SUPPORTED_LOCALES.includes(base as Locale) ? (base as Locale) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getManualLocale = (): Locale | null => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return normalizeLocale(localStorage.getItem(MANUAL_LOCALE_STORAGE_KEY));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBrowserLocale = (): Locale | null => {
|
||||||
|
if (typeof navigator === "undefined") return null;
|
||||||
|
const candidates = [...(navigator.languages ?? []), navigator.language];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const locale = normalizeLocale(candidate);
|
||||||
|
if (locale) return locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveInitialLocale = (): Locale => {
|
||||||
|
const manualLocale = getManualLocale();
|
||||||
|
if (manualLocale) return manualLocale;
|
||||||
|
|
||||||
|
return getBrowserLocale() ?? DEFAULT_LOCALE;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveManualLocale = (locale: Locale) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(MANUAL_LOCALE_STORAGE_KEY, locale);
|
||||||
|
};
|
||||||
45
src/features/i18n/model/useI18n.tsx
Normal file
45
src/features/i18n/model/useI18n.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
I18nKey,
|
||||||
|
Locale,
|
||||||
|
TranslationParams,
|
||||||
|
translateText,
|
||||||
|
} from "@/shared/config/i18n";
|
||||||
|
|
||||||
|
type I18nContextValue = {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
t: (key: I18nKey | string, params?: TranslationParams, fallback?: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue>({
|
||||||
|
locale: DEFAULT_LOCALE,
|
||||||
|
setLocale: () => {},
|
||||||
|
t: (key, params, fallback) =>
|
||||||
|
translateText(DEFAULT_LOCALE, key, params, fallback),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function I18nProvider({
|
||||||
|
children,
|
||||||
|
locale,
|
||||||
|
setLocale,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
}) {
|
||||||
|
const t = (key: I18nKey | string, params?: TranslationParams, fallback?: string) =>
|
||||||
|
translateText(locale, key, params, fallback);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<I18nContext.Provider value={{ locale, setLocale, t }}>
|
||||||
|
{children}
|
||||||
|
</I18nContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useI18n = () => useContext(I18nContext);
|
||||||
168
src/features/i18n/ui/I18nLayoutShell.tsx
Normal file
168
src/features/i18n/ui/I18nLayoutShell.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Menu as MenuIcon, X as CloseIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
I18nKey,
|
||||||
|
LOCALE_LABELS,
|
||||||
|
Locale,
|
||||||
|
SUPPORTED_LOCALES,
|
||||||
|
translateText,
|
||||||
|
} from "@/shared/config/i18n";
|
||||||
|
import {
|
||||||
|
resolveInitialLocale,
|
||||||
|
saveManualLocale,
|
||||||
|
} from "@/features/i18n/model/resolveInitialLocale";
|
||||||
|
import { I18nProvider } from "@/features/i18n/model/useI18n";
|
||||||
|
|
||||||
|
export function I18nLayoutShell({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isLobby = pathname === "/";
|
||||||
|
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initialLocale = resolveInitialLocale();
|
||||||
|
setLocale(initialLocale);
|
||||||
|
document.documentElement.lang = initialLocale;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetLocale = (nextLocale: Locale) => {
|
||||||
|
if (!SUPPORTED_LOCALES.includes(nextLocale)) return;
|
||||||
|
setLocale(nextLocale);
|
||||||
|
saveManualLocale(nextLocale);
|
||||||
|
document.documentElement.lang = nextLocale;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocaleChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const nextLocale = event.target.value as Locale;
|
||||||
|
handleSetLocale(nextLocale);
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = (key: I18nKey) => translateText(locale, key);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMenuOpen) return;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (!menuRef.current) return;
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (target && !menuRef.current.contains(target)) {
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
menuButtonRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [isMenuOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<I18nProvider locale={locale} setLocale={handleSetLocale}>
|
||||||
|
<div
|
||||||
|
className={`relative z-10 mx-auto flex min-h-screen flex-col ${
|
||||||
|
isLobby ? "w-full max-w-none" : "max-w-6xl"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
className={`flex items-center justify-between px-6 py-4 ${
|
||||||
|
isLobby ? "lg:hidden" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="z-50 text-lg font-bold tracking-wider text-indigo-400 transition-colors hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
FOCUSTELLA
|
||||||
|
</Link>
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="relative z-50"
|
||||||
|
onBlur={(event) => {
|
||||||
|
if (!isMenuOpen) return;
|
||||||
|
const nextFocused = event.relatedTarget as Node | null;
|
||||||
|
if (nextFocused && event.currentTarget.contains(nextFocused)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
ref={menuButtonRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsMenuOpen((prev) => !prev)}
|
||||||
|
aria-expanded={isMenuOpen}
|
||||||
|
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
|
||||||
|
className="rounded-lg border border-slate-700 bg-slate-900/70 p-2 text-slate-200 transition-colors hover:border-slate-500 hover:bg-slate-800/80"
|
||||||
|
>
|
||||||
|
{isMenuOpen ? (
|
||||||
|
<CloseIcon className="h-5 w-5" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<MenuIcon className="h-5 w-5" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isMenuOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-64 rounded-xl border border-slate-700 bg-slate-900/95 p-3 shadow-2xl backdrop-blur">
|
||||||
|
<nav className="flex flex-col gap-2 text-sm font-medium text-slate-300">
|
||||||
|
<Link
|
||||||
|
href="/log"
|
||||||
|
className="rounded-md px-3 py-2 transition-colors hover:bg-slate-800 hover:text-slate-100"
|
||||||
|
>
|
||||||
|
{t("layout.nav.log")}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="rounded-md px-3 py-2 transition-colors hover:bg-slate-800 hover:text-slate-100"
|
||||||
|
>
|
||||||
|
{t("layout.nav.settings")}
|
||||||
|
</Link>
|
||||||
|
<div className="rounded-md px-3 py-2">
|
||||||
|
<label className="flex items-center justify-between gap-3 text-xs text-slate-300">
|
||||||
|
<span>{t("layout.nav.language")}</span>
|
||||||
|
<select
|
||||||
|
value={locale}
|
||||||
|
onChange={handleLocaleChange}
|
||||||
|
className="rounded border border-slate-700 bg-slate-900/70 px-2 py-1 text-xs text-slate-200 outline-none transition-colors focus:border-indigo-400"
|
||||||
|
>
|
||||||
|
{SUPPORTED_LOCALES.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{LOCALE_LABELS[item]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="relative flex w-full flex-1 flex-col">{children}</main>
|
||||||
|
</div>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/features/lobby-session/model/useLobbyRedirect.ts
Normal file
15
src/features/lobby-session/model/useLobbyRedirect.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getCurrentVoyage } from '@/shared/lib/store';
|
||||||
|
|
||||||
|
export function useLobbyRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const current = getCurrentVoyage();
|
||||||
|
if (current && current.status === 'in_progress') {
|
||||||
|
router.replace('/flight');
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
}
|
||||||
1
src/features/lobby-starfield/index.ts
Normal file
1
src/features/lobby-starfield/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ConstellationScene } from './ui/ConstellationScene';
|
||||||
107
src/features/lobby-starfield/model/constellationData.ts
Normal file
107
src/features/lobby-starfield/model/constellationData.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
export type LobbyStar = {
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
r: number;
|
||||||
|
armScale?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LobbySegment = {
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LobbyConstellation = {
|
||||||
|
key: "orion" | "auriga" | "ursaMajor";
|
||||||
|
className: string;
|
||||||
|
viewBox: string;
|
||||||
|
colorClass: string;
|
||||||
|
stars: LobbyStar[];
|
||||||
|
segments: LobbySegment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LOBBY_CONSTELLATIONS: LobbyConstellation[] = [
|
||||||
|
{
|
||||||
|
key: "orion",
|
||||||
|
className: "absolute bottom-10 left-5 h-72 w-72 opacity-38",
|
||||||
|
viewBox: "0 0 100 100",
|
||||||
|
colorClass: "text-[#ffffff]",
|
||||||
|
stars: [
|
||||||
|
{ cx: 26, cy: 20, r: 1.2, armScale: 1.2 },
|
||||||
|
{ cx: 65, cy: 22, r: 0.82 },
|
||||||
|
{ cx: 45, cy: 53, r: 0.58 },
|
||||||
|
{ cx: 50, cy: 50, r: 0.54 },
|
||||||
|
{ cx: 55, cy: 47, r: 0.6 },
|
||||||
|
{ cx: 38, cy: 84, r: 0.86 },
|
||||||
|
{ cx: 70, cy: 82, r: 1.1, armScale: 1.2 },
|
||||||
|
],
|
||||||
|
segments: [
|
||||||
|
{ x1: 26, y1: 20, x2: 45, y2: 53 },
|
||||||
|
{ x1: 65, y1: 22, x2: 55, y2: 47 },
|
||||||
|
{ x1: 45, y1: 53, x2: 50, y2: 50 },
|
||||||
|
{ x1: 50, y1: 50, x2: 55, y2: 47 },
|
||||||
|
{ x1: 45, y1: 53, x2: 38, y2: 84 },
|
||||||
|
{ x1: 55, y1: 47, x2: 70, y2: 82 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "auriga",
|
||||||
|
className: "absolute top-10 right-10 h-64 w-64 opacity-34",
|
||||||
|
viewBox: "0 0 100 100",
|
||||||
|
colorClass: "text-[#ffffff]",
|
||||||
|
stars: [
|
||||||
|
{ cx: 50, cy: 15, r: 1.06, armScale: 1.1 },
|
||||||
|
{ cx: 20, cy: 35, r: 0.67 },
|
||||||
|
{ cx: 25, cy: 75, r: 0.65 },
|
||||||
|
{ cx: 75, cy: 75, r: 0.66 },
|
||||||
|
{ cx: 85, cy: 35, r: 0.76 },
|
||||||
|
],
|
||||||
|
segments: [
|
||||||
|
{ x1: 50, y1: 15, x2: 20, y2: 35 },
|
||||||
|
{ x1: 20, y1: 35, x2: 25, y2: 75 },
|
||||||
|
{ x1: 25, y1: 75, x2: 75, y2: 75 },
|
||||||
|
{ x1: 75, y1: 75, x2: 85, y2: 35 },
|
||||||
|
{ x1: 85, y1: 35, x2: 50, y2: 15 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ursaMajor",
|
||||||
|
className: "absolute top-20 left-10 h-48 w-80 opacity-34",
|
||||||
|
viewBox: "0 0 100 60",
|
||||||
|
colorClass: "text-[#ffffff]",
|
||||||
|
stars: [
|
||||||
|
{ cx: 8, cy: 12, r: 0.64 },
|
||||||
|
{ cx: 32, cy: 12, r: 0.67 },
|
||||||
|
{ cx: 46, cy: 24, r: 0.69 },
|
||||||
|
{ cx: 58, cy: 30, r: 0.99 },
|
||||||
|
{ cx: 88, cy: 36, r: 0.98 },
|
||||||
|
{ cx: 78, cy: 48, r: 0.93 },
|
||||||
|
{ cx: 58, cy: 42, r: 0.96 },
|
||||||
|
],
|
||||||
|
segments: [
|
||||||
|
{ x1: 8, y1: 12, x2: 32, y2: 12 },
|
||||||
|
{ x1: 32, y1: 12, x2: 46, y2: 24 },
|
||||||
|
{ x1: 46, y1: 24, x2: 58, y2: 30 },
|
||||||
|
{ x1: 58, y1: 30, x2: 88, y2: 36 },
|
||||||
|
{ x1: 88, y1: 36, x2: 78, y2: 48 },
|
||||||
|
{ x1: 78, y1: 48, x2: 58, y2: 42 },
|
||||||
|
{ x1: 58, y1: 42, x2: 58, y2: 30 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LOBBY_STAR_TIMINGS = [
|
||||||
|
{ duration: 2.3, delay: 0.1 },
|
||||||
|
{ duration: 3.1, delay: 1.2 },
|
||||||
|
{ duration: 4.8, delay: 0.8 },
|
||||||
|
{ duration: 2.7, delay: 2.1 },
|
||||||
|
{ duration: 5.2, delay: 1.7 },
|
||||||
|
{ duration: 3.9, delay: 0.4 },
|
||||||
|
{ duration: 4.4, delay: 2.6 },
|
||||||
|
{ duration: 2.1, delay: 1.3 },
|
||||||
|
{ duration: 5.8, delay: 0.2 },
|
||||||
|
{ duration: 3.3, delay: 2.4 },
|
||||||
|
{ duration: 4.0, delay: 1.1 },
|
||||||
|
{ duration: 2.9, delay: 1.9 },
|
||||||
|
] as const;
|
||||||
49
src/features/lobby-starfield/ui/ConstellationScene.tsx
Normal file
49
src/features/lobby-starfield/ui/ConstellationScene.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { LOBBY_CONSTELLATIONS } from '@/features/lobby-starfield/model/constellationData';
|
||||||
|
import { StarGlint } from './StarGlint';
|
||||||
|
|
||||||
|
export function ConstellationScene() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{LOBBY_CONSTELLATIONS.map((constellation, constellationIndex) => {
|
||||||
|
const starIndexOffset = LOBBY_CONSTELLATIONS.slice(0, constellationIndex).reduce(
|
||||||
|
(sum, item) => sum + item.stars.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={constellation.key} className={constellation.className}>
|
||||||
|
<svg
|
||||||
|
viewBox={constellation.viewBox}
|
||||||
|
className={`h-full w-full fill-current ${constellation.colorClass}`}
|
||||||
|
>
|
||||||
|
{constellation.segments.map((segment, segmentIndex) => (
|
||||||
|
<line
|
||||||
|
key={`${constellation.key}-segment-${segmentIndex}`}
|
||||||
|
x1={segment.x1}
|
||||||
|
y1={segment.y1}
|
||||||
|
x2={segment.x2}
|
||||||
|
y2={segment.y2}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
className="opacity-20"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{constellation.stars.map((star, starIndex) => {
|
||||||
|
const globalStarIndex = starIndexOffset + starIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StarGlint
|
||||||
|
key={`${constellation.key}-star-${globalStarIndex}`}
|
||||||
|
starIndex={globalStarIndex}
|
||||||
|
star={star}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/features/lobby-starfield/ui/StarGlint.tsx
Normal file
133
src/features/lobby-starfield/ui/StarGlint.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LOBBY_STAR_TIMINGS,
|
||||||
|
LobbyStar,
|
||||||
|
} from "@/features/lobby-starfield/model/constellationData";
|
||||||
|
|
||||||
|
export function StarGlint({
|
||||||
|
star,
|
||||||
|
starIndex,
|
||||||
|
}: {
|
||||||
|
star: LobbyStar;
|
||||||
|
starIndex: number;
|
||||||
|
}) {
|
||||||
|
const timing = LOBBY_STAR_TIMINGS[starIndex % LOBBY_STAR_TIMINGS.length];
|
||||||
|
const coreRadius = star.r * 0.5;
|
||||||
|
const strengthTier =
|
||||||
|
coreRadius >= 0.9 ? "bright" : coreRadius >= 0.7 ? "mid" : "faint";
|
||||||
|
const glintPeak =
|
||||||
|
strengthTier === "bright" ? 0.54 : strengthTier === "mid" ? 0.47 : 0.41;
|
||||||
|
const bloomPeak =
|
||||||
|
strengthTier === "bright" ? 0.085 : strengthTier === "mid" ? 0.065 : 0.045;
|
||||||
|
const coreLow =
|
||||||
|
strengthTier === "bright" ? 0.88 : strengthTier === "mid" ? 0.73 : 0.6;
|
||||||
|
const coreHigh =
|
||||||
|
strengthTier === "bright" ? 0.98 : strengthTier === "mid" ? 0.89 : 0.81;
|
||||||
|
const coreReduced =
|
||||||
|
strengthTier === "bright" ? 0.88 : strengthTier === "mid" ? 0.75 : 0.64;
|
||||||
|
|
||||||
|
const coreStyle = {
|
||||||
|
animationDuration: `${timing.duration}s`,
|
||||||
|
animationDelay: `${timing.delay}s`,
|
||||||
|
"--core-low": `${coreLow}`,
|
||||||
|
"--core-high": `${coreHigh}`,
|
||||||
|
"--core-reduced": `${coreReduced}`,
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
|
const glintStyle = {
|
||||||
|
animationDuration: `${timing.duration}s`,
|
||||||
|
animationDelay: `${timing.delay + 0.12}s`,
|
||||||
|
"--glint-peak": `${glintPeak}`,
|
||||||
|
"--glint-base": "0.02",
|
||||||
|
"--bloom-peak": `${bloomPeak}`,
|
||||||
|
"--bloom-base": "0.01",
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
|
const glintLength = coreRadius * 5.2 * (star.armScale ?? 1);
|
||||||
|
const gradientXId = `glint-x-${starIndex}`;
|
||||||
|
const gradientYId = `glint-y-${starIndex}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id={gradientXId}
|
||||||
|
x1={star.cx - glintLength}
|
||||||
|
y1={star.cy}
|
||||||
|
x2={star.cx + glintLength}
|
||||||
|
y2={star.cy}
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
|
||||||
|
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
|
||||||
|
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id={gradientYId}
|
||||||
|
x1={star.cx}
|
||||||
|
y1={star.cy - glintLength}
|
||||||
|
x2={star.cx}
|
||||||
|
y2={star.cy + glintLength}
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
|
||||||
|
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
|
||||||
|
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<circle
|
||||||
|
cx={star.cx}
|
||||||
|
cy={star.cy}
|
||||||
|
r={coreRadius}
|
||||||
|
className="star-core"
|
||||||
|
style={coreStyle}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={star.cx}
|
||||||
|
cy={star.cy}
|
||||||
|
r={coreRadius * 1.55}
|
||||||
|
className="star-core-bloom"
|
||||||
|
style={coreStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<line
|
||||||
|
x1={star.cx - glintLength}
|
||||||
|
y1={star.cy}
|
||||||
|
x2={star.cx + glintLength}
|
||||||
|
y2={star.cy}
|
||||||
|
className="star-glint-bloom"
|
||||||
|
stroke={`url(#${gradientXId})`}
|
||||||
|
style={glintStyle}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={star.cx}
|
||||||
|
y1={star.cy - glintLength}
|
||||||
|
x2={star.cx}
|
||||||
|
y2={star.cy + glintLength}
|
||||||
|
className="star-glint-bloom"
|
||||||
|
stroke={`url(#${gradientYId})`}
|
||||||
|
style={glintStyle}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={star.cx - glintLength}
|
||||||
|
y1={star.cy}
|
||||||
|
x2={star.cx + glintLength}
|
||||||
|
y2={star.cy}
|
||||||
|
className="star-glint"
|
||||||
|
stroke={`url(#${gradientXId})`}
|
||||||
|
style={glintStyle}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={star.cx}
|
||||||
|
y1={star.cy - glintLength}
|
||||||
|
x2={star.cx}
|
||||||
|
y2={star.cy + glintLength}
|
||||||
|
className="star-glint"
|
||||||
|
stroke={`url(#${gradientYId})`}
|
||||||
|
style={glintStyle}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
62
src/shared/config/featureFlags.ts
Normal file
62
src/shared/config/featureFlags.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export const FEATURE_FLAGS = {
|
||||||
|
crewPresencePanelEnabled: false,
|
||||||
|
crewPresenceNotificationsEnabled: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CREW_PRESENCE_ROLLOUT_GUARDRAILS = {
|
||||||
|
forcePanelOff: false,
|
||||||
|
forceNotificationsOff: false,
|
||||||
|
reduceRenderCap: false,
|
||||||
|
relaxUpdateInterval: false,
|
||||||
|
reducedRenderCap: 30,
|
||||||
|
defaultRenderCap: 50,
|
||||||
|
defaultUpdateIntervalMs: 30_000,
|
||||||
|
relaxedUpdateIntervalMs: 60_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CREW_PRESENCE_ROLLBACK_TOGGLE_BINDINGS = {
|
||||||
|
forcePanelOff: "rollback.panel_off",
|
||||||
|
forceNotificationsOff: "rollback.notifications_off",
|
||||||
|
reduceRenderCap: "rollback.cap_reduce",
|
||||||
|
relaxUpdateInterval: "rollback.interval_relax",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CrewPresenceRuntimeConfig = {
|
||||||
|
panelEnabled: boolean;
|
||||||
|
notificationsEnabled: boolean;
|
||||||
|
renderCap: number;
|
||||||
|
updateIntervalMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveCrewPresenceRuntimeConfig = (): CrewPresenceRuntimeConfig => {
|
||||||
|
const panelEnabled =
|
||||||
|
FEATURE_FLAGS.crewPresencePanelEnabled &&
|
||||||
|
!CREW_PRESENCE_ROLLOUT_GUARDRAILS.forcePanelOff;
|
||||||
|
const notificationsEnabled =
|
||||||
|
FEATURE_FLAGS.crewPresenceNotificationsEnabled &&
|
||||||
|
!CREW_PRESENCE_ROLLOUT_GUARDRAILS.forceNotificationsOff;
|
||||||
|
const renderCap = CREW_PRESENCE_ROLLOUT_GUARDRAILS.reduceRenderCap
|
||||||
|
? CREW_PRESENCE_ROLLOUT_GUARDRAILS.reducedRenderCap
|
||||||
|
: CREW_PRESENCE_ROLLOUT_GUARDRAILS.defaultRenderCap;
|
||||||
|
const updateIntervalMs = CREW_PRESENCE_ROLLOUT_GUARDRAILS.relaxUpdateInterval
|
||||||
|
? CREW_PRESENCE_ROLLOUT_GUARDRAILS.relaxedUpdateIntervalMs
|
||||||
|
: CREW_PRESENCE_ROLLOUT_GUARDRAILS.defaultUpdateIntervalMs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
panelEnabled,
|
||||||
|
notificationsEnabled,
|
||||||
|
renderCap,
|
||||||
|
updateIntervalMs,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isCrewPresenceRuntimeConfigSafe = (
|
||||||
|
config: CrewPresenceRuntimeConfig,
|
||||||
|
): boolean => config.renderCap > 0 && config.updateIntervalMs >= 1_000;
|
||||||
|
|
||||||
|
export const CREW_PRESENCE_QA_GUARDRAIL_CHECKS = [
|
||||||
|
"panel_toggle_guard",
|
||||||
|
"notifications_toggle_guard",
|
||||||
|
"render_cap_guard",
|
||||||
|
"update_interval_guard",
|
||||||
|
] as const;
|
||||||
555
src/shared/config/i18n.ts
Normal file
555
src/shared/config/i18n.ts
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
import { VoyageStatus } from "@/shared/types";
|
||||||
|
|
||||||
|
export const SUPPORTED_LOCALES = ["ko", "en", "ja", "fr", "de"] as const;
|
||||||
|
|
||||||
|
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
|
||||||
|
export const DEFAULT_LOCALE: Locale = "en";
|
||||||
|
|
||||||
|
export const MANUAL_LOCALE_STORAGE_KEY = "focustella_locale_manual_v1";
|
||||||
|
|
||||||
|
export const LOCALE_LABELS: Record<Locale, string> = {
|
||||||
|
ko: "한국어",
|
||||||
|
en: "English",
|
||||||
|
ja: "日本語",
|
||||||
|
fr: "Français",
|
||||||
|
de: "Deutsch",
|
||||||
|
};
|
||||||
|
|
||||||
|
const enMessages = {
|
||||||
|
"layout.nav.log": "Logbook",
|
||||||
|
"layout.nav.settings": "Settings",
|
||||||
|
"layout.nav.language": "Language",
|
||||||
|
"common.loading": "Loading...",
|
||||||
|
"common.minuteShort": "min",
|
||||||
|
"routes.station.name": "Space Station",
|
||||||
|
"routes.station.tag": "Wait/Flexible",
|
||||||
|
"routes.station.description": "A safe zone you can stay in without time limits",
|
||||||
|
"routes.orion.name": "Orion",
|
||||||
|
"routes.orion.tag": "Deep Work",
|
||||||
|
"routes.orion.description": "60-minute focus voyage",
|
||||||
|
"routes.gemini.name": "Gemini",
|
||||||
|
"routes.gemini.tag": "Short Sprint",
|
||||||
|
"routes.gemini.description": "30-minute focus voyage",
|
||||||
|
"lobby.title": "Which constellation will you sail to?",
|
||||||
|
"lobby.subtitle": "Choose an orbit that helps your focus.",
|
||||||
|
"lobby.cta.station": "Enter Station (Wait)",
|
||||||
|
"lobby.cta.launch": "Launch Now",
|
||||||
|
"lobby.modal.boardingCheck": "Boarding Check",
|
||||||
|
"lobby.modal.routeBoarding": "{routeName} Route Boarding",
|
||||||
|
"lobby.modal.description": "Set your mission before starting this voyage.",
|
||||||
|
"boarding.check": "Boarding Check",
|
||||||
|
"boarding.routeBoarding": "{routeName} Route Boarding",
|
||||||
|
"boarding.missionLabel": "Core mission for this voyage",
|
||||||
|
"boarding.missionPlaceholder": "Optional: e.g. Finish 3 intro paragraphs",
|
||||||
|
"boarding.optionalLabel": "Optional",
|
||||||
|
"boarding.cancel": "Cancel",
|
||||||
|
"boarding.submit": "Dock Complete (Launch)",
|
||||||
|
"flight.badge.paused": "Paused",
|
||||||
|
"flight.badge.cruising": "Cruising",
|
||||||
|
"flight.missionLabel": "Voyage Mission",
|
||||||
|
"flight.pause": "Pause",
|
||||||
|
"flight.resume": "Resume",
|
||||||
|
"flight.crewPresence.header": "Sailing together now: {count}",
|
||||||
|
"flight.crewPresence.expand": "Expand",
|
||||||
|
"flight.crewPresence.collapse": "Collapse",
|
||||||
|
"flight.crewPresence.listLimit": "Showing up to {limit} crew",
|
||||||
|
"flight.crewPresence.overflow": "+{count} more",
|
||||||
|
"flight.crewPresence.status.online": "Online",
|
||||||
|
"flight.crewPresence.status.idle": "Idle",
|
||||||
|
"flight.crewPresence.status.offline": "Offline",
|
||||||
|
"flight.crewPresence.activityHint": "Based on recent activity",
|
||||||
|
"flight.crewPresence.elapsedMinutes": "{minutes}m ago",
|
||||||
|
"flight.crewPresence.goal.private": "Goal private",
|
||||||
|
"flight.crewPresence.goal.unset": "Goal not set",
|
||||||
|
"flight.crewPresence.notifications.policyOn": "Alerts ON",
|
||||||
|
"flight.crewPresence.notifications.filterHint":
|
||||||
|
"Showing friend/my-crew events only",
|
||||||
|
"flight.crewPresence.notifications.target.friend": "Friend",
|
||||||
|
"flight.crewPresence.notifications.target.myCrew": "My crew",
|
||||||
|
"flight.finish.debrief": "Arrived (Debrief)",
|
||||||
|
"flight.finish.end": "End Voyage",
|
||||||
|
"flight.debrief.title": "Wrap up this voyage",
|
||||||
|
"flight.debrief.description": "Write a short note and save it to your logbook.",
|
||||||
|
"debrief.page.title": "You safely reached orbit",
|
||||||
|
"debrief.page.description": "Record this voyage briefly and wrap up.",
|
||||||
|
"debrief.status.label": "Voyage Result",
|
||||||
|
"debrief.option.completed.label": "Completed the mission",
|
||||||
|
"debrief.option.completed.desc": "I finished what I set out to do.",
|
||||||
|
"debrief.option.partial.label": "Made partial progress",
|
||||||
|
"debrief.option.partial.desc": "I advanced key parts and left next steps.",
|
||||||
|
"debrief.option.reoriented.label": "Redefined the mission",
|
||||||
|
"debrief.option.reoriented.desc": "I reset scope and priorities while working.",
|
||||||
|
"debrief.reflection.label": "Reflection after this voyage",
|
||||||
|
"debrief.reflection.placeholder":
|
||||||
|
"e.g. What worked, what did not, and what I learned",
|
||||||
|
"debrief.save": "Save to Logbook",
|
||||||
|
"log.title": "My Voyage Logs",
|
||||||
|
"log.mission.empty": "No mission entered",
|
||||||
|
"log.empty": "No voyages recorded yet.",
|
||||||
|
"log.firstVoyage": "Start your first voyage",
|
||||||
|
"log.detail.back": "Back to list",
|
||||||
|
"log.detail.loadingOrNotFound": "Loading or Not Found...",
|
||||||
|
"log.detail.statusTitle": "Result Status",
|
||||||
|
"log.detail.progressTitle": "What I secured",
|
||||||
|
"log.detail.nextActionTitle": "Next Action",
|
||||||
|
"log.detail.initialNoteTitle": "Initial Note",
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.hideSeconds.title": "Hide Seconds",
|
||||||
|
"settings.hideSeconds.description":
|
||||||
|
"Show only minutes on the timer to reduce pressure.",
|
||||||
|
"status.completed": "Completed",
|
||||||
|
"status.partial": "Partial",
|
||||||
|
"status.reoriented": "Reoriented",
|
||||||
|
"status.aborted": "Aborted Early",
|
||||||
|
"status.in_progress": "In Progress",
|
||||||
|
} satisfies Record<string, string>;
|
||||||
|
|
||||||
|
type MessageKey = keyof typeof enMessages;
|
||||||
|
type I18nMessages = Record<MessageKey, string>;
|
||||||
|
|
||||||
|
const koMessages = {
|
||||||
|
"layout.nav.log": "항해일지",
|
||||||
|
"layout.nav.settings": "설정",
|
||||||
|
"layout.nav.language": "언어",
|
||||||
|
"common.loading": "로딩 중...",
|
||||||
|
"common.minuteShort": "분",
|
||||||
|
"routes.station.name": "우주정거장",
|
||||||
|
"routes.station.tag": "대기/자유",
|
||||||
|
"routes.station.description": "시간 제한 없이 머무를 수 있는 안전지대",
|
||||||
|
"routes.orion.name": "오리온",
|
||||||
|
"routes.orion.tag": "딥워크",
|
||||||
|
"routes.orion.description": "60분 집중 항해",
|
||||||
|
"routes.gemini.name": "쌍둥이자리",
|
||||||
|
"routes.gemini.tag": "숏스프린트",
|
||||||
|
"routes.gemini.description": "30분 집중 항해",
|
||||||
|
"lobby.title": "어느 별자리로 출항할까요?",
|
||||||
|
"lobby.subtitle": "몰입하기 좋은 궤도입니다.",
|
||||||
|
"lobby.cta.station": "정거장 진입 (대기)",
|
||||||
|
"lobby.cta.launch": "바로 출항",
|
||||||
|
"lobby.modal.boardingCheck": "Boarding Check",
|
||||||
|
"lobby.modal.routeBoarding": "{routeName} 항로 탑승",
|
||||||
|
"lobby.modal.description": "항해를 시작하기 전에 목표를 설정하세요.",
|
||||||
|
"boarding.check": "Boarding Check",
|
||||||
|
"boarding.routeBoarding": "{routeName} 항로 탑승",
|
||||||
|
"boarding.missionLabel": "이번 항해의 핵심 목표",
|
||||||
|
"boarding.missionPlaceholder": "선택: 예) 서론 3문단 완성하기",
|
||||||
|
"boarding.optionalLabel": "선택",
|
||||||
|
"boarding.cancel": "취소",
|
||||||
|
"boarding.submit": "도킹 완료 (출항)",
|
||||||
|
"flight.badge.paused": "일시정지",
|
||||||
|
"flight.badge.cruising": "순항 중",
|
||||||
|
"flight.missionLabel": "이번 항해 목표",
|
||||||
|
"flight.pause": "일시정지",
|
||||||
|
"flight.resume": "다시 시작",
|
||||||
|
"flight.crewPresence.header": "현재 함께 항해 중 {count}명",
|
||||||
|
"flight.crewPresence.expand": "펼치기",
|
||||||
|
"flight.crewPresence.collapse": "접기",
|
||||||
|
"flight.crewPresence.listLimit": "최대 {limit}명까지 표시",
|
||||||
|
"flight.crewPresence.overflow": "+{count}명",
|
||||||
|
"flight.crewPresence.status.online": "온라인",
|
||||||
|
"flight.crewPresence.status.idle": "자리비움",
|
||||||
|
"flight.crewPresence.status.offline": "오프라인",
|
||||||
|
"flight.crewPresence.activityHint": "최근 활동 기반",
|
||||||
|
"flight.crewPresence.elapsedMinutes": "{minutes}분 전",
|
||||||
|
"flight.crewPresence.goal.private": "목표 비공개",
|
||||||
|
"flight.crewPresence.goal.unset": "목표 미설정",
|
||||||
|
"flight.crewPresence.notifications.policyOn": "알림 ON",
|
||||||
|
"flight.crewPresence.notifications.filterHint":
|
||||||
|
"friend/my-crew 이벤트만 표시",
|
||||||
|
"flight.crewPresence.notifications.target.friend": "친구",
|
||||||
|
"flight.crewPresence.notifications.target.myCrew": "내 크루",
|
||||||
|
"flight.finish.debrief": "도착 (회고)",
|
||||||
|
"flight.finish.end": "항해 종료",
|
||||||
|
"flight.debrief.title": "이번 항해를 정리하세요",
|
||||||
|
"flight.debrief.description": "짧게 기록하고 항해일지에 저장합니다.",
|
||||||
|
"debrief.page.title": "무사히 궤도에 도착했습니다",
|
||||||
|
"debrief.page.description": "이번 항해를 짧게 기록하고 마무리하세요.",
|
||||||
|
"debrief.status.label": "항해 결과",
|
||||||
|
"debrief.option.completed.label": "목표를 완수했어요",
|
||||||
|
"debrief.option.completed.desc": "시작할 때 정한 목표를 끝까지 해냈어요",
|
||||||
|
"debrief.option.partial.label": "일부까지 진행했어요",
|
||||||
|
"debrief.option.partial.desc": "중요한 부분을 진행했고 다음 단계가 남았어요",
|
||||||
|
"debrief.option.reoriented.label": "목표를 재정의했어요",
|
||||||
|
"debrief.option.reoriented.desc":
|
||||||
|
"진행 중 목표 범위와 우선순위를 다시 정했어요",
|
||||||
|
"debrief.reflection.label": "이번 항해를 마치고 느낀 점",
|
||||||
|
"debrief.reflection.placeholder":
|
||||||
|
"예: 잘한 점, 아쉬운 점, 느낀 점을 짧게 남겨보세요",
|
||||||
|
"debrief.save": "항해일지 저장",
|
||||||
|
"log.title": "나의 항해 기록",
|
||||||
|
"log.mission.empty": "미입력",
|
||||||
|
"log.empty": "아직 기록된 항해가 없습니다.",
|
||||||
|
"log.firstVoyage": "첫 항해 떠나기",
|
||||||
|
"log.detail.back": "목록으로",
|
||||||
|
"log.detail.loadingOrNotFound": "로딩 중이거나 기록이 없습니다.",
|
||||||
|
"log.detail.statusTitle": "결과 상태",
|
||||||
|
"log.detail.progressTitle": "확보한 것",
|
||||||
|
"log.detail.nextActionTitle": "다음 행동",
|
||||||
|
"log.detail.initialNoteTitle": "초기 메모",
|
||||||
|
"settings.title": "설정",
|
||||||
|
"settings.hideSeconds.title": "초 단위 숨기기",
|
||||||
|
"settings.hideSeconds.description":
|
||||||
|
"타이머에서 분 단위만 표시하여 불안감을 줄입니다.",
|
||||||
|
"status.completed": "✅ 계획대로",
|
||||||
|
"status.partial": "🌓 부분 진행",
|
||||||
|
"status.reoriented": "🧭 방향 재설정",
|
||||||
|
"status.aborted": "🚨 조기 귀환",
|
||||||
|
"status.in_progress": "진행 중",
|
||||||
|
} satisfies I18nMessages;
|
||||||
|
|
||||||
|
const jaMessages = {
|
||||||
|
"layout.nav.log": "航海ログ",
|
||||||
|
"layout.nav.settings": "設定",
|
||||||
|
"layout.nav.language": "言語",
|
||||||
|
"common.loading": "読み込み中...",
|
||||||
|
"common.minuteShort": "分",
|
||||||
|
"routes.station.name": "宇宙ステーション",
|
||||||
|
"routes.station.tag": "待機/自由",
|
||||||
|
"routes.station.description": "時間制限なしで滞在できる安全地帯",
|
||||||
|
"routes.orion.name": "オリオン",
|
||||||
|
"routes.orion.tag": "ディープワーク",
|
||||||
|
"routes.orion.description": "60分集中航海",
|
||||||
|
"routes.gemini.name": "ふたご座",
|
||||||
|
"routes.gemini.tag": "ショートスプリント",
|
||||||
|
"routes.gemini.description": "30分集中航海",
|
||||||
|
"lobby.title": "どの星座へ航海しますか?",
|
||||||
|
"lobby.subtitle": "集中しやすい軌道を選びましょう。",
|
||||||
|
"lobby.cta.station": "ステーションへ入る(待機)",
|
||||||
|
"lobby.cta.launch": "今すぐ出航",
|
||||||
|
"lobby.modal.boardingCheck": "搭乗チェック",
|
||||||
|
"lobby.modal.routeBoarding": "{routeName} 航路に搭乗",
|
||||||
|
"lobby.modal.description": "航海を始める前に目標を設定してください。",
|
||||||
|
"boarding.check": "搭乗チェック",
|
||||||
|
"boarding.routeBoarding": "{routeName} 航路に搭乗",
|
||||||
|
"boarding.missionLabel": "今回の航海のコア目標",
|
||||||
|
"boarding.missionPlaceholder": "任意: 例) 導入の3段落を完成する",
|
||||||
|
"boarding.optionalLabel": "任意",
|
||||||
|
"boarding.cancel": "キャンセル",
|
||||||
|
"boarding.submit": "ドッキング完了(出航)",
|
||||||
|
"flight.badge.paused": "一時停止",
|
||||||
|
"flight.badge.cruising": "巡航中",
|
||||||
|
"flight.missionLabel": "今回の航海目標",
|
||||||
|
"flight.pause": "一時停止",
|
||||||
|
"flight.resume": "再開",
|
||||||
|
"flight.crewPresence.header": "現在一緒に航海中 {count}人",
|
||||||
|
"flight.crewPresence.expand": "展開",
|
||||||
|
"flight.crewPresence.collapse": "折りたたむ",
|
||||||
|
"flight.crewPresence.listLimit": "最大{limit}人まで表示",
|
||||||
|
"flight.crewPresence.overflow": "+{count}人",
|
||||||
|
"flight.crewPresence.status.online": "オンライン",
|
||||||
|
"flight.crewPresence.status.idle": "離席",
|
||||||
|
"flight.crewPresence.status.offline": "オフライン",
|
||||||
|
"flight.crewPresence.activityHint": "最近のアクティビティに基づく",
|
||||||
|
"flight.crewPresence.elapsedMinutes": "{minutes}分前",
|
||||||
|
"flight.crewPresence.goal.private": "目標は非公開",
|
||||||
|
"flight.crewPresence.goal.unset": "目標未設定",
|
||||||
|
"flight.crewPresence.notifications.policyOn": "通知 ON",
|
||||||
|
"flight.crewPresence.notifications.filterHint":
|
||||||
|
"friend/my-crew イベントのみ表示",
|
||||||
|
"flight.crewPresence.notifications.target.friend": "フレンド",
|
||||||
|
"flight.crewPresence.notifications.target.myCrew": "マイクルー",
|
||||||
|
"flight.finish.debrief": "到着(振り返り)",
|
||||||
|
"flight.finish.end": "航海終了",
|
||||||
|
"flight.debrief.title": "今回の航海を整理しましょう",
|
||||||
|
"flight.debrief.description": "短く記録して航海ログに保存します。",
|
||||||
|
"debrief.page.title": "無事に軌道へ到着しました",
|
||||||
|
"debrief.page.description": "今回の航海を短く記録して締めくくりましょう。",
|
||||||
|
"debrief.status.label": "航海結果",
|
||||||
|
"debrief.option.completed.label": "目標を達成しました",
|
||||||
|
"debrief.option.completed.desc": "開始時に決めた目標を最後まで完了しました。",
|
||||||
|
"debrief.option.partial.label": "一部まで進めました",
|
||||||
|
"debrief.option.partial.desc": "重要な部分を進め、次の段階が残っています。",
|
||||||
|
"debrief.option.reoriented.label": "目標を再定義しました",
|
||||||
|
"debrief.option.reoriented.desc":
|
||||||
|
"進行中に目標範囲と優先順位を見直しました。",
|
||||||
|
"debrief.reflection.label": "今回の航海を終えて感じたこと",
|
||||||
|
"debrief.reflection.placeholder":
|
||||||
|
"例: 良かった点・難しかった点・気づきを短く残してください",
|
||||||
|
"debrief.save": "航海ログに保存",
|
||||||
|
"log.title": "私の航海記録",
|
||||||
|
"log.mission.empty": "未入力",
|
||||||
|
"log.empty": "まだ記録された航海がありません。",
|
||||||
|
"log.firstVoyage": "最初の航海を始める",
|
||||||
|
"log.detail.back": "一覧へ戻る",
|
||||||
|
"log.detail.loadingOrNotFound": "読み込み中、または記録が見つかりません。",
|
||||||
|
"log.detail.statusTitle": "結果ステータス",
|
||||||
|
"log.detail.progressTitle": "確保できたこと",
|
||||||
|
"log.detail.nextActionTitle": "次の行動",
|
||||||
|
"log.detail.initialNoteTitle": "初期メモ",
|
||||||
|
"settings.title": "設定",
|
||||||
|
"settings.hideSeconds.title": "秒表示を隠す",
|
||||||
|
"settings.hideSeconds.description":
|
||||||
|
"タイマーを分表示のみにしてプレッシャーを減らします。",
|
||||||
|
"status.completed": "✅ 計画どおり",
|
||||||
|
"status.partial": "🌓 一部進行",
|
||||||
|
"status.reoriented": "🧭 方針再設定",
|
||||||
|
"status.aborted": "🚨 早期帰還",
|
||||||
|
"status.in_progress": "進行中",
|
||||||
|
} satisfies I18nMessages;
|
||||||
|
|
||||||
|
const normalizedEnMessages: I18nMessages = enMessages;
|
||||||
|
const frMessages: I18nMessages = {
|
||||||
|
"layout.nav.log": "Journal de bord",
|
||||||
|
"layout.nav.settings": "Parametres",
|
||||||
|
"layout.nav.language": "Langue",
|
||||||
|
"common.loading": "Chargement...",
|
||||||
|
"common.minuteShort": "min",
|
||||||
|
"routes.station.name": "Station spatiale",
|
||||||
|
"routes.station.tag": "Attente/Flexible",
|
||||||
|
"routes.station.description":
|
||||||
|
"Une zone sure ou vous pouvez rester sans limite de temps",
|
||||||
|
"routes.orion.name": "Orion",
|
||||||
|
"routes.orion.tag": "Travail profond",
|
||||||
|
"routes.orion.description": "Voyage de concentration de 60 minutes",
|
||||||
|
"routes.gemini.name": "Gemini",
|
||||||
|
"routes.gemini.tag": "Sprint court",
|
||||||
|
"routes.gemini.description": "Voyage de concentration de 30 minutes",
|
||||||
|
"lobby.title": "Vers quelle constellation voulez-vous naviguer ?",
|
||||||
|
"lobby.subtitle": "Choisissez une orbite qui favorise votre concentration.",
|
||||||
|
"lobby.cta.station": "Entrer dans la station (Attente)",
|
||||||
|
"lobby.cta.launch": "Decoller maintenant",
|
||||||
|
"lobby.modal.boardingCheck": "Verification d'embarquement",
|
||||||
|
"lobby.modal.routeBoarding": "Embarquement route {routeName}",
|
||||||
|
"lobby.modal.description":
|
||||||
|
"Definissez votre mission avant de commencer ce voyage.",
|
||||||
|
"boarding.check": "Verification d'embarquement",
|
||||||
|
"boarding.routeBoarding": "Embarquement route {routeName}",
|
||||||
|
"boarding.missionLabel": "Mission principale pour ce voyage",
|
||||||
|
"boarding.missionPlaceholder":
|
||||||
|
"Facultatif: terminer 3 paragraphes d'introduction",
|
||||||
|
"boarding.optionalLabel": "Facultatif",
|
||||||
|
"boarding.cancel": "Annuler",
|
||||||
|
"boarding.submit": "Amarrage termine (Decoller)",
|
||||||
|
"flight.badge.paused": "En pause",
|
||||||
|
"flight.badge.cruising": "En croisiere",
|
||||||
|
"flight.missionLabel": "Mission du voyage",
|
||||||
|
"flight.pause": "Pause",
|
||||||
|
"flight.resume": "Reprendre",
|
||||||
|
"flight.crewPresence.header": "En navigation ensemble actuellement : {count}",
|
||||||
|
"flight.crewPresence.expand": "Afficher",
|
||||||
|
"flight.crewPresence.collapse": "Reduire",
|
||||||
|
"flight.crewPresence.listLimit": "Afficher jusqu'a {limit} membres",
|
||||||
|
"flight.crewPresence.overflow": "+{count} personnes",
|
||||||
|
"flight.crewPresence.status.online": "En ligne",
|
||||||
|
"flight.crewPresence.status.idle": "Inactif",
|
||||||
|
"flight.crewPresence.status.offline": "Hors ligne",
|
||||||
|
"flight.crewPresence.activityHint": "Base sur l'activite recente",
|
||||||
|
"flight.crewPresence.elapsedMinutes": "il y a {minutes} min",
|
||||||
|
"flight.crewPresence.goal.private": "Objectif prive",
|
||||||
|
"flight.crewPresence.goal.unset": "Objectif non defini",
|
||||||
|
"flight.crewPresence.notifications.policyOn": "Alertes ON",
|
||||||
|
"flight.crewPresence.notifications.filterHint":
|
||||||
|
"Afficher uniquement les evenements friend/my-crew",
|
||||||
|
"flight.crewPresence.notifications.target.friend": "Ami",
|
||||||
|
"flight.crewPresence.notifications.target.myCrew": "Mon crew",
|
||||||
|
"flight.finish.debrief": "Arrivee (Debrief)",
|
||||||
|
"flight.finish.end": "Terminer le voyage",
|
||||||
|
"flight.debrief.title": "Concluez ce voyage",
|
||||||
|
"flight.debrief.description":
|
||||||
|
"Ecrivez une courte note et enregistrez-la dans votre journal.",
|
||||||
|
"debrief.page.title": "Vous etes arrive en orbite en securite",
|
||||||
|
"debrief.page.description":
|
||||||
|
"Consignez brievement ce voyage puis terminez.",
|
||||||
|
"debrief.status.label": "Resultat du voyage",
|
||||||
|
"debrief.option.completed.label": "Mission accomplie",
|
||||||
|
"debrief.option.completed.desc":
|
||||||
|
"J'ai termine ce que j'avais prevu de faire.",
|
||||||
|
"debrief.option.partial.label": "Progression partielle",
|
||||||
|
"debrief.option.partial.desc":
|
||||||
|
"J'ai avance les points cles et garde les prochaines etapes.",
|
||||||
|
"debrief.option.reoriented.label": "Mission redefinie",
|
||||||
|
"debrief.option.reoriented.desc":
|
||||||
|
"J'ai reajuste le perimetre et les priorites pendant le travail.",
|
||||||
|
"debrief.reflection.label": "Reflexion apres ce voyage",
|
||||||
|
"debrief.reflection.placeholder":
|
||||||
|
"Exemple: ce qui a fonctionne, ce qui n'a pas marche, et ce que j'ai appris",
|
||||||
|
"debrief.save": "Enregistrer dans le journal",
|
||||||
|
"log.title": "Mes journaux de voyage",
|
||||||
|
"log.mission.empty": "Mission non renseignee",
|
||||||
|
"log.empty": "Aucun voyage enregistre pour le moment.",
|
||||||
|
"log.firstVoyage": "Commencer votre premier voyage",
|
||||||
|
"log.detail.back": "Retour a la liste",
|
||||||
|
"log.detail.loadingOrNotFound": "Chargement ou introuvable...",
|
||||||
|
"log.detail.statusTitle": "Statut du resultat",
|
||||||
|
"log.detail.progressTitle": "Ce que j'ai obtenu",
|
||||||
|
"log.detail.nextActionTitle": "Action suivante",
|
||||||
|
"log.detail.initialNoteTitle": "Note initiale",
|
||||||
|
"settings.title": "Parametres",
|
||||||
|
"settings.hideSeconds.title": "Masquer les secondes",
|
||||||
|
"settings.hideSeconds.description":
|
||||||
|
"Afficher uniquement les minutes sur le minuteur pour reduire la pression.",
|
||||||
|
"status.completed": "Termine",
|
||||||
|
"status.partial": "Partiel",
|
||||||
|
"status.reoriented": "Reoriente",
|
||||||
|
"status.aborted": "Interrompu tot",
|
||||||
|
"status.in_progress": "En cours",
|
||||||
|
};
|
||||||
|
|
||||||
|
const deMessages: I18nMessages = {
|
||||||
|
"layout.nav.log": "Logbuch",
|
||||||
|
"layout.nav.settings": "Einstellungen",
|
||||||
|
"layout.nav.language": "Sprache",
|
||||||
|
"common.loading": "Wird geladen...",
|
||||||
|
"common.minuteShort": "Min",
|
||||||
|
"routes.station.name": "Raumstation",
|
||||||
|
"routes.station.tag": "Warten/Flexibel",
|
||||||
|
"routes.station.description":
|
||||||
|
"Ein sicherer Bereich, in dem Sie ohne Zeitlimit bleiben konnen",
|
||||||
|
"routes.orion.name": "Orion",
|
||||||
|
"routes.orion.tag": "Tiefe Arbeit",
|
||||||
|
"routes.orion.description": "60-Minuten-Fokusreise",
|
||||||
|
"routes.gemini.name": "Gemini",
|
||||||
|
"routes.gemini.tag": "Kurzer Sprint",
|
||||||
|
"routes.gemini.description": "30-Minuten-Fokusreise",
|
||||||
|
"lobby.title": "Zu welcher Konstellation mochten Sie reisen?",
|
||||||
|
"lobby.subtitle":
|
||||||
|
"Wahlen Sie eine Umlaufbahn, die Ihre Konzentration unterstutzt.",
|
||||||
|
"lobby.cta.station": "Station betreten (Warten)",
|
||||||
|
"lobby.cta.launch": "Jetzt starten",
|
||||||
|
"lobby.modal.boardingCheck": "Boarding-Check",
|
||||||
|
"lobby.modal.routeBoarding": "Boarding fur Route {routeName}",
|
||||||
|
"lobby.modal.description":
|
||||||
|
"Legen Sie Ihre Mission fest, bevor diese Reise beginnt.",
|
||||||
|
"boarding.check": "Boarding-Check",
|
||||||
|
"boarding.routeBoarding": "Boarding fur Route {routeName}",
|
||||||
|
"boarding.missionLabel": "Kernmission fur diese Reise",
|
||||||
|
"boarding.missionPlaceholder":
|
||||||
|
"Optional: 3 Einleitungsabsatze fertigstellen",
|
||||||
|
"boarding.optionalLabel": "Optional",
|
||||||
|
"boarding.cancel": "Abbrechen",
|
||||||
|
"boarding.submit": "Andocken abgeschlossen (Start)",
|
||||||
|
"flight.badge.paused": "Pausiert",
|
||||||
|
"flight.badge.cruising": "Im Flug",
|
||||||
|
"flight.missionLabel": "Reisemission",
|
||||||
|
"flight.pause": "Pause",
|
||||||
|
"flight.resume": "Fortsetzen",
|
||||||
|
"flight.crewPresence.header": "Aktuell gemeinsam auf Reise: {count}",
|
||||||
|
"flight.crewPresence.expand": "Ausklappen",
|
||||||
|
"flight.crewPresence.collapse": "Einklappen",
|
||||||
|
"flight.crewPresence.listLimit": "Bis zu {limit} Crewmitglieder anzeigen",
|
||||||
|
"flight.crewPresence.overflow": "+{count} mehr",
|
||||||
|
"flight.crewPresence.status.online": "Online",
|
||||||
|
"flight.crewPresence.status.idle": "Inaktiv",
|
||||||
|
"flight.crewPresence.status.offline": "Offline",
|
||||||
|
"flight.crewPresence.activityHint": "Basierend auf letzter Aktivitat",
|
||||||
|
"flight.crewPresence.elapsedMinutes": "vor {minutes} Min",
|
||||||
|
"flight.crewPresence.goal.private": "Ziel privat",
|
||||||
|
"flight.crewPresence.goal.unset": "Ziel nicht festgelegt",
|
||||||
|
"flight.crewPresence.notifications.policyOn": "Hinweise ON",
|
||||||
|
"flight.crewPresence.notifications.filterHint":
|
||||||
|
"Nur friend/my-crew Ereignisse anzeigen",
|
||||||
|
"flight.crewPresence.notifications.target.friend": "Freund",
|
||||||
|
"flight.crewPresence.notifications.target.myCrew": "Meine Crew",
|
||||||
|
"flight.finish.debrief": "Ankunft (Debrief)",
|
||||||
|
"flight.finish.end": "Reise beenden",
|
||||||
|
"flight.debrief.title": "Diese Reise abschliessen",
|
||||||
|
"flight.debrief.description":
|
||||||
|
"Schreiben Sie eine kurze Notiz und speichern Sie sie im Logbuch.",
|
||||||
|
"debrief.page.title": "Sie haben die Umlaufbahn sicher erreicht",
|
||||||
|
"debrief.page.description":
|
||||||
|
"Dokumentieren Sie diese Reise kurz und schliessen Sie ab.",
|
||||||
|
"debrief.status.label": "Reiseergebnis",
|
||||||
|
"debrief.option.completed.label": "Mission abgeschlossen",
|
||||||
|
"debrief.option.completed.desc":
|
||||||
|
"Ich habe das erledigt, was ich mir vorgenommen hatte.",
|
||||||
|
"debrief.option.partial.label": "Teilweise Fortschritte",
|
||||||
|
"debrief.option.partial.desc":
|
||||||
|
"Ich habe wichtige Teile vorangebracht und nachste Schritte offen gelassen.",
|
||||||
|
"debrief.option.reoriented.label": "Mission neu ausgerichtet",
|
||||||
|
"debrief.option.reoriented.desc":
|
||||||
|
"Ich habe Umfang und Prioritaten wahrend der Arbeit neu festgelegt.",
|
||||||
|
"debrief.reflection.label": "Reflexion nach dieser Reise",
|
||||||
|
"debrief.reflection.placeholder":
|
||||||
|
"Beispiel: was gut lief, was nicht gut lief, und was ich gelernt habe",
|
||||||
|
"debrief.save": "Im Logbuch speichern",
|
||||||
|
"log.title": "Meine Reiseprotokolle",
|
||||||
|
"log.mission.empty": "Keine Mission eingetragen",
|
||||||
|
"log.empty": "Noch keine Reisen aufgezeichnet.",
|
||||||
|
"log.firstVoyage": "Ihre erste Reise starten",
|
||||||
|
"log.detail.back": "Zuruck zur Liste",
|
||||||
|
"log.detail.loadingOrNotFound": "Wird geladen oder nicht gefunden...",
|
||||||
|
"log.detail.statusTitle": "Ergebnisstatus",
|
||||||
|
"log.detail.progressTitle": "Was ich erreicht habe",
|
||||||
|
"log.detail.nextActionTitle": "Nachste Aktion",
|
||||||
|
"log.detail.initialNoteTitle": "Anfangsnotiz",
|
||||||
|
"settings.title": "Einstellungen",
|
||||||
|
"settings.hideSeconds.title": "Sekunden ausblenden",
|
||||||
|
"settings.hideSeconds.description":
|
||||||
|
"Nur Minuten auf dem Timer anzeigen, um Druck zu reduzieren.",
|
||||||
|
"status.completed": "Abgeschlossen",
|
||||||
|
"status.partial": "Teilweise",
|
||||||
|
"status.reoriented": "Neu ausgerichtet",
|
||||||
|
"status.aborted": "Fruh abgebrochen",
|
||||||
|
"status.in_progress": "In Bearbeitung",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const I18N_MESSAGES: Record<Locale, I18nMessages> = {
|
||||||
|
ko: koMessages,
|
||||||
|
en: normalizedEnMessages,
|
||||||
|
ja: jaMessages,
|
||||||
|
fr: frMessages,
|
||||||
|
de: deMessages,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type I18nKey = MessageKey;
|
||||||
|
|
||||||
|
export type TranslationParams = Record<string, string | number>;
|
||||||
|
|
||||||
|
const interpolateMessage = (
|
||||||
|
template: string,
|
||||||
|
params?: TranslationParams,
|
||||||
|
): string => {
|
||||||
|
if (!params) return template;
|
||||||
|
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, paramKey) => {
|
||||||
|
const value = params[paramKey];
|
||||||
|
return value === undefined ? "" : String(value);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const translateText = (
|
||||||
|
locale: Locale,
|
||||||
|
key: I18nKey | string,
|
||||||
|
params?: TranslationParams,
|
||||||
|
fallback = "",
|
||||||
|
): string => {
|
||||||
|
const localeMessages = I18N_MESSAGES[locale] as Record<string, string>;
|
||||||
|
const defaultMessages = I18N_MESSAGES[DEFAULT_LOCALE] as Record<string, string>;
|
||||||
|
const template =
|
||||||
|
localeMessages[key] ?? defaultMessages[key] ?? fallback;
|
||||||
|
|
||||||
|
return interpolateMessage(template, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DebriefStatusOption = "completed" | "partial" | "reoriented";
|
||||||
|
|
||||||
|
export const DEBRIEF_STATUS_OPTIONS: Array<{
|
||||||
|
value: DebriefStatusOption;
|
||||||
|
labelKey: I18nKey;
|
||||||
|
descKey: I18nKey;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: "completed",
|
||||||
|
labelKey: "debrief.option.completed.label",
|
||||||
|
descKey: "debrief.option.completed.desc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "partial",
|
||||||
|
labelKey: "debrief.option.partial.label",
|
||||||
|
descKey: "debrief.option.partial.desc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "reoriented",
|
||||||
|
labelKey: "debrief.option.reoriented.label",
|
||||||
|
descKey: "debrief.option.reoriented.desc",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VOYAGE_STATUS_LABEL_KEYS: Record<VoyageStatus, I18nKey> = {
|
||||||
|
completed: "status.completed",
|
||||||
|
partial: "status.partial",
|
||||||
|
reoriented: "status.reoriented",
|
||||||
|
aborted: "status.aborted",
|
||||||
|
in_progress: "status.in_progress",
|
||||||
|
};
|
||||||
28
src/shared/config/routes.ts
Normal file
28
src/shared/config/routes.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Route } from '@/shared/types';
|
||||||
|
|
||||||
|
export const ROUTES: Route[] = [
|
||||||
|
{
|
||||||
|
id: 'station',
|
||||||
|
durationMinutes: 0,
|
||||||
|
nameKey: 'routes.station.name',
|
||||||
|
tagKey: 'routes.station.tag',
|
||||||
|
descriptionKey: 'routes.station.description',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orion',
|
||||||
|
durationMinutes: 60,
|
||||||
|
nameKey: 'routes.orion.name',
|
||||||
|
tagKey: 'routes.orion.tag',
|
||||||
|
descriptionKey: 'routes.orion.description',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini',
|
||||||
|
durationMinutes: 30,
|
||||||
|
nameKey: 'routes.gemini.name',
|
||||||
|
tagKey: 'routes.gemini.tag',
|
||||||
|
descriptionKey: 'routes.gemini.description',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const findRouteById = (routeId: string) =>
|
||||||
|
ROUTES.find((route) => route.id === routeId);
|
||||||
54
src/shared/config/starfield.ts
Normal file
54
src/shared/config/starfield.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export const FLIGHT_STARFIELD_TUNING = {
|
||||||
|
mobileBreakpoint: 768,
|
||||||
|
densityDivisor: 42000,
|
||||||
|
densityMultiplier: 1.35,
|
||||||
|
speedScale: 0.3,
|
||||||
|
starCount: {
|
||||||
|
mobile: { min: 16, max: 48 },
|
||||||
|
desktop: { min: 24, max: 72 },
|
||||||
|
},
|
||||||
|
maxStars: {
|
||||||
|
mobile: 48,
|
||||||
|
desktop: 72,
|
||||||
|
},
|
||||||
|
vanishXJitter: { min: 10, max: 25 },
|
||||||
|
speedTiers: {
|
||||||
|
slow: { chance: 0.9, min: 0.00255, max: 0.00595 },
|
||||||
|
medium: { chance: 0.99, min: 0.00595, max: 0.00935 },
|
||||||
|
fast: { min: 0.00935, max: 0.0119 },
|
||||||
|
},
|
||||||
|
tail: {
|
||||||
|
pointChance: 0.82,
|
||||||
|
shortChance: 0.86,
|
||||||
|
pointRange: { min: 0.5, max: 2.5 },
|
||||||
|
shortRange: { min: 2.5, max: 3.8 },
|
||||||
|
longRange: { min: 4, max: 10 },
|
||||||
|
cleanup: {
|
||||||
|
minAlphaToDraw: 0.08,
|
||||||
|
minMovementToDraw: 0.12,
|
||||||
|
minLineWidthToDraw: 0.86,
|
||||||
|
minTailLengthToDraw: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spawnRadius: {
|
||||||
|
centerChance: 0.08,
|
||||||
|
ringChance: 0.8,
|
||||||
|
centerRange: { min: 0, max: 60 },
|
||||||
|
ringRange: { min: 80, max: 320 },
|
||||||
|
wideRange: { min: 120, maxScaleOfViewport: 0.7, maxAbsolute: 360 },
|
||||||
|
},
|
||||||
|
zRange: {
|
||||||
|
initial: { min: 0.55, max: 1.6 },
|
||||||
|
respawn: { min: 0.9, max: 1.55 },
|
||||||
|
recycleThreshold: 0.22,
|
||||||
|
},
|
||||||
|
radius: {
|
||||||
|
normal: { min: 0.7, max: 1.2 },
|
||||||
|
highlight: { min: 1.2, max: 1.8 },
|
||||||
|
highlightChance: 0.16,
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
normal: { min: 0.25, max: 0.55 },
|
||||||
|
highlight: { min: 0.55, max: 0.85 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
71
src/shared/lib/analytics.ts
Normal file
71
src/shared/lib/analytics.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
export type CrewPresenceEventName =
|
||||||
|
| "panel_visible"
|
||||||
|
| "panel_collapsed"
|
||||||
|
| "presence_event_click";
|
||||||
|
|
||||||
|
export type CrewPresenceNotificationTarget = "friend" | "my-crew";
|
||||||
|
|
||||||
|
export type CrewPresenceEventPayload = {
|
||||||
|
decisionId: string;
|
||||||
|
routeId: string;
|
||||||
|
totalCount: number;
|
||||||
|
visibleCount: number;
|
||||||
|
renderCap?: number;
|
||||||
|
updateIntervalMs?: number;
|
||||||
|
collapsed?: boolean;
|
||||||
|
targetType?: CrewPresenceNotificationTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CrewPresenceDashboardBinding = {
|
||||||
|
metricKey: string;
|
||||||
|
chartKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CREW_PRESENCE_DASHBOARD_BINDINGS: Record<
|
||||||
|
CrewPresenceEventName,
|
||||||
|
CrewPresenceDashboardBinding
|
||||||
|
> = {
|
||||||
|
panel_visible: {
|
||||||
|
metricKey: "crew_presence.panel_visible.count",
|
||||||
|
chartKey: "crew_presence_panel_visible_trend",
|
||||||
|
},
|
||||||
|
panel_collapsed: {
|
||||||
|
metricKey: "crew_presence.panel_collapsed.count",
|
||||||
|
chartKey: "crew_presence_panel_collapsed_trend",
|
||||||
|
},
|
||||||
|
presence_event_click: {
|
||||||
|
metricKey: "crew_presence.presence_event_click.count",
|
||||||
|
chartKey: "crew_presence_event_click_target_split",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__HUSHROOM_ANALYTICS__?: {
|
||||||
|
track: (eventName: string, payload: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trackCrewPresenceEvent = (
|
||||||
|
eventName: CrewPresenceEventName,
|
||||||
|
payload: CrewPresenceEventPayload,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const dashboardBinding = CREW_PRESENCE_DASHBOARD_BINDINGS[eventName];
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("hushroom:crew-presence-analytics", {
|
||||||
|
detail: { eventName, payload, dashboardBinding },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
window.__HUSHROOM_ANALYTICS__?.track(eventName, {
|
||||||
|
...payload,
|
||||||
|
...dashboardBinding,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Keep analytics failures isolated from UX flow.
|
||||||
|
}
|
||||||
|
};
|
||||||
6
src/shared/lib/cn.ts
Normal file
6
src/shared/lib/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
5
src/shared/lib/math/number.ts
Normal file
5
src/shared/lib/math/number.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const clamp = (value: number, min: number, max: number) =>
|
||||||
|
Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
|
export const randomInRange = (min: number, max: number) =>
|
||||||
|
min + Math.random() * (max - min);
|
||||||
4
src/shared/lib/motion/prefersReducedMotion.ts
Normal file
4
src/shared/lib/motion/prefersReducedMotion.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||||
|
|
||||||
|
export const getPrefersReducedMotionMediaQuery = () =>
|
||||||
|
window.matchMedia(REDUCED_MOTION_QUERY);
|
||||||
42
src/shared/lib/store.ts
Normal file
42
src/shared/lib/store.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { UserPreferences, Voyage } from '@/shared/types';
|
||||||
|
|
||||||
|
const KEYS = {
|
||||||
|
HISTORY: 'focustella_history_v1',
|
||||||
|
CURRENT: 'focustella_current_v1',
|
||||||
|
PREFS: 'focustella_prefs_v1',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHistory = (): Voyage[] => {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
const item = localStorage.getItem(KEYS.HISTORY);
|
||||||
|
return item ? JSON.parse(item) : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveToHistory = (voyage: Voyage) => {
|
||||||
|
const history = getHistory();
|
||||||
|
localStorage.setItem(KEYS.HISTORY, JSON.stringify([voyage, ...history]));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentVoyage = (): Voyage | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
const item = localStorage.getItem(KEYS.CURRENT);
|
||||||
|
return item ? JSON.parse(item) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveCurrentVoyage = (voyage: Voyage | null) => {
|
||||||
|
if (voyage === null) {
|
||||||
|
localStorage.removeItem(KEYS.CURRENT);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(KEYS.CURRENT, JSON.stringify(voyage));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPreferences = (): UserPreferences => {
|
||||||
|
if (typeof window === 'undefined') return { hideSeconds: false };
|
||||||
|
const item = localStorage.getItem(KEYS.PREFS);
|
||||||
|
return item ? JSON.parse(item) : { hideSeconds: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const savePreferences = (prefs: UserPreferences) => {
|
||||||
|
localStorage.setItem(KEYS.PREFS, JSON.stringify(prefs));
|
||||||
|
};
|
||||||
33
src/shared/types/index.ts
Normal file
33
src/shared/types/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface Route {
|
||||||
|
id: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
nameKey: string;
|
||||||
|
tagKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VoyageStatus =
|
||||||
|
| 'completed'
|
||||||
|
| 'partial'
|
||||||
|
| 'reoriented'
|
||||||
|
| 'aborted'
|
||||||
|
| 'in_progress';
|
||||||
|
|
||||||
|
export interface Voyage {
|
||||||
|
id: string;
|
||||||
|
routeId: string;
|
||||||
|
routeName: string;
|
||||||
|
startedAt: number;
|
||||||
|
endedAt?: number;
|
||||||
|
durationMinutes: number;
|
||||||
|
status: VoyageStatus;
|
||||||
|
missionText: string;
|
||||||
|
notes?: string;
|
||||||
|
debriefProgress?: string;
|
||||||
|
nextAction?: string;
|
||||||
|
blockerTag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPreferences {
|
||||||
|
hideSeconds: boolean;
|
||||||
|
}
|
||||||
1
src/widgets/flight-background/index.ts
Normal file
1
src/widgets/flight-background/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FlightBackgroundWidget } from './ui/FlightBackgroundWidget';
|
||||||
11
src/widgets/flight-background/ui/FlightBackgroundWidget.tsx
Normal file
11
src/widgets/flight-background/ui/FlightBackgroundWidget.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { FlightStarfieldCanvas } from '@/features/flight-starfield';
|
||||||
|
|
||||||
|
export function FlightBackgroundWidget({ isPaused }: { isPaused: boolean }) {
|
||||||
|
return (
|
||||||
|
<FlightStarfieldCanvas
|
||||||
|
vanishYOffset={-68}
|
||||||
|
centerProtectRadius={200}
|
||||||
|
isPaused={isPaused}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/widgets/flight-hud/index.ts
Normal file
1
src/widgets/flight-hud/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FlightHudWidget } from './ui/FlightHudWidget';
|
||||||
609
src/widgets/flight-hud/ui/FlightHudWidget.tsx
Normal file
609
src/widgets/flight-hud/ui/FlightHudWidget.tsx
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useI18n } from "@/features/i18n/model/useI18n";
|
||||||
|
import {
|
||||||
|
isCrewPresenceRuntimeConfigSafe,
|
||||||
|
resolveCrewPresenceRuntimeConfig,
|
||||||
|
} from "@/shared/config/featureFlags";
|
||||||
|
import { DEBRIEF_STATUS_OPTIONS } from "@/shared/config/i18n";
|
||||||
|
import { findRouteById } from "@/shared/config/routes";
|
||||||
|
import {
|
||||||
|
CrewPresenceNotificationTarget,
|
||||||
|
trackCrewPresenceEvent,
|
||||||
|
} from "@/shared/lib/analytics";
|
||||||
|
import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store";
|
||||||
|
import { Voyage, VoyageStatus } from "@/shared/types";
|
||||||
|
|
||||||
|
const FINISH_HOLD_MS = 1000;
|
||||||
|
const HOLD_STAGE_ONE_MS = 100;
|
||||||
|
const HOLD_STAGE_ONE_PROGRESS = 0.2;
|
||||||
|
const CREW_PRESENCE_GUARDRAIL_FALLBACK_CAP = 1;
|
||||||
|
const CREW_PRESENCE_GUARDRAIL_FALLBACK_INTERVAL_MS = 60_000;
|
||||||
|
const CREW_PRESENCE_DECISION_ID = "DEC-20260216-01";
|
||||||
|
|
||||||
|
type CrewPresenceStatus = "online" | "idle" | "offline";
|
||||||
|
type CrewGoalVisibility = "public" | "private" | "unset";
|
||||||
|
type CrewNotificationTarget = "friend" | "my-crew" | "global";
|
||||||
|
|
||||||
|
type CrewPresenceMember = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: CrewPresenceStatus;
|
||||||
|
elapsedMinutes: number;
|
||||||
|
goalVisibility: CrewGoalVisibility;
|
||||||
|
goalText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CrewPresenceNotification = {
|
||||||
|
id: string;
|
||||||
|
targetType: CrewNotificationTarget;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREW_PRESENCE_STATUS_CYCLE: CrewPresenceStatus[] = [
|
||||||
|
"online",
|
||||||
|
"idle",
|
||||||
|
"offline",
|
||||||
|
];
|
||||||
|
const CREW_PRESENCE_GOAL_CYCLE: CrewGoalVisibility[] = [
|
||||||
|
"public",
|
||||||
|
"private",
|
||||||
|
"unset",
|
||||||
|
];
|
||||||
|
const CREW_PRESENCE_MOCK_MEMBERS: CrewPresenceMember[] = Array.from(
|
||||||
|
{ length: 57 },
|
||||||
|
(_, index) => ({
|
||||||
|
id: `crew-${index + 1}`,
|
||||||
|
name: `Crew ${index + 1}`,
|
||||||
|
status:
|
||||||
|
CREW_PRESENCE_STATUS_CYCLE[index % CREW_PRESENCE_STATUS_CYCLE.length],
|
||||||
|
elapsedMinutes: (index + 1) * 2,
|
||||||
|
goalVisibility:
|
||||||
|
CREW_PRESENCE_GOAL_CYCLE[index % CREW_PRESENCE_GOAL_CYCLE.length],
|
||||||
|
goalText: `Mission ${index + 1}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const CREW_STATUS_LABEL_KEYS: Record<CrewPresenceStatus, string> = {
|
||||||
|
online: "flight.crewPresence.status.online",
|
||||||
|
idle: "flight.crewPresence.status.idle",
|
||||||
|
offline: "flight.crewPresence.status.offline",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREW_GOAL_STATE_LABEL_KEYS: Record<
|
||||||
|
Exclude<CrewGoalVisibility, "public">,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
private: "flight.crewPresence.goal.private",
|
||||||
|
unset: "flight.crewPresence.goal.unset",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREW_NOTIFICATION_ALLOWED_TARGETS = ["friend", "my-crew"] as const;
|
||||||
|
|
||||||
|
const CREW_PRESENCE_MOCK_NOTIFICATIONS: CrewPresenceNotification[] = [
|
||||||
|
{
|
||||||
|
id: "notif-1",
|
||||||
|
targetType: "friend",
|
||||||
|
message: "Crew 2 just joined this voyage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notif-2",
|
||||||
|
targetType: "my-crew",
|
||||||
|
message: "Crew 7 switched to online",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notif-3",
|
||||||
|
targetType: "global",
|
||||||
|
message: "A public voyage wave started",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CREW_NOTIFICATION_TARGET_LABEL_KEYS: Record<
|
||||||
|
(typeof CREW_NOTIFICATION_ALLOWED_TARGETS)[number],
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
friend: "flight.crewPresence.notifications.target.friend",
|
||||||
|
"my-crew": "flight.crewPresence.notifications.target.myCrew",
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCrewNotificationTargetAllowed = (
|
||||||
|
targetType: CrewNotificationTarget,
|
||||||
|
): targetType is (typeof CREW_NOTIFICATION_ALLOWED_TARGETS)[number] =>
|
||||||
|
CREW_NOTIFICATION_ALLOWED_TARGETS.includes(
|
||||||
|
targetType as (typeof CREW_NOTIFICATION_ALLOWED_TARGETS)[number],
|
||||||
|
);
|
||||||
|
|
||||||
|
type AllowedCrewPresenceNotification = CrewPresenceNotification & {
|
||||||
|
targetType: CrewPresenceNotificationTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlightHudWidgetProps = {
|
||||||
|
voyage: Voyage | null;
|
||||||
|
isPaused: boolean;
|
||||||
|
formattedTime: string;
|
||||||
|
isCountdownCompleted: boolean;
|
||||||
|
handlePauseToggle: () => void;
|
||||||
|
handleFinish: () => Voyage | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FlightHudWidget({
|
||||||
|
voyage,
|
||||||
|
isPaused,
|
||||||
|
formattedTime,
|
||||||
|
isCountdownCompleted,
|
||||||
|
handlePauseToggle,
|
||||||
|
handleFinish,
|
||||||
|
}: FlightHudWidgetProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isDebriefOpen, setIsDebriefOpen] = useState(false);
|
||||||
|
const [finishedVoyage, setFinishedVoyage] = useState<Voyage | null>(null);
|
||||||
|
const [status, setStatus] = useState<VoyageStatus | null>(null);
|
||||||
|
const [progress, setProgress] = useState("");
|
||||||
|
const [holdProgress, setHoldProgress] = useState(0);
|
||||||
|
const [isCrewPanelCollapsed, setIsCrewPanelCollapsed] = useState(true);
|
||||||
|
const holdStartAtRef = useRef<number | null>(null);
|
||||||
|
const holdRafRef = useRef<number | null>(null);
|
||||||
|
const isHoldCompletedRef = useRef(false);
|
||||||
|
|
||||||
|
const openDebriefModal = () => {
|
||||||
|
const endedVoyage = handleFinish();
|
||||||
|
if (!endedVoyage) return;
|
||||||
|
|
||||||
|
setFinishedVoyage(endedVoyage);
|
||||||
|
setStatus(null);
|
||||||
|
setProgress("");
|
||||||
|
setIsDebriefOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDebriefSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!status || !finishedVoyage) return;
|
||||||
|
|
||||||
|
const finalVoyage: Voyage = {
|
||||||
|
...finishedVoyage,
|
||||||
|
status,
|
||||||
|
debriefProgress: progress,
|
||||||
|
};
|
||||||
|
|
||||||
|
saveToHistory(finalVoyage);
|
||||||
|
saveCurrentVoyage(null);
|
||||||
|
setIsDebriefOpen(false);
|
||||||
|
router.push("/log");
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopHoldLoop = () => {
|
||||||
|
if (holdRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(holdRafRef.current);
|
||||||
|
holdRafRef.current = null;
|
||||||
|
}
|
||||||
|
holdStartAtRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetHold = () => {
|
||||||
|
stopHoldLoop();
|
||||||
|
isHoldCompletedRef.current = false;
|
||||||
|
setHoldProgress(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDebriefByHold = () => {
|
||||||
|
isHoldCompletedRef.current = true;
|
||||||
|
stopHoldLoop();
|
||||||
|
setHoldProgress(1);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
resetHold();
|
||||||
|
openDebriefModal();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const tickHoldProgress = (timestamp: number) => {
|
||||||
|
if (holdStartAtRef.current === null) return;
|
||||||
|
|
||||||
|
const elapsed = timestamp - holdStartAtRef.current;
|
||||||
|
const nextProgress = (() => {
|
||||||
|
if (elapsed <= HOLD_STAGE_ONE_MS) {
|
||||||
|
return Math.min(
|
||||||
|
HOLD_STAGE_ONE_PROGRESS,
|
||||||
|
(elapsed / HOLD_STAGE_ONE_MS) * HOLD_STAGE_ONE_PROGRESS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageTwoElapsed = elapsed - HOLD_STAGE_ONE_MS;
|
||||||
|
const stageTwoDuration = FINISH_HOLD_MS - HOLD_STAGE_ONE_MS;
|
||||||
|
const stageTwoProgressRatio = Math.min(
|
||||||
|
1,
|
||||||
|
stageTwoElapsed / stageTwoDuration,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
1,
|
||||||
|
HOLD_STAGE_ONE_PROGRESS +
|
||||||
|
stageTwoProgressRatio * (1 - HOLD_STAGE_ONE_PROGRESS),
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
setHoldProgress(nextProgress);
|
||||||
|
|
||||||
|
if (nextProgress >= 1) {
|
||||||
|
openDebriefByHold();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
holdRafRef.current = requestAnimationFrame(tickHoldProgress);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startHoldToFinish = () => {
|
||||||
|
if (isDebriefOpen) return;
|
||||||
|
|
||||||
|
resetHold();
|
||||||
|
holdStartAtRef.current = performance.now();
|
||||||
|
setHoldProgress(0);
|
||||||
|
holdRafRef.current = requestAnimationFrame(tickHoldProgress);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelHoldToFinish = () => {
|
||||||
|
if (isHoldCompletedRef.current) return;
|
||||||
|
resetHold();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (holdRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(holdRafRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const voyageRouteId = voyage?.routeId ?? "";
|
||||||
|
const route = voyageRouteId ? findRouteById(voyageRouteId) : null;
|
||||||
|
const routeName = route
|
||||||
|
? t(route.nameKey, undefined, voyage?.routeName ?? "")
|
||||||
|
: (voyage?.routeName ?? "");
|
||||||
|
const hasMissionText =
|
||||||
|
Boolean(voyage?.missionText?.trim()) &&
|
||||||
|
voyage?.missionText?.trim() !== "미입력";
|
||||||
|
const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({
|
||||||
|
value: option.value as VoyageStatus,
|
||||||
|
label: t(option.labelKey),
|
||||||
|
desc: t(option.descKey),
|
||||||
|
}));
|
||||||
|
const crewRuntimeConfig = resolveCrewPresenceRuntimeConfig();
|
||||||
|
const hasSafeCrewRuntimeConfig =
|
||||||
|
isCrewPresenceRuntimeConfigSafe(crewRuntimeConfig);
|
||||||
|
const crewRenderCap = hasSafeCrewRuntimeConfig
|
||||||
|
? crewRuntimeConfig.renderCap
|
||||||
|
: CREW_PRESENCE_GUARDRAIL_FALLBACK_CAP;
|
||||||
|
const crewUpdateIntervalMs = hasSafeCrewRuntimeConfig
|
||||||
|
? crewRuntimeConfig.updateIntervalMs
|
||||||
|
: CREW_PRESENCE_GUARDRAIL_FALLBACK_INTERVAL_MS;
|
||||||
|
const isCrewPresencePanelVisible =
|
||||||
|
hasSafeCrewRuntimeConfig && crewRuntimeConfig.panelEnabled;
|
||||||
|
const isCrewNotificationPolicyEnabled =
|
||||||
|
hasSafeCrewRuntimeConfig && crewRuntimeConfig.notificationsEnabled;
|
||||||
|
const crewTotalCount = CREW_PRESENCE_MOCK_MEMBERS.length;
|
||||||
|
const visibleCrewMembers = CREW_PRESENCE_MOCK_MEMBERS.slice(0, crewRenderCap);
|
||||||
|
const overflowCrewCount = Math.max(0, crewTotalCount - crewRenderCap);
|
||||||
|
const filteredCrewNotifications: AllowedCrewPresenceNotification[] =
|
||||||
|
CREW_PRESENCE_MOCK_NOTIFICATIONS.filter(
|
||||||
|
(notification): notification is AllowedCrewPresenceNotification =>
|
||||||
|
isCrewNotificationTargetAllowed(notification.targetType),
|
||||||
|
);
|
||||||
|
const buildCommonCrewAnalyticsPayload = () => ({
|
||||||
|
decisionId: CREW_PRESENCE_DECISION_ID,
|
||||||
|
routeId: voyageRouteId,
|
||||||
|
totalCount: crewTotalCount,
|
||||||
|
visibleCount: visibleCrewMembers.length,
|
||||||
|
renderCap: crewRenderCap,
|
||||||
|
updateIntervalMs: crewUpdateIntervalMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCrewPanelToggle = () => {
|
||||||
|
setIsCrewPanelCollapsed((prev) => {
|
||||||
|
const nextCollapsed = !prev;
|
||||||
|
trackCrewPresenceEvent("panel_collapsed", {
|
||||||
|
...buildCommonCrewAnalyticsPayload(),
|
||||||
|
collapsed: nextCollapsed,
|
||||||
|
});
|
||||||
|
return nextCollapsed;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePresenceEventClick = (
|
||||||
|
targetType: CrewPresenceNotificationTarget,
|
||||||
|
) => {
|
||||||
|
trackCrewPresenceEvent("presence_event_click", {
|
||||||
|
...buildCommonCrewAnalyticsPayload(),
|
||||||
|
targetType,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCrewPresencePanelVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackPanelVisible = () => {
|
||||||
|
trackCrewPresenceEvent("panel_visible", {
|
||||||
|
...buildCommonCrewAnalyticsPayload(),
|
||||||
|
collapsed: isCrewPanelCollapsed,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
trackPanelVisible();
|
||||||
|
const intervalId = window.setInterval(
|
||||||
|
trackPanelVisible,
|
||||||
|
crewUpdateIntervalMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
crewTotalCount,
|
||||||
|
isCrewPanelCollapsed,
|
||||||
|
isCrewPresencePanelVisible,
|
||||||
|
crewUpdateIntervalMs,
|
||||||
|
visibleCrewMembers.length,
|
||||||
|
voyageRouteId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!voyage) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="absolute top-8 z-10 text-center">
|
||||||
|
<span className="rounded-full border border-indigo-500/30 bg-indigo-950/50 px-4 py-1.5 text-xs font-medium uppercase tracking-widest text-indigo-300 shadow-[0_0_15px_rgba(99,102,241,0.3)] backdrop-blur">
|
||||||
|
{routeName} ·{" "}
|
||||||
|
{isPaused ? t("flight.badge.paused") : t("flight.badge.cruising")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`relative z-10 my-12 font-mono text-7xl font-light tracking-tighter tabular-nums drop-shadow-2xl transition-opacity duration-300 md:text-9xl ${isPaused ? "opacity-50" : "opacity-100"}`}
|
||||||
|
>
|
||||||
|
{formattedTime}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMissionText && (
|
||||||
|
<div className="relative z-10 mb-24 w-full max-w-2xl px-4">
|
||||||
|
<section className="rounded-2xl border border-slate-600/60 bg-slate-950/55 p-4 text-left shadow-[0_16px_40px_rgba(2,6,23,0.35)] backdrop-blur md:p-6">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400 md:text-xs">
|
||||||
|
{t("flight.missionLabel")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-h-32 overflow-y-auto break-words whitespace-pre-wrap pr-1 text-base leading-relaxed text-slate-100 md:text-lg">
|
||||||
|
{voyage.missionText}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCrewPresencePanelVisible && (
|
||||||
|
<div className="relative z-10 mb-8 w-full max-w-2xl px-4">
|
||||||
|
<section className="rounded-2xl border border-slate-600/60 bg-slate-950/55 p-4 text-left shadow-[0_16px_40px_rgba(2,6,23,0.35)] backdrop-blur md:p-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-100 md:text-base">
|
||||||
|
{t("flight.crewPresence.header", {
|
||||||
|
count: crewTotalCount,
|
||||||
|
})}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCrewPanelToggle}
|
||||||
|
className="rounded-lg border border-slate-600/80 bg-slate-900/40 px-3 py-1.5 text-xs font-semibold text-slate-200 transition-colors hover:border-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
{isCrewPanelCollapsed
|
||||||
|
? t("flight.crewPresence.expand")
|
||||||
|
: t("flight.crewPresence.collapse")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!isCrewPanelCollapsed && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{t("flight.crewPresence.listLimit", {
|
||||||
|
limit: crewRenderCap,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{t("flight.crewPresence.activityHint")}
|
||||||
|
</p>
|
||||||
|
<ul className="max-h-56 space-y-2 overflow-y-auto rounded-xl border border-slate-700/70 bg-slate-900/35 p-3">
|
||||||
|
{visibleCrewMembers.map((member) => (
|
||||||
|
<li
|
||||||
|
key={member.id}
|
||||||
|
className="space-y-1 rounded-lg border border-slate-700/70 bg-slate-900/60 px-3 py-2 text-sm text-slate-100"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="font-semibold">{member.name}</span>
|
||||||
|
<span className="rounded-md border border-slate-600/80 px-2 py-0.5 text-[11px] text-slate-200">
|
||||||
|
{t(CREW_STATUS_LABEL_KEYS[member.status])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-300">
|
||||||
|
{t("flight.crewPresence.elapsedMinutes", {
|
||||||
|
minutes: member.elapsedMinutes,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-300">
|
||||||
|
{member.goalVisibility === "public"
|
||||||
|
? member.goalText
|
||||||
|
: t(
|
||||||
|
CREW_GOAL_STATE_LABEL_KEYS[member.goalVisibility],
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{overflowCrewCount > 0 && (
|
||||||
|
<p className="text-xs font-semibold text-slate-300">
|
||||||
|
{t("flight.crewPresence.overflow", {
|
||||||
|
count: overflowCrewCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isCrewNotificationPolicyEnabled &&
|
||||||
|
filteredCrewNotifications.length > 0 && (
|
||||||
|
<section className="rounded-xl border border-slate-700/70 bg-slate-900/35 p-3">
|
||||||
|
<p className="text-xs font-semibold text-slate-200">
|
||||||
|
{t("flight.crewPresence.notifications.policyOn")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
|
{t("flight.crewPresence.notifications.filterHint")}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-1.5">
|
||||||
|
{filteredCrewNotifications.map((notification) => (
|
||||||
|
<li key={notification.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handlePresenceEventClick(
|
||||||
|
notification.targetType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full rounded-md border border-slate-700/70 bg-slate-900/60 px-2.5 py-1.5 text-left text-xs text-slate-200"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-slate-100">
|
||||||
|
{t(
|
||||||
|
CREW_NOTIFICATION_TARGET_LABEL_KEYS[
|
||||||
|
notification.targetType
|
||||||
|
],
|
||||||
|
)}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-slate-300">
|
||||||
|
{notification.message}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute bottom-12 z-10 flex gap-6">
|
||||||
|
<button
|
||||||
|
onClick={handlePauseToggle}
|
||||||
|
className="rounded-full border border-slate-600 bg-slate-900/50 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-300 backdrop-blur transition-all hover:border-slate-400 hover:bg-slate-800/80 hover:text-white"
|
||||||
|
>
|
||||||
|
{isPaused ? t("flight.resume") : t("flight.pause")}
|
||||||
|
</button>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onPointerDown={startHoldToFinish}
|
||||||
|
onPointerUp={cancelHoldToFinish}
|
||||||
|
onPointerLeave={cancelHoldToFinish}
|
||||||
|
onPointerCancel={cancelHoldToFinish}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
startHoldToFinish();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelHoldToFinish();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={cancelHoldToFinish}
|
||||||
|
className="relative overflow-hidden rounded-full border border-slate-200 bg-slate-100 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-900 shadow-lg shadow-white/10 transition-all hover:border-indigo-300 hover:text-slate-950"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 origin-left bg-indigo-400/45"
|
||||||
|
style={{ transform: `scaleX(${holdProgress})` }}
|
||||||
|
/>
|
||||||
|
<span className="relative z-10">
|
||||||
|
{isCountdownCompleted
|
||||||
|
? t("flight.finish.debrief")
|
||||||
|
: t("flight.finish.end")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isDebriefOpen} onOpenChange={setIsDebriefOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto border-slate-700/80 bg-slate-950 text-slate-100">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">
|
||||||
|
{t("flight.debrief.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
{t("flight.debrief.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleDebriefSubmit} className="space-y-6">
|
||||||
|
<section>
|
||||||
|
<label className="mb-3 block text-sm font-medium text-slate-300">
|
||||||
|
{t("debrief.status.label")}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
{statusOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatus(opt.value)}
|
||||||
|
className={`flex min-h-[116px] flex-col justify-between rounded-xl border px-4 py-3.5 text-left transition-all ${
|
||||||
|
status === opt.value
|
||||||
|
? "border-indigo-500 bg-indigo-900/40 ring-1 ring-indigo-500"
|
||||||
|
: "border-slate-800 bg-slate-900/50 hover:bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm leading-snug font-bold text-slate-200 break-keep">
|
||||||
|
{opt.label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] leading-relaxed text-slate-500">
|
||||||
|
{opt.desc}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-slate-300">
|
||||||
|
{t("debrief.reflection.label")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={progress}
|
||||||
|
onChange={(event) => setProgress(event.target.value)}
|
||||||
|
placeholder={t("debrief.reflection.placeholder")}
|
||||||
|
className="w-full rounded-lg border border-slate-800 bg-slate-900/30 px-4 py-3 text-slate-200 outline-none transition-all focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsDebriefOpen(false)}
|
||||||
|
className="rounded-xl border border-slate-700 bg-slate-900/60 px-4 py-3 font-semibold text-slate-300 transition-colors hover:border-slate-500 hover:text-white"
|
||||||
|
>
|
||||||
|
{t("boarding.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!status}
|
||||||
|
className="rounded-xl bg-indigo-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500"
|
||||||
|
>
|
||||||
|
{t("debrief.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/widgets/lobby-background/index.ts
Normal file
1
src/widgets/lobby-background/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { LobbyBackgroundWidget } from './ui/LobbyBackgroundWidget';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ConstellationScene } from '@/features/lobby-starfield';
|
||||||
|
|
||||||
|
export function LobbyBackgroundWidget() {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black lg:left-72">
|
||||||
|
<ConstellationScene />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/widgets/lobby-routes/index.ts
Normal file
1
src/widgets/lobby-routes/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { LobbyRoutesPanel } from './ui/LobbyRoutesPanel';
|
||||||
217
src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx
Normal file
217
src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { BookOpenText, Languages, Settings } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ChangeEvent, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { BoardingMissionForm, startVoyage } from "@/features/boarding";
|
||||||
|
import { useI18n } from "@/features/i18n/model/useI18n";
|
||||||
|
import { useLobbyRedirect } from "@/features/lobby-session/model/useLobbyRedirect";
|
||||||
|
import { LOCALE_LABELS, Locale, SUPPORTED_LOCALES } from "@/shared/config/i18n";
|
||||||
|
import { ROUTES } from "@/shared/config/routes";
|
||||||
|
|
||||||
|
function RouteCard({
|
||||||
|
route,
|
||||||
|
isCTA = false,
|
||||||
|
onLaunch,
|
||||||
|
}: {
|
||||||
|
route: (typeof ROUTES)[number];
|
||||||
|
isCTA?: boolean;
|
||||||
|
onLaunch: (route: (typeof ROUTES)[number]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative flex h-full flex-col rounded-2xl border border-slate-800 bg-slate-900/60 p-6 shadow-lg backdrop-blur-md transition-all duration-300 hover:border-indigo-500/50 hover:bg-slate-800/80 hover:shadow-indigo-900/20 ${isCTA ? "min-h-[200px] items-center justify-center text-center" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative z-10 flex w-full ${isCTA ? "flex-col items-center gap-4" : "mb-4 items-start justify-between"}`}
|
||||||
|
>
|
||||||
|
<div className={isCTA ? "flex flex-col items-center" : ""}>
|
||||||
|
<h3
|
||||||
|
className={`font-bold text-indigo-100 transition-colors group-hover:text-white ${isCTA ? "text-3xl" : "text-xl"}`}
|
||||||
|
>
|
||||||
|
{t(route.nameKey, undefined, route.id)}
|
||||||
|
</h3>
|
||||||
|
<span className="mt-2 inline-block rounded-full border border-slate-800 bg-slate-950/50 px-2 py-0.5 text-xs font-medium text-slate-400">
|
||||||
|
{t(route.tagKey)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{route.durationMinutes !== 0 && (
|
||||||
|
<span
|
||||||
|
className={`font-light text-slate-300 transition-colors group-hover:text-white ${isCTA ? "mt-2 text-4xl" : "text-3xl"}`}
|
||||||
|
>
|
||||||
|
{route.durationMinutes !== 0 && route.durationMinutes}
|
||||||
|
<span className="ml-1 text-sm text-slate-500">
|
||||||
|
{route.durationMinutes !== 0 && t("common.minuteShort")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCTA && (
|
||||||
|
<p className="relative z-10 mb-8 w-full flex-1 text-left text-sm leading-relaxed text-slate-400">
|
||||||
|
{t(route.descriptionKey)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCTA && (
|
||||||
|
<p className="relative z-10 mt-2 mb-6 max-w-lg text-left text-base text-slate-400">
|
||||||
|
{t(route.descriptionKey)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onLaunch(route)}
|
||||||
|
className={`relative z-10 flex items-center justify-center rounded-xl bg-indigo-600 font-bold text-white shadow-lg shadow-indigo-900/30 transition-all hover:bg-indigo-500 active:scale-[0.98] ${isCTA ? "w-full max-w-md py-4 text-lg" : "w-full py-4"}`}
|
||||||
|
>
|
||||||
|
{isCTA ? t("lobby.cta.station") : t("lobby.cta.launch")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LobbyRoutesPanel() {
|
||||||
|
const { locale, setLocale, t } = useI18n();
|
||||||
|
useLobbyRedirect();
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
|
||||||
|
const [isBoardingOpen, setIsBoardingOpen] = useState(false);
|
||||||
|
|
||||||
|
const stationRoute = ROUTES[0];
|
||||||
|
const normalRoutes = ROUTES.slice(1);
|
||||||
|
const selectedRoute =
|
||||||
|
ROUTES.find((route) => route.id === selectedRouteId) ?? stationRoute;
|
||||||
|
|
||||||
|
const handleOpenBoarding = (route: (typeof ROUTES)[number]) => {
|
||||||
|
setSelectedRouteId(route.id);
|
||||||
|
setIsBoardingOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocking = (mission: string) => {
|
||||||
|
const started = startVoyage({
|
||||||
|
route: selectedRoute,
|
||||||
|
mission,
|
||||||
|
routeName: t(selectedRoute.nameKey, undefined, selectedRoute.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!started) return;
|
||||||
|
|
||||||
|
setIsBoardingOpen(false);
|
||||||
|
router.push("/flight");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocaleChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setLocale(event.target.value as Locale);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex min-h-[calc(100vh-80px)] w-full flex-col p-6 md:p-12 lg:min-h-screen lg:flex-row lg:items-stretch lg:gap-0 lg:p-0">
|
||||||
|
<aside className="hidden w-72 shrink-0 border-r border-slate-700/80 bg-slate-800/65 px-7 py-8 shadow-[2px_0_24px_rgba(2,6,23,0.45)] backdrop-blur lg:flex lg:min-h-screen lg:flex-col lg:justify-between">
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<h1 className="text-lg font-bold tracking-[0.14em] text-indigo-300">
|
||||||
|
FOCUSTELLA
|
||||||
|
</h1>
|
||||||
|
<nav className="flex flex-col gap-2">
|
||||||
|
<Link
|
||||||
|
href="/log"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-sm font-semibold text-slate-300 transition-colors hover:border-slate-700 hover:bg-slate-900/70 hover:text-slate-100"
|
||||||
|
>
|
||||||
|
<BookOpenText className="h-4 w-4 text-indigo-300" aria-hidden />
|
||||||
|
<span>{t("layout.nav.log")}</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-sm font-semibold text-slate-300 transition-colors hover:border-slate-700 hover:bg-slate-900/70 hover:text-slate-100"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 text-indigo-300" aria-hidden />
|
||||||
|
<span>{t("layout.nav.settings")}</span>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center justify-between gap-3 rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2 text-xs text-slate-300">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Languages className="h-4 w-4 text-indigo-300" aria-hidden />
|
||||||
|
{t("layout.nav.language")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={locale}
|
||||||
|
onChange={handleLocaleChange}
|
||||||
|
className="rounded border border-slate-700 bg-slate-900/80 px-2 py-1 text-xs text-slate-200 outline-none transition-colors focus:border-indigo-400"
|
||||||
|
>
|
||||||
|
{SUPPORTED_LOCALES.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{LOCALE_LABELS[item]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="w-full flex-1 lg:min-h-screen lg:bg-slate-950/68 lg:px-10 lg:pt-10 lg:shadow-[inset_0_1px_0_rgba(148,163,184,0.06)]">
|
||||||
|
<div className="mx-auto mb-12 max-w-2xl space-y-4 rounded-3xl border border-slate-800/40 bg-slate-950/35 p-6 text-center backdrop-blur-sm">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-100 md:text-5xl">
|
||||||
|
{t("lobby.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-slate-300">{t("lobby.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 pb-8">
|
||||||
|
<div className="w-full">
|
||||||
|
<RouteCard
|
||||||
|
route={stationRoute}
|
||||||
|
isCTA={true}
|
||||||
|
onLaunch={handleOpenBoarding}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid w-full grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
{normalRoutes.map((route) => (
|
||||||
|
<RouteCard
|
||||||
|
key={route.id}
|
||||||
|
route={route}
|
||||||
|
onLaunch={handleOpenBoarding}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isBoardingOpen} onOpenChange={setIsBoardingOpen}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-xl border-slate-800 bg-slate-950 text-slate-100"
|
||||||
|
showCloseButton={true}
|
||||||
|
>
|
||||||
|
<DialogHeader className="mb-2">
|
||||||
|
<h2 className="mb-1 text-sm font-semibold uppercase tracking-widest text-indigo-400">
|
||||||
|
{t("lobby.modal.boardingCheck")}
|
||||||
|
</h2>
|
||||||
|
<DialogTitle className="text-2xl font-bold text-white">
|
||||||
|
{t("lobby.modal.routeBoarding", {
|
||||||
|
routeName: t(selectedRoute.nameKey, undefined, selectedRoute.id),
|
||||||
|
})}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
{t("lobby.modal.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<BoardingMissionForm
|
||||||
|
onDock={handleDocking}
|
||||||
|
onCancel={() => setIsBoardingOpen(false)}
|
||||||
|
autoFocus={true}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user