feat(space/app): app 진입부 및 space 몰입 환경(HUD/Tools) 프리미엄 UI 리팩토링
맥락: - 기존 app 대시보드와 space 화면의 UI가 SaaS 툴처럼 딱딱하고 투박하여, 유저가 기꺼이 지갑을 열 만한 몰입감과 고급스러움(Premium feel)이 부족함. - 인지적 과부하를 줄이기 위해 제안된 '첫 5분 행동(Micro-step)'이 타이머 영역에 묻혀 있어 행동 유발 효과가 미미함. 변경사항: - app: 컨테이너 박스를 제거하고 전체 배경 화면(Immersive Background)과 Glassmorphism을 활용한 1.5 Step 진입 플로우로 전면 개편. - space/hud: 하단의 두꺼운 타이머 패널을 초박형(Slim) 글라스 알약 형태로 축소하여 배경 씬의 개방감 확보. - space/hud: 목표(Goal)와 첫 단계(Micro-step)를 분리하여 좌측 상단의 우아한 Floating UI로 재배치하고, 체크 완료 시 사라지는 도파민 인터랙션 추가. - space/tools: 흩어져 있던 노트, 사운드, 설정 도구들을 우측 레일(Right-Rail)로 통합하고 팝오버 디자인을 고급화함. - ui/contrast: 밝은 배경에서도 텍스트가 잘 보이도록 좌측 상단 비네팅(Vignette) 및 다중 텍스트 그림자(Multi-layered Shadow) 효과 적용. 검증: - npm run build 정상 통과 확인. - 브라우저 상에서 micro-step 완료 애니메이션 및 도구막대 팝오버 슬라이드 동작 확인. 세션-상태: app 진입부터 space 몰입까지의 코어 UX/UI 하이엔드 개편 완료. 세션-다음: 프로 요금제(PRO) 전환 유도(Paywall) 흐름 및 상세 분석 리포트(Analytics) 뷰 구현. 세션-리스크: 없음.
This commit is contained in:
@@ -9,7 +9,6 @@ src/
|
||||
features/ # 사용자 액션/유즈케이스 단위
|
||||
entities/ # 도메인 타입/더미 데이터
|
||||
shared/ # 공용 UI/유틸
|
||||
store/ # 전역 상태(필요 최소)
|
||||
```
|
||||
|
||||
## 핵심 규칙
|
||||
@@ -19,6 +18,7 @@ src/
|
||||
3. UI 상태(토글/선택)만 컴포넌트 내부에서 최소 허용한다.
|
||||
4. 파일 길이 500줄 이상이면 즉시 분리한다.
|
||||
5. 하위 레이어가 상위 레이어를 import하지 않는다.
|
||||
6. 전역 상태가 필요하면 먼저 해당 도메인 slice의 `model/` 안에 둔다.
|
||||
|
||||
## Import 방향 규칙
|
||||
|
||||
@@ -33,12 +33,14 @@ src/
|
||||
- `features` -> 다른 `features` 직접 참조 (강한 결합 유발)
|
||||
- `shared` -> `entities/features/widgets/app` 참조
|
||||
- `page.tsx`에 도메인 로직/세부 UI 구현 누적
|
||||
- 루트 전역 저장소를 관성적으로 추가하는 것
|
||||
|
||||
## 구현 정책 (이 프로젝트 전용)
|
||||
|
||||
- 실제 타이머/오디오/서버/DB 기능은 구현하지 않는다.
|
||||
- 기능 트리거는 토스트 또는 더미 상태 전환으로 표현한다.
|
||||
- 도메인 표시는 `entities` 데이터에서 읽고 뷰 하드코딩을 지양한다.
|
||||
- 인증/세션 같은 전역 상태도 가능하면 해당 도메인 `entities/*/model` 안에서 관리한다.
|
||||
|
||||
## 파일 분리 기준
|
||||
|
||||
|
||||
61
docs/08_app_reframe_strategy.md
Normal file
61
docs/08_app_reframe_strategy.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# VibeRoom `/app` 프로덕트 고도화 및 수익화 기획안
|
||||
|
||||
## 1. 문제 정의: 왜 `/app`의 존재 의의가 애매해졌는가?
|
||||
현재 `/app` 화면은 단순히 '목표를 하나 입력하고 `/space`로 넘어가는 정거장' 역할만 수행하고 있습니다.
|
||||
사용자 입장에서는 "어차피 `/space`에서 타이머 돌리고 일할 건데, 굳이 `/app`이라는 별도 화면에서 목표 하나만 달랑 쳐야 하나?"라는 기능적 회의감이 들 수밖에 없습니다. 즉, **경험적 가치(UX Value) 없이 Depth만 하나 늘어난 상태**입니다.
|
||||
|
||||
## 2. 타겟 인사이트: ADHD & 프리랜서의 심리
|
||||
이 서비스의 핵심 타겟은 **ADHD 성향을 가진 사람들과 혼자 일하는 프리랜서**입니다. 이들의 가장 큰 페인포인트는 다음과 같습니다.
|
||||
1. **시작의 두려움 (High Activation Energy):** '무엇을 해야 할지'는 알지만, 첫 단추를 꿰는 것을 극도로 미룹니다.
|
||||
2. **작업 기억 용량 초과 (RAM Overload):** 머릿속에 해야 할 일과 잡념이 뒤엉켜 있어 하나에 집중하지 못합니다.
|
||||
3. **도파민 갈구 (Dopamine Seeking):** 즉각적인 피드백이나 보상이 없으면 쉽게 지루해하고 이탈합니다.
|
||||
|
||||
## 3. 핵심 전략: `/app`을 '단순 입력창'에서 **'몰입을 위한 의식(Ritual)의 공간'**으로 재정의
|
||||
최고의 서비스는 사용자에게 '행동'을 요구하기 전에 '감정'을 만져줍니다. `/app`은 투두리스트(To-do)를 적는 곳이 아닙니다. **복잡한 현실에서 벗어나, 고도의 집중 상태(`/space`)로 들어가기 전 마음을 다잡고 준비 운동을 하는 '감압실(Decompression Chamber)'**이 되어야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. `/app` 공간의 4가지 핵심 기능 기획
|
||||
|
||||
### A. Brain Dump (뇌 비우기) & The One Thing
|
||||
ADHD 유저에게 "지금 할 목표 하나만 적으세요"라고 하면 오히려 압박감을 느낍니다.
|
||||
- **기능:** 머릿속에 맴도는 모든 잡념과 해야 할 일들을 일단 마구잡이로 쏟아내게 합니다(Brain Dump).
|
||||
- **UX:** 쏟아낸 여러 항목 중 **"지금 당장 딱 하나만 쥐고 `/space`로 들어갑시다. 나머지는 저희가 안전하게 보관해 둘게요"**라며 단 하나의 목표(The One Thing)를 선택하게 유도합니다.
|
||||
- **효과:** 머릿속 RAM을 비워줌으로써 인지적 과부하를 해결하고 안도감을 줍니다.
|
||||
|
||||
### B. Micro-Stepping (초소형 첫 단계 쪼개기)
|
||||
"제안서 작성하기"라는 목표는 너무 거대해서 시작을 미루게 만듭니다.
|
||||
- **기능:** 유저가 큰 목표를 선택했을 때, "이 목표를 위해 지금 당장 할 수 있는 5분짜리 행동은 무엇인가요?"라고 묻습니다.
|
||||
- **UX:** "제안서 폴더 열기", "노션 새 페이지 만들기" 수준으로 목표를 극단적으로 잘게 쪼개도록 유도합니다.
|
||||
- **효과:** 시작의 허들(Activation Energy)을 바닥까지 낮춰줍니다.
|
||||
|
||||
### C. Commitment Ritual (몰입 세팅과 선언)
|
||||
`/space`에 들어가서 환경을 바꾸는 것이 아니라, `/app`에서 오늘 나를 도와줄 환경을 선택하고 '입장'하는 것이 하나의 의식이 되어야 합니다.
|
||||
- **기능:** 목표를 정한 후, 오늘 나의 기분에 맞는 **Vibe(배경 씬 + 백색소음)**와 **Pace(뽀모도로 25분 / 딥워크 50분 등)**를 선택합니다.
|
||||
- **UX:** 세팅을 마치고 "집중 모드 진입하기" 버튼을 누를 때, 시각적/청각적인 전환 효과와 함께 비행기가 이륙하듯 `/space`로 부드럽게 빨려 들어가는 연출을 줍니다.
|
||||
|
||||
### D. Momentum & Streak (과거의 작은 성공 시각화)
|
||||
- **기능:** `/app` 화면 한 켠에, 최근에 내가 해냈던 작은 몰입 세션들의 기록(잔디 심기, 자라나는 식물 등)을 시각적이고 감성적으로 보여줍니다.
|
||||
- **효과:** "어제도 해냈으니 오늘도 할 수 있다"는 자기 효능감을 심어주어 도파민을 자극합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 비즈니스 모델(BM): 기꺼이 돈을 내게 만드는 수익화 전략
|
||||
|
||||
유저가 제품에 깊이 몰입하고 나면, 다음과 같은 **PRO 플랜(구독 모델)**을 통해 수익화를 달성할 수 있습니다.
|
||||
|
||||
1. **AI Micro-Stepping Coach (AI 목표 쪼개기 및 코칭)**
|
||||
- **무료:** 유저가 직접 첫 단계를 고민해서 적어야 함.
|
||||
- **PRO:** "기획서 쓰기"라고 치면, AI가 [1. 레퍼런스 3개 찾기, 2. 목차 5개 적기, 3. 서론 쓰기] 등으로 즉각 분해해주고 응원의 메시지를 건넴. (ADHD 유저에게 압도적인 가치 제공)
|
||||
2. **Premium Vibes (독점 씬 & 사운드스케이프)**
|
||||
- **무료:** 기본 자연음(숲, 비투비) 2~3종 제공.
|
||||
- **PRO:** 유명 아티스트가 작곡한 Lo-Fi 집중 비트, 세계 유명 도서관/카페의 3D 공간 음향, 초고화질 동적 배경(비 오는 도쿄 야경 등) 무제한 접근.
|
||||
3. **Deep Focus Analytics (심층 분석 리포트)**
|
||||
- **무료:** 오늘 하루 총 집중 시간(단순 타이머 기록)만 확인 가능.
|
||||
- **PRO:** 웹 서비스의 한계를 극복하기 위해 **세션 종료 후 유저가 직접 평가하는 '집중도 점수(1~5점)'와 Page Visibility API를 활용한 '탭 이탈(딴짓) 횟수 기록'을 결합**합니다. 이를 통해 단순히 켜둔 시간이 아니라 어떤 사운드/환경에서 가장 '질 높은' 집중을 했는지, 요일별 언제가 집중 Peak Time인지 분석하여 나만의 최적화된 루틴을 제안합니다.
|
||||
4. **Calendar & Notion Integration (업무 툴 연동)**
|
||||
- **PRO:** 구글 캘린더의 일정이나 노션의 Task를 `/app`의 Brain Dump로 자동 불러오고, 집중 세션이 끝나면 소요 시간과 함께 자동으로 기록/동기화됨. (프리랜서들의 필수 니즈)
|
||||
- **기술적 실현 가능성 (Feasibility Check):** 웹 서비스 환경에서도 **OAuth 2.0 기반의 Google Calendar API 및 Notion API**를 연동하여 완벽히 구현 가능합니다. 서버에서 유저의 접근 권한 토큰을 관리하여 웹 클라이언트와 외부 서비스 간의 양방향 데이터 동기화를 지원할 수 있습니다.
|
||||
|
||||
## 요약 (Next Action)
|
||||
`/app`은 단순히 데이터를 넘기는 껍데기가 아니라, **유저의 마음을 달래고(Brain Dump), 허들을 낮춰주며(Micro-Stepping), 몰입을 세팅(Ritual)하는 심리적 안전기지**가 되어야 합니다. 이 경험이 탄탄해지면 유저는 VibeRoom을 단순한 타이머가 아닌 '나의 루틴 파트너'로 인식하게 되며, AI 코칭과 프리미엄 환경을 위해 기꺼이 지갑을 열게 될 것입니다.
|
||||
@@ -1 +1,2 @@
|
||||
export * from './model/types';
|
||||
export * from './model/useAuthStore';
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import Cookies from 'js-cookie';
|
||||
import type { AuthResponse } from '@/entities/auth';
|
||||
import { create } from 'zustand';
|
||||
import { REFRESH_TOKEN_COOKIE_KEY, TOKEN_COOKIE_KEY } from '@/shared/config/authTokens';
|
||||
import type { AuthResponse } from './types';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
user: AuthResponse['user'] | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
// 액션
|
||||
setAuth: (data: AuthResponse) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* VibeRoom 전역 인증(Auth) 상태 저장소
|
||||
*/
|
||||
export const useAuthStore = create<AuthState>((set) => {
|
||||
const isClient = typeof window !== 'undefined';
|
||||
const savedToken = isClient ? Cookies.get(TOKEN_COOKIE_KEY) : null;
|
||||
@@ -23,36 +18,31 @@ export const useAuthStore = create<AuthState>((set) => {
|
||||
return {
|
||||
accessToken: savedToken || null,
|
||||
user: null,
|
||||
isAuthenticated: !!savedToken,
|
||||
|
||||
isAuthenticated: Boolean(savedToken),
|
||||
setAuth: (data: AuthResponse) => {
|
||||
const cookieOptions = {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict' as const
|
||||
sameSite: 'strict' as const,
|
||||
};
|
||||
|
||||
// 1. Access Token 저장 (7일)
|
||||
Cookies.set(TOKEN_COOKIE_KEY, data.accessToken, {
|
||||
...cookieOptions,
|
||||
expires: 7
|
||||
expires: 7,
|
||||
});
|
||||
|
||||
// 2. Refresh Token 저장 (30일)
|
||||
if (data.refreshToken) {
|
||||
Cookies.set(REFRESH_TOKEN_COOKIE_KEY, data.refreshToken, {
|
||||
...cookieOptions,
|
||||
expires: 30
|
||||
expires: 30,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 상태 업데이트
|
||||
set({
|
||||
accessToken: data.accessToken,
|
||||
user: data.user,
|
||||
isAuthenticated: true
|
||||
user: data.user ?? null,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
Cookies.remove(TOKEN_COOKIE_KEY);
|
||||
Cookies.remove(REFRESH_TOKEN_COOKIE_KEY);
|
||||
@@ -60,7 +50,7 @@ export const useAuthStore = create<AuthState>((set) => {
|
||||
set({
|
||||
accessToken: null,
|
||||
user: null,
|
||||
isAuthenticated: false
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -73,11 +73,19 @@ export const useMediaCatalog = () => {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
void readMediaManifest(controller.signal).then((result) => {
|
||||
readMediaManifest(controller.signal)
|
||||
.then((result) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setManifest(result.manifest);
|
||||
setError(result.error);
|
||||
setUsedFallbackManifest(result.usedFallbackManifest);
|
||||
setHasResolvedManifest(true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== 'AbortError' && !controller.signal.aborted) {
|
||||
console.error('Failed to load media manifest:', err);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface PlanLockedPack {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type ProFeatureId = 'scene-packs' | 'sound-packs' | 'profiles';
|
||||
export type ProFeatureId = 'daily-plan' | 'rituals' | 'weekly-review';
|
||||
|
||||
export interface ProFeatureCard {
|
||||
id: ProFeatureId;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './model/mockSession';
|
||||
export * from './model/focusSystem';
|
||||
export * from './model/types';
|
||||
export * from './model/useThoughtInbox';
|
||||
|
||||
306
src/entities/session/model/focusSystem.ts
Normal file
306
src/entities/session/model/focusSystem.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
export interface FocusPlanItem {
|
||||
id: string;
|
||||
title: string;
|
||||
goal: string;
|
||||
blockLabel: string;
|
||||
ritualLabel: string;
|
||||
energyLabel: string;
|
||||
successSignal: string;
|
||||
sceneId: string;
|
||||
soundPresetId: string;
|
||||
timerLabel: string;
|
||||
proOnly?: boolean;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
goalPrompt: string;
|
||||
cadenceLabel: string;
|
||||
notificationTone: string;
|
||||
sceneId: string;
|
||||
soundPresetId: string;
|
||||
timerLabel: string;
|
||||
proOnly?: boolean;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionOutcome {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
nextSuggestion: string;
|
||||
}
|
||||
|
||||
export interface WeeklyReviewMetric {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
delta: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface WeeklyReviewInsight {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
description: string;
|
||||
proOnly?: boolean;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface WeeklyReview {
|
||||
headline: string;
|
||||
summary: string;
|
||||
metrics: WeeklyReviewMetric[];
|
||||
insights: WeeklyReviewInsight[];
|
||||
}
|
||||
|
||||
export interface AsyncCheckIn {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
message: string;
|
||||
timeLabel: string;
|
||||
reactionSummary: string;
|
||||
proOnly?: boolean;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
const FOCUS_PLAN_ITEMS_BASE: FocusPlanItem[] = [
|
||||
{
|
||||
id: 'design-qa',
|
||||
title: '디자인 QA 요청 3개 정리',
|
||||
goal: '디자인 QA 요청 3개 우선순위 정리',
|
||||
blockLabel: '15분 triage + 10분 결정',
|
||||
ritualLabel: 'Soft Landing 25/5',
|
||||
energyLabel: '낮은 진입 장벽',
|
||||
successSignal: '버릴 것 1개, 오늘 처리 1개만 확정',
|
||||
sceneId: 'quiet-library',
|
||||
soundPresetId: 'deep-white',
|
||||
timerLabel: '25/5',
|
||||
},
|
||||
{
|
||||
id: 'proposal-intro',
|
||||
title: '제안서 첫 문단 다듬기',
|
||||
goal: '제안서 첫 문단 다듬기',
|
||||
blockLabel: '30분 초안 + 20분 정리',
|
||||
ritualLabel: 'Rain Draft 50/10',
|
||||
energyLabel: '깊은 사고',
|
||||
successSignal: '첫 문단을 소리 내어 읽었을 때 어색함이 없어질 것',
|
||||
sceneId: 'rain-window',
|
||||
soundPresetId: 'rain-focus',
|
||||
timerLabel: '50/10',
|
||||
proOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'ship-one-function',
|
||||
title: '핵심 함수 1개 마무리',
|
||||
goal: '핵심 함수 1개 마무리',
|
||||
blockLabel: '40분 구현 + 10분 정리',
|
||||
ritualLabel: 'Forest Ship 50/10',
|
||||
energyLabel: '중간 몰입',
|
||||
successSignal: '리뷰 전에 스스로 설명 가능한 상태 만들기',
|
||||
sceneId: 'forest',
|
||||
soundPresetId: 'forest-birds',
|
||||
timerLabel: '50/10',
|
||||
proOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
const SESSION_TEMPLATES_BASE: SessionTemplate[] = [
|
||||
{
|
||||
id: 'soft-landing',
|
||||
name: 'Soft Landing',
|
||||
description: '몸을 풀듯 천천히 진입하는 시작 ritual',
|
||||
goalPrompt: '메일 3개 또는 작은 정리 1개',
|
||||
cadenceLabel: '25/5 · 낮은 압박',
|
||||
notificationTone: '조용함',
|
||||
sceneId: 'sun-window',
|
||||
soundPresetId: 'rain-focus',
|
||||
timerLabel: '25/5',
|
||||
},
|
||||
{
|
||||
id: 'rain-draft',
|
||||
name: 'Rain Draft',
|
||||
description: '문서, 글쓰기, 제안서 초안용 깊은 진입 ritual',
|
||||
goalPrompt: '문단 1개 또는 초안 1개',
|
||||
cadenceLabel: '50/10 · 깊은 몰입',
|
||||
notificationTone: '기본',
|
||||
sceneId: 'rain-window',
|
||||
soundPresetId: 'rain-focus',
|
||||
timerLabel: '50/10',
|
||||
proOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'forest-ship',
|
||||
name: 'Forest Ship',
|
||||
description: '코드, QA, 실행 정리용 차분한 마감 ritual',
|
||||
goalPrompt: '함수 1개 또는 리뷰 2개',
|
||||
cadenceLabel: '50/10 · 차분한 추진',
|
||||
notificationTone: '강함',
|
||||
sceneId: 'forest',
|
||||
soundPresetId: 'forest-birds',
|
||||
timerLabel: '50/10',
|
||||
proOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SESSION_OUTCOMES: SessionOutcome[] = [
|
||||
{
|
||||
id: 'carry-forward',
|
||||
title: '다음 한 조각으로 이어가기',
|
||||
description: '세션이 끝난 뒤 바로 다음 블록을 붙여서 흐름을 지킵니다.',
|
||||
nextSuggestion: '방금 한 작업의 다음 문단 1개',
|
||||
},
|
||||
{
|
||||
id: 'promote-inbox',
|
||||
title: '주차한 생각 끌어올리기',
|
||||
description: 'distraction dump에 넣어둔 항목을 오늘 큐로 승격합니다.',
|
||||
nextSuggestion: '인박스에서 가장 가벼운 메모 1개',
|
||||
},
|
||||
{
|
||||
id: 'reset-with-smaller-goal',
|
||||
title: '더 작게 다시 시작하기',
|
||||
description: '무너진 날에는 범위를 절반으로 줄여 다시 시작합니다.',
|
||||
nextSuggestion: '5분 안에 끝낼 수 있는 아주 작은 조각',
|
||||
},
|
||||
];
|
||||
|
||||
export const CALM_WEEKLY_REVIEW: WeeklyReview = {
|
||||
headline: '이번 주는 짧은 진입 ritual이 시작 성공률을 끌어올렸어요.',
|
||||
summary:
|
||||
'길게 버티는 것보다, 짧게 시작하고 이어가는 패턴이 가장 안정적이었어요. 점심 직전에는 목표를 더 작게 쪼개는 편이 좋았습니다.',
|
||||
metrics: [
|
||||
{ id: 'start-success', label: '시작 성공률', value: '78%', delta: '+12%' },
|
||||
{ id: 'completion-rate', label: '완료율', value: '64%', delta: '+9%' },
|
||||
{ id: 'recovery-rate', label: '중단 후 복귀율', value: '71%', delta: '+6%', locked: true },
|
||||
{ id: 'ritual-fit', label: 'ritual 적합도', value: 'Soft Landing', delta: '가장 안정적', locked: true },
|
||||
],
|
||||
insights: [
|
||||
{
|
||||
id: 'fragile-window',
|
||||
label: '흔들리는 시간대',
|
||||
value: '11:30 - 12:30',
|
||||
description: '점심 직전엔 25/5와 작은 목표가 더 잘 맞았어요.',
|
||||
},
|
||||
{
|
||||
id: 'best-window',
|
||||
label: '잘 풀리는 시간대',
|
||||
value: '14:00 - 16:00',
|
||||
description: '오후 초반엔 50/10 깊은 몰입 블록이 유지됐어요.',
|
||||
},
|
||||
{
|
||||
id: 'best-ritual',
|
||||
label: '가장 잘 맞는 ritual',
|
||||
value: 'Soft Landing',
|
||||
description: '짧은 준비와 낮은 압박이 시작 지연을 줄였습니다.',
|
||||
proOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'recovery-cue',
|
||||
label: '복귀 신호',
|
||||
value: '문장 1개만 고치기',
|
||||
description: '큰 목표보다 작은 복귀 문장이 재시작을 쉽게 만들었어요.',
|
||||
proOnly: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ASYNC_CHECK_INS_BASE: AsyncCheckIn[] = [
|
||||
{
|
||||
id: 'buddy-mina',
|
||||
name: 'Mina',
|
||||
role: '브랜드 디자이너',
|
||||
status: '오늘은 천천히',
|
||||
message: '오후에는 QA만 정리하고 끝내려 해요.',
|
||||
timeLabel: '12분 전',
|
||||
reactionSummary: '👍 3 · 🫶 1',
|
||||
},
|
||||
{
|
||||
id: 'buddy-jisoo',
|
||||
name: 'Jisoo',
|
||||
role: '프리랜서 라이터',
|
||||
status: '25분만 달릴게요',
|
||||
message: '첫 문단만 정리해두면 오늘은 충분해요.',
|
||||
timeLabel: '31분 전',
|
||||
reactionSummary: '👏 2 · 🔥 1',
|
||||
},
|
||||
{
|
||||
id: 'digest-repeat-buddy',
|
||||
name: '반복 파트너 digest',
|
||||
role: 'PRO',
|
||||
status: '이번 주 리듬 요약',
|
||||
message: '같은 시간대에 서로 시작한 날이 4일이었어요.',
|
||||
timeLabel: '금요일 오전',
|
||||
reactionSummary: '주간 digest',
|
||||
proOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
const markLocked = <T extends { proOnly?: boolean }>(items: T[], isPro: boolean) => {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
locked: Boolean(!isPro && item.proOnly),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getFocusPlanItems = (isPro: boolean) => {
|
||||
return markLocked(FOCUS_PLAN_ITEMS_BASE, isPro);
|
||||
};
|
||||
|
||||
export const getSessionTemplates = (isPro: boolean) => {
|
||||
return markLocked(SESSION_TEMPLATES_BASE, isPro);
|
||||
};
|
||||
|
||||
export const getWeeklyReview = (isPro: boolean): WeeklyReview => {
|
||||
return {
|
||||
...CALM_WEEKLY_REVIEW,
|
||||
metrics: CALM_WEEKLY_REVIEW.metrics.map((metric) => ({
|
||||
...metric,
|
||||
locked: Boolean(!isPro && metric.locked),
|
||||
})),
|
||||
insights: markLocked(CALM_WEEKLY_REVIEW.insights, isPro),
|
||||
};
|
||||
};
|
||||
|
||||
export const getAsyncCheckIns = (isPro: boolean) => {
|
||||
return markLocked(ASYNC_CHECK_INS_BASE, isPro);
|
||||
};
|
||||
|
||||
export const buildSessionStartHref = (params: {
|
||||
goal: string;
|
||||
sceneId: string;
|
||||
soundPresetId: string;
|
||||
timerLabel: string;
|
||||
}) => {
|
||||
const query = new URLSearchParams({
|
||||
goal: params.goal,
|
||||
scene: params.sceneId,
|
||||
sound: params.soundPresetId,
|
||||
timer: params.timerLabel,
|
||||
});
|
||||
|
||||
return `/space?${query.toString()}`;
|
||||
};
|
||||
|
||||
export const getFocusPlanStartHref = (item: FocusPlanItem) => {
|
||||
return buildSessionStartHref({
|
||||
goal: item.goal,
|
||||
sceneId: item.sceneId,
|
||||
soundPresetId: item.soundPresetId,
|
||||
timerLabel: item.timerLabel,
|
||||
});
|
||||
};
|
||||
|
||||
export const getSessionTemplateStartHref = (template: SessionTemplate) => {
|
||||
return buildSessionStartHref({
|
||||
goal: template.goalPrompt,
|
||||
sceneId: template.sceneId,
|
||||
soundPresetId: template.soundPresetId,
|
||||
timerLabel: template.timerLabel,
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthResponse } from '@/features/auth/types';
|
||||
import type { AuthResponse } from '@/entities/auth';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { TOKEN_COOKIE_KEY } from "@/shared/config/authTokens";
|
||||
import { Button, type ButtonSize, type ButtonVariant } from "@/shared/ui/Button";
|
||||
import { useAuthStore } from "@/store/useAuthStore";
|
||||
import Cookies from 'js-cookie';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useAuthStore } from '@/entities/auth';
|
||||
import { TOKEN_COOKIE_KEY } from '@/shared/config/authTokens';
|
||||
import { Button, type ButtonSize, type ButtonVariant } from '@/shared/ui/Button';
|
||||
|
||||
interface AuthRedirectButtonProps {
|
||||
children: ReactNode;
|
||||
@@ -19,10 +19,10 @@ interface AuthRedirectButtonProps {
|
||||
export function AuthRedirectButton({
|
||||
children,
|
||||
className,
|
||||
size = "md",
|
||||
variant = "primary",
|
||||
authenticatedHref = "/space",
|
||||
unauthenticatedHref = "/login",
|
||||
size = 'md',
|
||||
variant = 'primary',
|
||||
authenticatedHref = '/space',
|
||||
unauthenticatedHref = '/login',
|
||||
}: AuthRedirectButtonProps) {
|
||||
const router = useRouter();
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useGoogleLogin } from '@react-oauth/google';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import appleAuthHelpers from 'react-apple-signin-auth';
|
||||
import { useAuthStore } from '@/entities/auth';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
import { authApi } from '../api/authApi';
|
||||
|
||||
interface AppleSignInResponse {
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
export interface SocialLoginRequest {
|
||||
provider: "google" | "apple" | "facebook";
|
||||
token: string; // 소셜 프로바이더로부터 발급받은 id_token 또는 access_token
|
||||
}
|
||||
|
||||
export interface PasswordLoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
accessToken: string; // VibeRoom 전용 JWT (API 요청 시 사용)
|
||||
refreshToken: string; // 토큰 갱신용
|
||||
user?: UserMeResponse; // 선택적으로 유저 정보를 포함할 수 있음
|
||||
}
|
||||
|
||||
export interface UserMeResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
grade: string;
|
||||
}
|
||||
export type {
|
||||
AuthResponse,
|
||||
PasswordLoginRequest,
|
||||
SocialLoginRequest,
|
||||
UserMeResponse,
|
||||
} from '@/entities/auth';
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
import { normalizeFocusPlanToday, type FocusPlanToday } from '@/entities/focus-plan';
|
||||
import { apiClient } from '@/shared/lib/apiClient';
|
||||
|
||||
export type FocusSessionPhase = 'focus' | 'break';
|
||||
export type FocusSessionState = 'running' | 'paused';
|
||||
export type FocusSessionCompletionType = 'goal-complete' | 'timer-complete';
|
||||
|
||||
interface RawFocusSession {
|
||||
id: number | string;
|
||||
sceneId: string;
|
||||
goal: string;
|
||||
timerPresetId: string;
|
||||
soundPresetId: string | null;
|
||||
focusPlanItemId?: number | null;
|
||||
microStep?: string | null;
|
||||
phase: FocusSessionPhase;
|
||||
state: FocusSessionState;
|
||||
phaseStartedAt: string;
|
||||
phaseEndsAt: string | null;
|
||||
phaseRemainingSeconds: number;
|
||||
focusDurationSeconds: number;
|
||||
breakDurationSeconds: number;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
abandonedAt: string | null;
|
||||
serverNow: string;
|
||||
}
|
||||
|
||||
interface RawAdvanceCurrentGoalResponse {
|
||||
nextSession: RawFocusSession;
|
||||
updatedPlanToday: Parameters<typeof normalizeFocusPlanToday>[0];
|
||||
}
|
||||
|
||||
export interface FocusSession {
|
||||
id: string;
|
||||
sceneId: string;
|
||||
goal: string;
|
||||
timerPresetId: string;
|
||||
soundPresetId: string | null;
|
||||
focusPlanItemId?: string | null;
|
||||
microStep?: string | null;
|
||||
phase: FocusSessionPhase;
|
||||
state: FocusSessionState;
|
||||
phaseStartedAt: string;
|
||||
@@ -28,12 +57,16 @@ export interface StartFocusSessionRequest {
|
||||
goal: string;
|
||||
timerPresetId: string;
|
||||
soundPresetId?: string | null;
|
||||
focusPlanItemId?: string;
|
||||
microStep?: string | null;
|
||||
entryPoint?: 'space-setup' | 'goal-complete' | 'resume-restore';
|
||||
}
|
||||
|
||||
export interface CompleteFocusSessionRequest {
|
||||
completionType: FocusSessionCompletionType;
|
||||
completedGoal?: string;
|
||||
focusScore?: number;
|
||||
distractionCount?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCurrentFocusSessionSelectionRequest {
|
||||
@@ -41,108 +74,126 @@ export interface UpdateCurrentFocusSessionSelectionRequest {
|
||||
soundPresetId?: string | null;
|
||||
}
|
||||
|
||||
export interface AdvanceCurrentGoalRequest {
|
||||
completedGoal: string;
|
||||
nextGoal: string;
|
||||
sceneId: string;
|
||||
timerPresetId: string;
|
||||
soundPresetId?: string | null;
|
||||
focusPlanItemId?: string;
|
||||
}
|
||||
|
||||
export interface AdvanceCurrentGoalResponse {
|
||||
nextSession: FocusSession;
|
||||
updatedPlanToday: FocusPlanToday;
|
||||
}
|
||||
|
||||
const toNumericId = (value: string | undefined) => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
const normalizeFocusSession = (session: RawFocusSession): FocusSession => {
|
||||
return {
|
||||
...session,
|
||||
id: String(session.id),
|
||||
focusPlanItemId: session.focusPlanItemId == null ? null : String(session.focusPlanItemId),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAdvanceGoalResponse = (
|
||||
response: RawAdvanceCurrentGoalResponse,
|
||||
): AdvanceCurrentGoalResponse => {
|
||||
return {
|
||||
nextSession: normalizeFocusSession(response.nextSession),
|
||||
updatedPlanToday: normalizeFocusPlanToday(response.updatedPlanToday),
|
||||
};
|
||||
};
|
||||
|
||||
export const focusSessionApi = {
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 로그인한 사용자의 진행 중 세션을 0개 또는 1개 반환한다.
|
||||
* - 응답에는 serverNow, phaseStartedAt, phaseEndsAt, phaseRemainingSeconds를 포함해
|
||||
* 클라이언트가 남은 시간을 안정적으로 계산할 수 있게 한다.
|
||||
* - 진행 중 세션이 없으면 `data: null`을 반환한다.
|
||||
*/
|
||||
getCurrentSession: async (): Promise<FocusSession | null> => {
|
||||
return apiClient<FocusSession | null>('api/v1/focus-sessions/current', {
|
||||
const response = await apiClient<RawFocusSession | null>('api/v1/focus-sessions/current', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return response ? normalizeFocusSession(response) : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 새로운 집중 세션을 생성하고 즉시 running 상태로 시작한다.
|
||||
* - sceneId, goal, timerPresetId, soundPresetId를 저장한다.
|
||||
* - 응답에는 생성 직후의 세션 상태와 남은 시간 계산에 필요한 시각 필드를 포함한다.
|
||||
*/
|
||||
startSession: async (payload: StartFocusSessionRequest): Promise<FocusSession> => {
|
||||
return apiClient<FocusSession>('api/v1/focus-sessions', {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
focusPlanItemId: toNumericId(payload.focusPlanItemId),
|
||||
}),
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 현재 세션의 현재 phase를 일시정지한다.
|
||||
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
|
||||
* - phaseRemainingSeconds를 정확히 저장해 재개 시 이어서 동작하게 한다.
|
||||
* - 이미 paused 상태여도 멱등적으로 최신 세션 상태를 반환한다.
|
||||
*/
|
||||
pauseSession: async (): Promise<FocusSession> => {
|
||||
return apiClient<FocusSession>('api/v1/focus-sessions/current/pause', {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/pause', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 일시정지된 세션을 재개하고 새 phaseEndsAt/serverNow를 반환한다.
|
||||
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
|
||||
* - 이미 running 상태여도 멱등적으로 최신 세션 상태를 반환한다.
|
||||
* - 남은 시간을 다시 계산할 수 있게 phaseRemainingSeconds도 함께 내려준다.
|
||||
*/
|
||||
resumeSession: async (): Promise<FocusSession> => {
|
||||
return apiClient<FocusSession>('api/v1/focus-sessions/current/resume', {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/resume', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 현재 진행 중인 phase를 처음 길이로 다시 시작한다.
|
||||
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
|
||||
* - focus/break 어느 phase인지 유지한 채 phaseStartedAt, phaseEndsAt, phaseRemainingSeconds를 갱신한다.
|
||||
* - 클라이언트의 Reset 버튼은 이 API 응답으로 즉시 HUD를 다시 그린다.
|
||||
*/
|
||||
restartCurrentPhase: async (): Promise<FocusSession> => {
|
||||
return apiClient<FocusSession>('api/v1/focus-sessions/current/restart-phase', {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/restart-phase', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - Space 우측 패널에서 scene/sound를 바꿨을 때 현재 활성 세션의 선택값을 patch 방식으로 갱신한다.
|
||||
* - sceneId, soundPresetId 중 일부만 보내도 된다.
|
||||
* - 응답은 갱신 후 최신 current session 스냅샷을 반환한다.
|
||||
*/
|
||||
updateCurrentSelection: async (
|
||||
payload: UpdateCurrentFocusSessionSelectionRequest,
|
||||
): Promise<FocusSession> => {
|
||||
return apiClient<FocusSession>('api/v1/focus-sessions/current/selection', {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/selection', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다.
|
||||
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
|
||||
* - completionType으로 goal-complete / timer-complete을 구분해 저장한다.
|
||||
* - 완료된 세션 스냅샷을 반환하거나, 최소한 성공적으로 완료되었음을 알 수 있는 응답을 보낸다.
|
||||
*/
|
||||
completeSession: async (payload: CompleteFocusSessionRequest): Promise<FocusSession> => {
|
||||
return apiClient<FocusSession>('api/v1/focus-sessions/current/complete', {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
advanceGoal: async (payload: AdvanceCurrentGoalRequest): Promise<AdvanceCurrentGoalResponse> => {
|
||||
const response = await apiClient<RawAdvanceCurrentGoalResponse>(
|
||||
'api/v1/focus-sessions/current/advance-goal',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
focusPlanItemId: toNumericId(payload.focusPlanItemId),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return normalizeAdvanceGoalResponse(response);
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 현재 세션을 중도 종료(abandon) 처리한다.
|
||||
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
|
||||
* - 통계에서는 abandon 여부를 구분할 수 있게 저장한다.
|
||||
* - 성공 시 204 또는 빈 성공 응답을 반환한다.
|
||||
*/
|
||||
abandonSession: async (): Promise<void> => {
|
||||
return apiClient<void>('api/v1/focus-sessions/current/abandon', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import {
|
||||
type AdvanceCurrentGoalRequest,
|
||||
type AdvanceCurrentGoalResponse,
|
||||
focusSessionApi,
|
||||
type CompleteFocusSessionRequest,
|
||||
type FocusSession,
|
||||
@@ -72,6 +74,7 @@ interface UseFocusSessionEngineResult {
|
||||
restartCurrentPhase: () => Promise<FocusSession | null>;
|
||||
updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise<FocusSession | null>;
|
||||
completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>;
|
||||
advanceGoal: (payload: AdvanceCurrentGoalRequest) => Promise<AdvanceCurrentGoalResponse | null>;
|
||||
abandonSession: () => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
}
|
||||
@@ -260,6 +263,23 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
||||
|
||||
return session;
|
||||
},
|
||||
advanceGoal: async (payload) => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await runMutation(
|
||||
() => focusSessionApi.advanceGoal(payload),
|
||||
copy.focusSession.completeFailed,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
applySession(result.nextSession);
|
||||
return result;
|
||||
},
|
||||
abandonSession: async () => {
|
||||
if (!currentSession) {
|
||||
return true;
|
||||
|
||||
@@ -17,8 +17,8 @@ export const PlanPill = ({ plan, onClick }: PlanPillProps) => {
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium tracking-[0.08em] uppercase transition-colors',
|
||||
isPro
|
||||
? 'border-amber-200/46 bg-amber-200/14 text-amber-100 hover:bg-amber-200/24'
|
||||
: 'border-white/20 bg-white/8 text-white/82 hover:bg-white/14',
|
||||
? 'border-amber-300/30 bg-amber-100/88 text-amber-950 hover:bg-amber-100'
|
||||
: 'border-brand-dark/12 bg-white/88 text-brand-dark/72 hover:bg-white',
|
||||
)}
|
||||
>
|
||||
{isPro ? copy.space.toolsDock.planPro : copy.space.toolsDock.planNormal}
|
||||
|
||||
@@ -37,14 +37,13 @@ export const app = {
|
||||
completedCycles: '완료한 사이클',
|
||||
sessionEntries: '입장 횟수',
|
||||
last7DaysFocus: '최근 7일 집중 시간',
|
||||
bestDay: '최고 몰입일',
|
||||
streak: '연속 달성',
|
||||
startedSessions: '시작한 세션',
|
||||
completedSessions: '완료한 세션',
|
||||
carriedOverCount: '이월된 계획',
|
||||
syncedApi: '동기화됨',
|
||||
temporary: '임시값',
|
||||
actualAggregate: '실집계',
|
||||
mockAggregate: '목업',
|
||||
streakActive: '유지 중',
|
||||
streakStart: '시작 전',
|
||||
countUnit: '회',
|
||||
dayUnit: '일',
|
||||
minuteUnit: '분',
|
||||
@@ -53,19 +52,19 @@ export const app = {
|
||||
plan: {
|
||||
proFeatureCards: [
|
||||
{
|
||||
id: 'scene-packs',
|
||||
name: 'Scene Packs',
|
||||
description: '프리미엄 공간 묶음과 장면 변주',
|
||||
id: 'daily-plan',
|
||||
name: 'Daily Focus Plan',
|
||||
description: '오늘의 집중을 블록 단위로 쪼개고 큐를 운영합니다.',
|
||||
},
|
||||
{
|
||||
id: 'sound-packs',
|
||||
name: 'Sound Packs',
|
||||
description: '확장 사운드 프리셋 묶음',
|
||||
id: 'rituals',
|
||||
name: 'Rituals',
|
||||
description: 'scene + sound + timer 조합을 반복 가능한 시작 방식으로 저장합니다.',
|
||||
},
|
||||
{
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
description: '내 기본 세팅 저장/불러오기',
|
||||
id: 'weekly-review',
|
||||
name: 'Weekly Review',
|
||||
description: '총 시간보다 시작 성공률과 복귀 패턴을 먼저 해석합니다.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const core = {
|
||||
appName: 'VibeRoom',
|
||||
metadata: {
|
||||
title: 'VibeRoom - 당신만의 편안한 몰입 공간',
|
||||
title: 'VibeRoom - 조용한 집중을 위한 Calm Session OS',
|
||||
description:
|
||||
'프리랜서와 온전한 집중이 필요한 분들을 위한 따뜻하고 구조화된 온라인 코워킹 스페이스. 작업 타이머, 세션 관리, 그리고 느슨한 연대를 통해 당신의 리듬을 찾아보세요.',
|
||||
'프리랜서와 창작자를 위한 조용한 집중 운영체제. 오늘의 큐, ritual, 주간 리뷰를 통해 더 빨리 시작하고 더 잘 이어가세요.',
|
||||
},
|
||||
common: {
|
||||
close: '닫기',
|
||||
@@ -28,76 +28,77 @@ export const core = {
|
||||
startFree: '무료로 시작하기',
|
||||
},
|
||||
hero: {
|
||||
titleLead: '함께하는 조용한 몰입,',
|
||||
titleLead: '조용한 집중을 위한,',
|
||||
titleAccent: 'VibeRoom',
|
||||
description:
|
||||
'집중하기 어려운 순간, 당신을 다그치지 않는 편안한 공간으로 들어오세요. 구조화된 코워킹 세션과 느슨한 연대가 당신의 페이스를 되찾아 줍니다.',
|
||||
primaryCta: '나만의 공간 만들기',
|
||||
'예쁜 공간보다 중요한 건 오늘 무엇을 할지 고르고, 무너진 뒤에도 다시 돌아오는 흐름입니다. 오늘의 집중 큐, ritual, 주간 리뷰를 한 화면에서 시작하세요.',
|
||||
primaryCta: '오늘의 집중 열기',
|
||||
secondaryCta: '더 알아보기',
|
||||
timerPreview: '45:00 남음',
|
||||
},
|
||||
features: {
|
||||
title: '당신을 위한 다정한 몰입 장치',
|
||||
description: '단순한 타이머가 아닙니다. 무리하지 않고 오래 지속할 수 있는 환경을 제공합니다.',
|
||||
title: '더 잘 이어가기 위한 집중 장치',
|
||||
description: '타이머만 주는 대신, 시작과 복귀를 돕는 운영 흐름을 제공합니다.',
|
||||
items: [
|
||||
{
|
||||
icon: '⏳',
|
||||
title: '구조화된 세션 타이머',
|
||||
title: 'Daily Focus Plan',
|
||||
description:
|
||||
'부담 없이 시작할 수 있는 짧은 몰입과 확실한 휴식. 당신만의 작업 리듬을 부드럽게 설정하고 관리하세요.',
|
||||
'오늘 해야 할 일을 블록 단위로 쪼개고, 세션 시작 전에 이번 한 조각을 고르게 만듭니다.',
|
||||
},
|
||||
{
|
||||
icon: '🌱',
|
||||
title: '다정한 연대와 코워킹',
|
||||
icon: '🪷',
|
||||
title: 'Rituals / Templates',
|
||||
description:
|
||||
'화면 너머 누군가와 함께하는 바디 더블링 효과. 감시가 아닌, 조용하지만 강력한 동기를 서로 나누어보세요.',
|
||||
'scene, sound, timer, 알림 강도를 반복 가능한 시작 방식으로 저장해 지치기 전에 바로 진입할 수 있습니다.',
|
||||
},
|
||||
{
|
||||
icon: '🛋️',
|
||||
title: '나만의 심미적 공간',
|
||||
title: 'Weekly Review',
|
||||
description:
|
||||
'비 오는 다락방, 햇살 드는 카페. 백색소음과 함께 내가 가장 편안함을 느끼는 가상 공간을 꾸미고 머무르세요.',
|
||||
'총 집중 시간보다 시작 성공률, 완료율, 중단 후 복귀 패턴을 먼저 보여줘 다음 주의 리듬을 정리합니다.',
|
||||
},
|
||||
],
|
||||
},
|
||||
pricing: {
|
||||
title: '나에게 맞는 공간 선택하기',
|
||||
description: '개인의 가벼운 집중부터 프리랜서의 완벽한 워크스페이스까지.',
|
||||
title: '집중을 이어가는 방식에 맞는 플랜',
|
||||
description: 'Free는 기본 시작을, Pro는 Calm Session OS 전체 흐름을 제공합니다.',
|
||||
plans: {
|
||||
starter: {
|
||||
name: 'Starter',
|
||||
subtitle: '가벼운 집중이 필요한 분',
|
||||
name: 'Free',
|
||||
subtitle: '부담 없이 바로 시작하고 싶은 분',
|
||||
price: '무료',
|
||||
cta: '무료로 시작하기',
|
||||
features: ['기본 가상 공간 테마', '1:1 파트너 매칭 (주 3회)', '오픈 코워킹 룸 입장'],
|
||||
features: ['기본 scene / sound / timer', '오늘의 집중 큐 1개', '저장 ritual 1개', '최근 7일 기본 통계'],
|
||||
},
|
||||
pro: {
|
||||
badge: '추천',
|
||||
name: 'Pro',
|
||||
subtitle: '방해 없는 완벽한 몰입 환경',
|
||||
subtitle: '더 빨리 시작하고 더 잘 이어가고 싶은 분',
|
||||
price: '₩6,900',
|
||||
priceSuffix: '/월',
|
||||
cta: 'Pro 시작하기',
|
||||
features: [
|
||||
'프리미엄 테마 무제한',
|
||||
'1:1 매칭 무제한',
|
||||
'고급 집중 통계 및 리포트',
|
||||
'공간 커스텀 아이템 제공',
|
||||
'다중 focus queue',
|
||||
'ritual/template 무제한',
|
||||
'주간 review + 고급 session analytics',
|
||||
'premium scene / sound packs',
|
||||
'비동기 accountability 기능',
|
||||
],
|
||||
},
|
||||
teams: {
|
||||
name: 'Teams',
|
||||
subtitle: '리모트 워크 기업 및 팀',
|
||||
price: '₩12,000',
|
||||
priceSuffix: '/인·월',
|
||||
subtitle: '소규모 크리에이티브 팀용 준비 중',
|
||||
price: '준비 중',
|
||||
priceSuffix: '',
|
||||
cta: '도입 문의하기',
|
||||
features: ['Pro 플랜의 모든 기능', '프라이빗 팀 스페이스', '팀 전체 생산성 대시보드'],
|
||||
features: ['Pro 기반 팀 운영 설계', '반복 check-in 운영 지원', '팀 기능은 순차 출시 예정'],
|
||||
},
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
description:
|
||||
'프리랜서와 온전한 집중이 필요한 분들을 위한 따뜻하고 구조화된 온라인 코워킹 스페이스입니다.',
|
||||
'프리랜서와 창작자를 위한 조용한 집중 운영체제입니다. 오늘의 큐, ritual, 주간 리뷰로 더 잘 이어가세요.',
|
||||
productTitle: '제품',
|
||||
companyTitle: '회사',
|
||||
links: {
|
||||
|
||||
@@ -54,19 +54,19 @@ export const stats = {
|
||||
export const plan = {
|
||||
proFeatureCards: [
|
||||
{
|
||||
id: 'scene-packs',
|
||||
name: 'Scene Packs',
|
||||
description: '프리미엄 공간 묶음과 장면 변주',
|
||||
id: 'daily-plan',
|
||||
name: 'Daily Focus Plan',
|
||||
description: '오늘의 집중을 블록 단위로 쪼개고 큐를 운영합니다.',
|
||||
},
|
||||
{
|
||||
id: 'sound-packs',
|
||||
name: 'Sound Packs',
|
||||
description: '확장 사운드 프리셋 묶음',
|
||||
id: 'rituals',
|
||||
name: 'Rituals',
|
||||
description: 'scene + sound + timer 조합을 반복 가능한 시작 방식으로 저장합니다.',
|
||||
},
|
||||
{
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
description: '내 기본 세팅 저장/불러오기',
|
||||
id: 'weekly-review',
|
||||
name: 'Weekly Review',
|
||||
description: '총 시간보다 시작 성공률과 복귀 패턴을 먼저 해석합니다.',
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
@@ -7,9 +7,9 @@ export const space = {
|
||||
},
|
||||
setup: {
|
||||
panelAriaLabel: '집중 시작 패널',
|
||||
eyebrow: 'Ritual',
|
||||
title: '이번 한 조각을 정하고 시작해요.',
|
||||
description: '목표를 정한 뒤 HUD의 시작 버튼으로 실제 세션을 시작해요.',
|
||||
eyebrow: 'Execution Setup',
|
||||
title: '이번 세션만 가볍게 맞추고 들어가요.',
|
||||
description: '목표, 배경, 타이머, 사운드만 정하고 바로 실행 화면으로 들어갑니다.',
|
||||
resumeTitle: '지난 한 조각 이어서',
|
||||
startFresh: '새로 시작',
|
||||
resumePrepare: '이어서 준비',
|
||||
@@ -17,7 +17,7 @@ export const space = {
|
||||
timerLabel: '타이머',
|
||||
soundLabel: '사운드',
|
||||
readyHint: '목표를 적으면 시작할 수 있어요.',
|
||||
openFocusScreen: '집중 화면 열기',
|
||||
openFocusScreen: '실행 화면 열기',
|
||||
},
|
||||
timerHud: {
|
||||
actions: [
|
||||
@@ -45,29 +45,30 @@ export const space = {
|
||||
description: '너무 크게 잡지 말고, 바로 다음 한 조각만.',
|
||||
closeAriaLabel: '닫기',
|
||||
restButton: '잠깐 쉬기',
|
||||
confirmButton: '바로 다음 조각 시작',
|
||||
confirmButton: '다음 목표로 바로 시작',
|
||||
confirmPending: '시작 중…',
|
||||
},
|
||||
controlCenter: {
|
||||
sectionTitles: {
|
||||
background: 'Background',
|
||||
time: 'Time',
|
||||
sound: 'Sound',
|
||||
packs: 'Packs',
|
||||
packs: 'Session OS',
|
||||
},
|
||||
packsDescription: '확장/개인화',
|
||||
packsDescription: 'plan · ritual · review',
|
||||
recommendation: (soundLabel: string, timerLabel: string) => `추천: ${soundLabel} · ${timerLabel}`,
|
||||
recommendationHint: '추천 조합은 참고 정보로만 제공돼요.',
|
||||
autoHideTitle: '컨트롤 자동 숨김',
|
||||
autoHideDescription: '입력이 없으면 잠시 후 패널을 닫아요.',
|
||||
autoHideAriaLabel: '컨트롤 자동 숨김',
|
||||
sideSheetSubtitle: '배경 · 타이머 · 사운드를 그 자리에서 바꿔요.',
|
||||
sideSheetSubtitle: '배경 · 타이머 · 사운드와 Session OS 진입점을 그 자리에서 바꿔요.',
|
||||
quickControlsTitle: 'Quick Controls',
|
||||
},
|
||||
toolsDock: {
|
||||
notesButton: 'Notes',
|
||||
popoverCloseAria: '팝오버 닫기',
|
||||
planPro: 'PRO',
|
||||
planNormal: 'Normal',
|
||||
planNormal: 'FREE',
|
||||
inboxSaved: '인박스에 저장됨',
|
||||
undo: '실행취소',
|
||||
inboxSaveUndone: '저장 취소됨',
|
||||
@@ -76,21 +77,21 @@ export const space = {
|
||||
emptyToClear: '비울 항목이 없어요.',
|
||||
clearedAll: '모두 비워짐',
|
||||
restored: '복원했어요.',
|
||||
normalPlanInfo: 'NORMAL 플랜 사용 중 · 잠금 항목에서만 업그레이드할 수 있어요.',
|
||||
proFeatureLocked: (source: string) => `${source}은(는) PRO 기능이에요.`,
|
||||
normalPlanInfo: 'FREE 플랜 사용 중 · Calm Session OS PRO에서 다중 큐와 주간 리뷰가 열려요.',
|
||||
proFeatureLocked: (source: string) => `${source}은(는) Calm Session OS PRO 기능이에요.`,
|
||||
proFeaturePending: (label: string) => `${label} 준비 중(더미)`,
|
||||
purchaseMock: '결제(더미)',
|
||||
purchaseMock: 'PRO 전환됨',
|
||||
manageSubscriptionMock: '구독 관리(더미)',
|
||||
restorePurchaseMock: '구매 복원(더미)',
|
||||
featureLabels: {
|
||||
scenePacks: 'Scene Packs',
|
||||
soundPacks: 'Sound Packs',
|
||||
profiles: 'Profiles',
|
||||
dailyPlan: 'Daily Focus Plan',
|
||||
rituals: 'Rituals',
|
||||
weeklyReview: 'Weekly Review',
|
||||
},
|
||||
utilityPanelTitle: {
|
||||
'control-center': 'Quick Controls',
|
||||
inbox: '인박스',
|
||||
paywall: 'PRO',
|
||||
paywall: 'Calm Session OS PRO',
|
||||
'manage-plan': '플랜 관리',
|
||||
},
|
||||
},
|
||||
@@ -141,13 +142,13 @@ export const space = {
|
||||
openQuickControlsTitle: 'Quick Controls',
|
||||
},
|
||||
paywall: {
|
||||
points: ['프리미엄 Scene Packs', '확장 Sound Packs', '프로필 저장 / 불러오기'],
|
||||
title: 'PRO에서 더 많은 공간과 사운드를 열어둘 수 있어요.',
|
||||
description: '잠금 항목을 누른 순간에만 열리는 더미 결제 시트입니다.',
|
||||
points: ['오늘의 집중 큐를 여러 개 운영', 'ritual/template를 무제한 저장', '주간 리뷰와 조용한 accountability 열기'],
|
||||
title: 'PRO는 더 많이 꾸미는 플랜이 아니라, 더 잘 이어가는 플랜이에요.',
|
||||
description: 'Daily plan, rituals, weekly review를 한 흐름으로 여는 더미 결제 시트입니다.',
|
||||
later: '나중에',
|
||||
startPro: 'PRO 시작하기',
|
||||
manageTitle: 'PRO 관리',
|
||||
manageDescription: '결제/복원은 더미 동작이며 실제 연동은 하지 않아요.',
|
||||
manageTitle: 'Calm Session OS PRO 관리',
|
||||
manageDescription: '결제/복원은 더미 동작이지만 유료 가치는 plan · ritual · review 흐름에 맞춰 정리했습니다.',
|
||||
openSubscription: '구독 관리 열기',
|
||||
restorePurchase: '구매 복원',
|
||||
},
|
||||
@@ -173,7 +174,7 @@ export const space = {
|
||||
restartFailed: '현재 페이즈를 다시 시작하지 못했어요.',
|
||||
restarted: '현재 페이즈를 처음부터 다시 시작했어요.',
|
||||
goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.',
|
||||
nextGoalReady: '다음 한 조각 준비 완료 · 시작 버튼을 눌러 이어가요.',
|
||||
nextGoalStarted: '다음 한 조각을 바로 시작했어요.',
|
||||
selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.',
|
||||
selectionSessionSyncFailed: '현재 세션의 배경/사운드 선택을 동기화하지 못했어요.',
|
||||
},
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useAdminConsole } from '../model/useAdminConsole';
|
||||
import { AdminDashboardView } from './AdminDashboardView';
|
||||
import { AdminLoginView } from './AdminLoginView';
|
||||
|
||||
export const AdminConsoleWidget = () => {
|
||||
const {
|
||||
session,
|
||||
activeView,
|
||||
setActiveView,
|
||||
activeMeta,
|
||||
isDurationOverrideEnabled,
|
||||
setIsDurationOverrideEnabled,
|
||||
loginId,
|
||||
password,
|
||||
loginError,
|
||||
loginPending,
|
||||
scenePending,
|
||||
soundPending,
|
||||
currentMessage,
|
||||
uploadResult,
|
||||
resultSummary,
|
||||
lastExtractedDurationSec,
|
||||
setLoginId,
|
||||
setPassword,
|
||||
handleLogin,
|
||||
handleLogout,
|
||||
handleSceneUpload,
|
||||
handleSoundUpload,
|
||||
} = useAdminConsole();
|
||||
|
||||
if (!session) {
|
||||
@@ -30,12 +45,22 @@ export const AdminConsoleWidget = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#f3f4f8] text-slate-900">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
|
||||
<p className="mt-2 text-slate-500">Welcome, {session.user?.name}!</p>
|
||||
<p className="mt-4 text-sm text-slate-400">Dashboard is under construction.</p>
|
||||
</div>
|
||||
</div>
|
||||
<AdminDashboardView
|
||||
session={session}
|
||||
activeView={activeView}
|
||||
onActiveViewChange={setActiveView}
|
||||
activeMeta={activeMeta}
|
||||
isDurationOverrideEnabled={isDurationOverrideEnabled}
|
||||
onDurationOverrideChange={setIsDurationOverrideEnabled}
|
||||
currentMessage={currentMessage}
|
||||
uploadResult={uploadResult}
|
||||
resultSummary={resultSummary}
|
||||
lastExtractedDurationSec={lastExtractedDurationSec}
|
||||
scenePending={scenePending}
|
||||
soundPending={soundPending}
|
||||
onLogout={handleLogout}
|
||||
onSceneSubmit={handleSceneUpload}
|
||||
onSoundSubmit={handleSoundUpload}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
482
src/widgets/admin-console/ui/AdminDashboardView.tsx
Normal file
482
src/widgets/admin-console/ui/AdminDashboardView.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import type { FormEventHandler } from 'react';
|
||||
import type { AuthResponse } from '@/entities/auth';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { Button } from '@/shared/ui';
|
||||
import type { AdminView, UploadResult } from '../model/types';
|
||||
import { fieldClassName, fileClassName, navItems, textareaClassName } from './constants';
|
||||
|
||||
interface AdminDashboardViewProps {
|
||||
session: AuthResponse;
|
||||
activeView: AdminView;
|
||||
onActiveViewChange: (view: AdminView) => void;
|
||||
activeMeta: typeof copy.admin.views.scene | typeof copy.admin.views.sound;
|
||||
isDurationOverrideEnabled: boolean;
|
||||
onDurationOverrideChange: (next: boolean) => void;
|
||||
currentMessage: string | null;
|
||||
uploadResult: UploadResult | null;
|
||||
resultSummary: string;
|
||||
lastExtractedDurationSec: number | null;
|
||||
scenePending: boolean;
|
||||
soundPending: boolean;
|
||||
onLogout: () => void;
|
||||
onSceneSubmit: FormEventHandler<HTMLFormElement>;
|
||||
onSoundSubmit: FormEventHandler<HTMLFormElement>;
|
||||
}
|
||||
|
||||
const MessagePanel = ({ message }: { message: string | null }) => {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminSceneForm = ({
|
||||
currentMessage,
|
||||
scenePending,
|
||||
onSubmit,
|
||||
}: {
|
||||
currentMessage: string | null;
|
||||
scenePending: boolean;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
}) => {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-6 py-5">
|
||||
<p className="text-lg font-semibold text-slate-950">{copy.admin.views.scene.workspaceTitle}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.admin.views.scene.workspaceDescription}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="px-6 py-6">
|
||||
<div className="max-w-xl">
|
||||
<div>
|
||||
<label htmlFor="scene-id" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{copy.admin.views.scene.sceneIdLabel}
|
||||
</label>
|
||||
<input
|
||||
id="scene-id"
|
||||
name="sceneId"
|
||||
className={fieldClassName}
|
||||
placeholder={copy.admin.views.scene.sceneIdPlaceholder}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.sceneIdHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-5">
|
||||
<div className="max-w-2xl">
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{copy.admin.views.scene.sourceImageLabel}
|
||||
</label>
|
||||
<input
|
||||
name="sourceImageFile"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png"
|
||||
className={fileClassName}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.sourceImageHint}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{copy.admin.views.scene.sourceImageDerivedHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 max-w-2xl">
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{copy.admin.views.scene.blurDataUrlLabel}
|
||||
</label>
|
||||
<textarea
|
||||
name="blurDataUrl"
|
||||
rows={6}
|
||||
className={textareaClassName}
|
||||
placeholder={copy.admin.views.scene.blurDataUrlPlaceholder}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.blurDataUrlHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 bg-slate-50 px-6 py-6 xl:border-l xl:border-t-0">
|
||||
<p className="text-sm font-semibold text-slate-900">{copy.admin.views.scene.notesTitle}</p>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-500">
|
||||
{copy.admin.views.scene.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<MessagePanel message={currentMessage} />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-6 h-12 w-full rounded-xl bg-slate-900 hover:bg-slate-800"
|
||||
disabled={scenePending}
|
||||
>
|
||||
{scenePending ? copy.admin.views.scene.pending : copy.admin.views.scene.submit}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminSoundForm = ({
|
||||
currentMessage,
|
||||
isDurationOverrideEnabled,
|
||||
lastExtractedDurationSec,
|
||||
onDurationOverrideChange,
|
||||
onSubmit,
|
||||
soundPending,
|
||||
}: {
|
||||
currentMessage: string | null;
|
||||
isDurationOverrideEnabled: boolean;
|
||||
lastExtractedDurationSec: number | null;
|
||||
onDurationOverrideChange: (next: boolean) => void;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
soundPending: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-6 py-5">
|
||||
<p className="text-lg font-semibold text-slate-950">{copy.admin.views.sound.workspaceTitle}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.admin.views.sound.workspaceDescription}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="px-6 py-6">
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="preset-id" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{copy.admin.views.sound.presetIdLabel}
|
||||
</label>
|
||||
<input
|
||||
id="preset-id"
|
||||
name="presetId"
|
||||
className={fieldClassName}
|
||||
placeholder={copy.admin.views.sound.presetIdPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{copy.admin.views.sound.defaultVolumeLabel}
|
||||
</label>
|
||||
<input
|
||||
name="defaultVolume"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
className={fieldClassName}
|
||||
placeholder={copy.admin.views.sound.defaultVolumePlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{copy.admin.views.sound.loopFileLabel}
|
||||
</label>
|
||||
<input name="loopFile" type="file" accept="audio/*" className={fileClassName} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{copy.admin.views.sound.previewFileLabel}
|
||||
</label>
|
||||
<input name="previewFile" type="file" accept="audio/*" className={fileClassName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{copy.admin.views.sound.fallbackLoopFileLabel}
|
||||
</label>
|
||||
<input name="fallbackLoopFile" type="file" accept="audio/*" className={fileClassName} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{copy.admin.views.sound.durationOverrideToggle}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">
|
||||
{copy.admin.views.sound.durationOverrideHint}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDurationOverrideChange(!isDurationOverrideEnabled)}
|
||||
className={`inline-flex h-7 w-12 items-center rounded-full px-1 transition ${
|
||||
isDurationOverrideEnabled ? 'bg-slate-900' : 'bg-slate-300'
|
||||
}`}
|
||||
aria-pressed={isDurationOverrideEnabled}
|
||||
>
|
||||
<span
|
||||
className={`h-5 w-5 rounded-full bg-white transition ${
|
||||
isDurationOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isDurationOverrideEnabled ? (
|
||||
<div className="mt-4">
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{copy.admin.views.sound.durationLabel}
|
||||
</label>
|
||||
<input
|
||||
name="durationSec"
|
||||
type="number"
|
||||
min={0}
|
||||
className={fieldClassName}
|
||||
placeholder={copy.admin.views.sound.durationPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 bg-slate-50 px-6 py-6 xl:border-l xl:border-t-0">
|
||||
<p className="text-sm font-semibold text-slate-900">{copy.admin.views.sound.notesTitle}</p>
|
||||
<div className="mt-4 rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
|
||||
{copy.admin.views.sound.extractedDurationLabel}
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-slate-900">
|
||||
{lastExtractedDurationSec == null
|
||||
? copy.admin.views.sound.extractedDurationEmpty
|
||||
: copy.admin.views.sound.extractedDurationValue(lastExtractedDurationSec)}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-500">
|
||||
{copy.admin.views.sound.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<MessagePanel message={currentMessage} />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-6 h-12 w-full rounded-xl bg-slate-900 hover:bg-slate-800"
|
||||
disabled={soundPending}
|
||||
>
|
||||
{soundPending ? copy.admin.views.sound.pending : copy.admin.views.sound.submit}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdminDashboardView = ({
|
||||
session,
|
||||
activeView,
|
||||
onActiveViewChange,
|
||||
activeMeta,
|
||||
isDurationOverrideEnabled,
|
||||
onDurationOverrideChange,
|
||||
currentMessage,
|
||||
uploadResult,
|
||||
resultSummary,
|
||||
lastExtractedDurationSec,
|
||||
scenePending,
|
||||
soundPending,
|
||||
onLogout,
|
||||
onSceneSubmit,
|
||||
onSoundSubmit,
|
||||
}: AdminDashboardViewProps) => {
|
||||
return (
|
||||
<main className="min-h-screen bg-[#f3f4f8] text-slate-900">
|
||||
<div className="grid min-h-screen lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside className="flex flex-col bg-[#171821] px-5 py-6 text-white">
|
||||
<div className="flex items-center gap-3 px-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-fuchsia-500 to-sky-500 text-lg font-bold">
|
||||
V
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold tracking-tight">{copy.appName}</p>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">{copy.admin.mediaAdminLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="mt-10 flex-1">
|
||||
<div className="px-3">
|
||||
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||
{copy.admin.navItems[0].section}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.id === activeView;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onActiveViewChange(item.id)}
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left transition ${
|
||||
isActive
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-slate-400 hover:bg-white/6 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-8 w-1 rounded-full ${isActive ? 'bg-sky-400' : 'bg-transparent'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{item.title}</p>
|
||||
<p className="truncate text-xs text-slate-500">{item.subtitle}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 px-3">
|
||||
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||
{copy.admin.sessionSection}
|
||||
</p>
|
||||
<div className="space-y-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">{copy.admin.sessionAdminLabel}</p>
|
||||
<p className="mt-1 font-medium text-white">{session.user?.name ?? copy.common.admin}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">{copy.admin.sessionRoleLabel}</p>
|
||||
<p className="mt-1 font-medium text-emerald-300">
|
||||
{session.user?.grade ?? 'ADMIN'}
|
||||
{copy.admin.roleAccessSuffix}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-white/8 px-3 pt-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="full"
|
||||
className="h-11 justify-start rounded-xl bg-white/6 px-4 text-white hover:bg-white/10"
|
||||
onClick={onLogout}
|
||||
>
|
||||
{copy.admin.logout}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 px-6 py-4">
|
||||
<div className="flex min-w-[280px] flex-1 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500"
|
||||
>
|
||||
≡
|
||||
</button>
|
||||
<div className="relative w-full max-w-md">
|
||||
<input
|
||||
value={activeView === 'scene' ? copy.admin.searchValues.scene : copy.admin.searchValues.sound}
|
||||
readOnly
|
||||
className="h-11 w-full rounded-xl border border-slate-200 bg-slate-50 pl-11 pr-4 text-sm text-slate-500 outline-none"
|
||||
/>
|
||||
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">
|
||||
⌕
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden items-center gap-2 rounded-full bg-slate-100 px-3 py-2 text-xs font-medium text-slate-500 md:flex">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
{copy.admin.manifestReady}
|
||||
</div>
|
||||
<div className="flex h-11 items-center gap-3 rounded-full border border-slate-200 bg-white px-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">
|
||||
A
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm font-semibold text-slate-900">{session.user?.name ?? copy.common.admin}</p>
|
||||
<p className="text-xs text-slate-500">{session.user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="flex-1 overflow-auto px-6 py-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="min-w-0 space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1.5fr)_repeat(2,minmax(0,1fr))]">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
|
||||
{activeMeta.eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">
|
||||
{activeMeta.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-500">{activeMeta.description}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-5">
|
||||
<p className="text-sm font-medium text-slate-500">{activeMeta.statTitle}</p>
|
||||
<p className="mt-6 text-3xl font-semibold tracking-tight text-slate-950">{activeMeta.statValue}</p>
|
||||
<p className="mt-2 text-sm text-slate-500">{activeMeta.statHint}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-5">
|
||||
<p className="text-sm font-medium text-slate-500">{copy.admin.inspector.currentRoleTitle}</p>
|
||||
<p className="mt-6 text-3xl font-semibold tracking-tight text-slate-950">
|
||||
{session.user?.grade ?? 'ADMIN'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-500">{copy.admin.inspector.bearerTokenSession}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeView === 'scene' ? (
|
||||
<AdminSceneForm
|
||||
currentMessage={currentMessage}
|
||||
scenePending={scenePending}
|
||||
onSubmit={onSceneSubmit}
|
||||
/>
|
||||
) : (
|
||||
<AdminSoundForm
|
||||
currentMessage={currentMessage}
|
||||
isDurationOverrideEnabled={isDurationOverrideEnabled}
|
||||
lastExtractedDurationSec={lastExtractedDurationSec}
|
||||
onDurationOverrideChange={onDurationOverrideChange}
|
||||
onSubmit={onSoundSubmit}
|
||||
soundPending={soundPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-5 py-4">
|
||||
<p className="text-sm font-semibold text-slate-950">{copy.admin.inspector.recentResponse}</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm leading-6 text-slate-500">{resultSummary}</p>
|
||||
<pre className="mt-4 max-h-[340px] overflow-auto rounded-xl bg-[#171821] px-4 py-4 text-[11px] leading-6 text-slate-100">
|
||||
{uploadResult ? JSON.stringify(uploadResult, null, 2) : copy.admin.inspector.noUploadPayload}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-5 py-4">
|
||||
<p className="text-sm font-semibold text-slate-950">{copy.admin.inspector.sessionToken}</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="break-all text-xs leading-6 text-slate-500">{session.accessToken}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
buildFocusEntryStartHref,
|
||||
type FocusPlanItem,
|
||||
type FocusPlanToday,
|
||||
useFocusPlan,
|
||||
} from '@/entities/focus-plan';
|
||||
import { usePlanTier } from '@/entities/plan';
|
||||
import { SCENE_THEMES, getSceneById } from '@/entities/scene';
|
||||
import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media';
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
||||
import { PlanPill } from '@/features/plan-pill';
|
||||
import { useFocusStats } from '@/features/stats';
|
||||
import { focusSessionApi } from '@/features/focus-session/api/focusSessionApi';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { FocusPlanManageSheet, type FocusPlanEditingState } from './FocusPlanManageSheet';
|
||||
@@ -20,24 +24,30 @@ const FREE_MAX_ITEMS = 1;
|
||||
const PRO_MAX_ITEMS = 5;
|
||||
|
||||
const focusEntryCopy = {
|
||||
eyebrow: 'Focus Entry',
|
||||
title: '지금 시작할 첫 블록',
|
||||
description: '한 줄로 정하고 바로 들어가요.',
|
||||
eyebrow: 'VibeRoom',
|
||||
title: '오늘의 깊은 몰입을 위한 단 하나의 목표',
|
||||
description: '지금 당장 시작할 딱 하나만 남겨두세요.',
|
||||
inputLabel: '첫 블록',
|
||||
inputPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
||||
helper: '아주 작게 잡아도 괜찮아요.',
|
||||
startNow: '지금 시작',
|
||||
manageBlocks: '블록 정리',
|
||||
startNow: '바로 몰입하기',
|
||||
nextStep: '환경 세팅',
|
||||
manageBlocks: '내 계획에서 가져오기',
|
||||
previewTitle: '이어갈 블록',
|
||||
previewDescription: '다음 후보는 가볍게만 두고, 시작은 위 버튼 하나로 끝냅니다.',
|
||||
reviewLinkLabel: 'stats',
|
||||
reviewFallback: '최근 7일 흐름을 불러오는 중이에요.',
|
||||
ritualMeta: '기본 ritual로 들어가요. 배경과 타이머는 /space에서 이어서 바꿀 수 있어요.',
|
||||
ritualMeta: '기본 설정으로 들어갑니다. 공간 안에서 언제든 바꿀 수 있어요.',
|
||||
apiUnavailableNote: '계획 연결이 잠시 느려요. 지금은 첫 블록부터 바로 시작할 수 있어요.',
|
||||
freeUpgradeLabel: '두 번째 블록부터는 PRO',
|
||||
paywallSource: 'focus-entry-manage-sheet',
|
||||
paywallLead: 'Calm Session OS PRO',
|
||||
paywallBody: '여러 블록을 이어서 정리하는 manage sheet는 PRO에서 열립니다.',
|
||||
microStepTitle: '가장 작은 첫 단계 (선택)',
|
||||
microStepHelper: '이 목표를 위해 당장 할 수 있는 5분짜리 행동은 무엇인가요?',
|
||||
microStepPlaceholder: '예: 폴더 열기, 노션 켜기',
|
||||
ritualTitle: '어떤 환경에서 몰입하시겠어요?',
|
||||
ritualHelper: '오늘의 무드를 선택하세요.',
|
||||
};
|
||||
|
||||
const ENTRY_SUGGESTIONS = [
|
||||
@@ -47,6 +57,7 @@ const ENTRY_SUGGESTIONS = [
|
||||
] as const;
|
||||
|
||||
type EntrySource = 'starter' | 'plan' | 'custom';
|
||||
type DashboardStep = 'goal' | 'ritual';
|
||||
|
||||
const getVisiblePlanItems = (
|
||||
currentItem: FocusPlanItem | null,
|
||||
@@ -58,76 +69,71 @@ const getVisiblePlanItems = (
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
const formatReviewLine = (startedSessions: number, completedSessions: number, carriedOverCount: number) => {
|
||||
return `최근 7일 시작 ${startedSessions}회 · 완료 ${completedSessions}회 · 이월 ${carriedOverCount}개`;
|
||||
};
|
||||
|
||||
const startButtonClassName =
|
||||
'inline-flex h-12 w-full items-center justify-center rounded-[1rem] bg-brand-primary text-sm font-semibold text-white shadow-[0_14px_32px_rgba(59,130,246,0.22)] transition hover:bg-brand-primary/92 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/18';
|
||||
|
||||
const previewButtonClassName =
|
||||
'w-full rounded-[1.1rem] border border-slate-200/88 bg-white/72 px-4 py-3 text-left transition hover:border-slate-300/88 hover:bg-white';
|
||||
|
||||
const resolveVisiblePlanItems = (nextPlan: FocusPlanToday | null, limit: number) => {
|
||||
return getVisiblePlanItems(nextPlan?.currentItem ?? null, nextPlan?.nextItems ?? [], limit);
|
||||
};
|
||||
|
||||
// Premium Glassmorphism UI Classes
|
||||
const glassInputClass = 'w-full rounded-full border border-white/20 bg-black/20 px-8 py-5 text-center text-lg md:text-xl font-light tracking-wide text-white placeholder:text-white/40 shadow-2xl backdrop-blur-xl outline-none transition-all focus:border-white/40 focus:bg-black/30 focus:ring-4 focus:ring-white/10';
|
||||
const primaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/20 bg-white/20 px-8 py-4 text-base font-medium text-white shadow-xl backdrop-blur-xl transition-all hover:bg-white/30 hover:scale-[1.02] active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
const secondaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/10 bg-transparent px-8 py-4 text-base font-medium text-white/80 transition-all hover:bg-white/10 hover:text-white active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
const panelGlassClass = 'rounded-[2rem] border border-white/10 bg-black/40 p-6 md:p-8 shadow-2xl backdrop-blur-2xl';
|
||||
const itemCardGlassClass = 'relative flex flex-col items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-4 text-white transition-all hover:bg-white/10 active:scale-95 cursor-pointer';
|
||||
const itemCardGlassSelectedClass = 'border-white/40 bg-white/20 shadow-[0_0_20px_rgba(255,255,255,0.1)]';
|
||||
|
||||
export const FocusDashboardWidget = () => {
|
||||
const router = useRouter();
|
||||
const { plan: planTier, isPro, setPlan } = usePlanTier();
|
||||
const { plan, isLoading, isSaving, error, source, createItem, updateItem, deleteItem } = useFocusPlan();
|
||||
const { summary } = useFocusStats();
|
||||
const { sceneAssetMap } = useMediaCatalog();
|
||||
|
||||
const [step, setStep] = useState<DashboardStep>('goal');
|
||||
const [paywallSource, setPaywallSource] = useState<string | null>(null);
|
||||
const [manageSheetOpen, setManageSheetOpen] = useState(false);
|
||||
const [editingState, setEditingState] = useState<FocusPlanEditingState>(null);
|
||||
|
||||
const [entryDraft, setEntryDraft] = useState('');
|
||||
const [selectedPlanItemId, setSelectedPlanItemId] = useState<string | null>(null);
|
||||
const [entrySource, setEntrySource] = useState<EntrySource>('starter');
|
||||
|
||||
const [microStepDraft, setMicroStepDraft] = useState('');
|
||||
// Use user's last preference or default to first
|
||||
const [selectedSceneId, setSelectedSceneId] = useState(SCENE_THEMES[0].id);
|
||||
const [selectedSoundId, setSelectedSoundId] = useState(SOUND_PRESETS[0].id);
|
||||
const [selectedTimerId, setSelectedTimerId] = useState('50-10');
|
||||
|
||||
const [isStartingSession, setIsStartingSession] = useState(false);
|
||||
|
||||
const entryInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const microStepInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedScene = useMemo(() => getSceneById(selectedSceneId) ?? SCENE_THEMES[0], [selectedSceneId]);
|
||||
|
||||
const maxItems = isPro ? PRO_MAX_ITEMS : FREE_MAX_ITEMS;
|
||||
const planItems = useMemo(() => {
|
||||
return getVisiblePlanItems(plan.currentItem, plan.nextItems, maxItems);
|
||||
}, [maxItems, plan.currentItem, plan.nextItems]);
|
||||
|
||||
const currentItem = planItems[0] ?? null;
|
||||
const previewItems = planItems.slice(1, 3);
|
||||
const reviewLine = formatReviewLine(
|
||||
summary.last7Days.startedSessions,
|
||||
summary.last7Days.completedSessions,
|
||||
summary.last7Days.carriedOverCount,
|
||||
);
|
||||
|
||||
const hasPendingEdit = editingState !== null;
|
||||
const canAddMore = planItems.length < maxItems;
|
||||
const canManagePlan = source === 'api' && !isLoading;
|
||||
const trimmedEntryGoal = entryDraft.trim();
|
||||
const startHref = trimmedEntryGoal
|
||||
? buildFocusEntryStartHref({
|
||||
goal: trimmedEntryGoal,
|
||||
planItemId: selectedPlanItemId,
|
||||
})
|
||||
: null;
|
||||
const isGoalReady = trimmedEntryGoal.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingState) return;
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
return () => window.cancelAnimationFrame(rafId);
|
||||
}, [editingState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentItem) return;
|
||||
if (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId)) {
|
||||
setEntryDraft(currentItem.title);
|
||||
setSelectedPlanItemId(currentItem.id);
|
||||
@@ -135,34 +141,10 @@ export const FocusDashboardWidget = () => {
|
||||
}
|
||||
}, [currentItem, entryDraft, entrySource, selectedPlanItemId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPlanItemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (planItems.some((item) => item.id === selectedPlanItemId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentItem) {
|
||||
setEntryDraft(currentItem.title);
|
||||
setSelectedPlanItemId(currentItem.id);
|
||||
setEntrySource('plan');
|
||||
return;
|
||||
}
|
||||
|
||||
setEntryDraft('');
|
||||
setSelectedPlanItemId(null);
|
||||
setEntrySource('custom');
|
||||
}, [currentItem, planItems, selectedPlanItemId]);
|
||||
|
||||
const openPaywall = () => {
|
||||
setPaywallSource(focusEntryCopy.paywallSource);
|
||||
};
|
||||
const openPaywall = () => setPaywallSource(focusEntryCopy.paywallSource);
|
||||
|
||||
const handleSelectPlanItem = (item: FocusPlanItem) => {
|
||||
const isCurrentSelection = currentItem?.id === item.id;
|
||||
|
||||
setEntryDraft(item.title);
|
||||
setSelectedPlanItemId(isCurrentSelection ? item.id : null);
|
||||
setEntrySource(isCurrentSelection ? 'plan' : 'custom');
|
||||
@@ -182,111 +164,58 @@ export const FocusDashboardWidget = () => {
|
||||
};
|
||||
|
||||
const handleAddBlock = () => {
|
||||
if (hasPendingEdit || isSaving || !canManagePlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPendingEdit || isSaving || !canManagePlan) return;
|
||||
if (!canAddMore) {
|
||||
if (!isPro) {
|
||||
openPaywall();
|
||||
}
|
||||
if (!isPro) openPaywall();
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingState({
|
||||
mode: 'new',
|
||||
value: '',
|
||||
});
|
||||
setEditingState({ mode: 'new', value: '' });
|
||||
};
|
||||
|
||||
const handleEditRow = (item: FocusPlanItem) => {
|
||||
if (hasPendingEdit || isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingState({
|
||||
mode: 'edit',
|
||||
itemId: item.id,
|
||||
value: item.title,
|
||||
});
|
||||
if (hasPendingEdit || isSaving) return;
|
||||
setEditingState({ mode: 'edit', itemId: item.id, value: item.title });
|
||||
};
|
||||
|
||||
const handleManageDraftChange = (value: string) => {
|
||||
setEditingState((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
value,
|
||||
};
|
||||
});
|
||||
setEditingState((current) => current ? { ...current, value } : current);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingState(null);
|
||||
if (!isSaving) setEditingState(null);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingState) return;
|
||||
const trimmedTitle = editingState.value.trim();
|
||||
|
||||
if (!trimmedTitle) {
|
||||
return;
|
||||
}
|
||||
if (!trimmedTitle) return;
|
||||
|
||||
if (editingState.mode === 'new') {
|
||||
const nextPlan = await createItem({ title: trimmedTitle });
|
||||
|
||||
if (!nextPlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextPlan) return;
|
||||
setEditingState(null);
|
||||
|
||||
if (!currentItem) {
|
||||
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
|
||||
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
|
||||
|
||||
if (nextCurrentItem) {
|
||||
setEntryDraft(nextCurrentItem.title);
|
||||
setSelectedPlanItemId(nextCurrentItem.id);
|
||||
setEntrySource('plan');
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRow = planItems.find((item) => item.id === editingState.itemId);
|
||||
|
||||
if (!currentRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentRow) return;
|
||||
if (currentRow.title === trimmedTitle) {
|
||||
setEditingState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPlan = await updateItem(editingState.itemId, {
|
||||
title: trimmedTitle,
|
||||
});
|
||||
|
||||
if (!nextPlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPlan = await updateItem(editingState.itemId, { title: trimmedTitle });
|
||||
if (!nextPlan) return;
|
||||
setEditingState(null);
|
||||
|
||||
if (selectedPlanItemId === editingState.itemId) {
|
||||
setEntryDraft(trimmedTitle);
|
||||
setEntrySource('plan');
|
||||
@@ -295,109 +224,144 @@ export const FocusDashboardWidget = () => {
|
||||
|
||||
const handleDeleteRow = async (itemId: string) => {
|
||||
const nextPlan = await deleteItem(itemId);
|
||||
|
||||
if (!nextPlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextPlan) return;
|
||||
if (editingState?.mode === 'edit' && editingState.itemId === itemId) {
|
||||
setEditingState(null);
|
||||
}
|
||||
|
||||
if (selectedPlanItemId === itemId) {
|
||||
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
|
||||
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
|
||||
|
||||
if (nextCurrentItem) {
|
||||
setEntryDraft(nextCurrentItem.title);
|
||||
setSelectedPlanItemId(nextCurrentItem.id);
|
||||
setEntrySource('plan');
|
||||
return;
|
||||
}
|
||||
|
||||
setEntryDraft('');
|
||||
setSelectedPlanItemId(null);
|
||||
setEntrySource('custom');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (!isGoalReady) {
|
||||
entryInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (step === 'goal') setStep('ritual');
|
||||
};
|
||||
|
||||
const handleStartSession = async () => {
|
||||
if (isStartingSession) return;
|
||||
setIsStartingSession(true);
|
||||
|
||||
try {
|
||||
await focusSessionApi.startSession({
|
||||
goal: trimmedEntryGoal,
|
||||
microStep: microStepDraft.trim() || null,
|
||||
sceneId: selectedSceneId,
|
||||
soundPresetId: selectedSoundId,
|
||||
timerPresetId: selectedTimerId,
|
||||
focusPlanItemId: selectedPlanItemId || undefined,
|
||||
entryPoint: 'space-setup'
|
||||
});
|
||||
router.push('/space');
|
||||
} catch (err) {
|
||||
console.error('Failed to start session', err);
|
||||
setIsStartingSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_12%_0%,rgba(191,219,254,0.42),transparent_36%),linear-gradient(180deg,#f8fafc_0%,#edf4fb_56%,#e7eef7_100%)] text-brand-dark">
|
||||
<div className="mx-auto w-full max-w-2xl px-4 pb-12 pt-8 sm:px-6">
|
||||
<header className="flex items-start justify-between gap-4">
|
||||
<div className="max-w-lg">
|
||||
<p className="text-[11px] uppercase tracking-[0.16em] text-brand-dark/40">
|
||||
<div className="relative min-h-dvh overflow-hidden bg-slate-900 text-white font-sans selection:bg-white/20">
|
||||
{/* Premium Cinematic Background */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-cover bg-center transition-all duration-1000 ease-out will-change-transform",
|
||||
isStartingSession ? 'scale-110 blur-2xl opacity-0' : 'scale-100 opacity-100',
|
||||
step === 'ritual' ? 'scale-105 blur-sm' : ''
|
||||
)}
|
||||
style={getSceneStageBackgroundStyle(selectedScene, sceneAssetMap?.[selectedScene.id])}
|
||||
/>
|
||||
{/* Global Gradient Overlay for text readability */}
|
||||
<div className={cn(
|
||||
"absolute inset-0 bg-gradient-to-b from-black/20 via-black/40 to-black/60 transition-opacity duration-1000",
|
||||
step === 'ritual' ? 'opacity-80' : 'opacity-100'
|
||||
)} />
|
||||
|
||||
{/* Header */}
|
||||
<header className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-6 md:p-8">
|
||||
<p className="text-sm font-semibold tracking-[0.3em] text-white/50 uppercase">
|
||||
{focusEntryCopy.eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-brand-dark">
|
||||
{focusEntryCopy.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-7 text-brand-dark/62">
|
||||
{focusEntryCopy.description}
|
||||
</p>
|
||||
</div>
|
||||
<PlanPill
|
||||
plan={planTier}
|
||||
onClick={() => {
|
||||
if (!isPro) {
|
||||
openPaywall();
|
||||
}
|
||||
if (!isPro) openPaywall();
|
||||
}}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<main className="mt-8 space-y-5">
|
||||
<section className="overflow-hidden rounded-[2rem] border border-black/5 bg-white/78 p-5 shadow-[0_24px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl sm:p-6">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-brand-dark">{focusEntryCopy.title}</p>
|
||||
<p className="text-sm text-brand-dark/58">{focusEntryCopy.helper}</p>
|
||||
</div>
|
||||
{/* Main Content Area */}
|
||||
<main className="relative z-10 flex h-dvh flex-col items-center justify-center px-4">
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label className="min-w-0 flex-1 space-y-2">
|
||||
<span className="text-[11px] uppercase tracking-[0.14em] text-brand-dark/40">
|
||||
{focusEntryCopy.inputLabel}
|
||||
</span>
|
||||
{/* Step 1: Goal Setup */}
|
||||
<div className={cn(
|
||||
"w-full max-w-2xl transition-all duration-700 absolute",
|
||||
step === 'goal'
|
||||
? 'opacity-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 -translate-y-8 pointer-events-none'
|
||||
)}>
|
||||
<div className="flex flex-col items-center space-y-10 text-center">
|
||||
<h1 className="text-3xl md:text-5xl font-light tracking-tight text-white drop-shadow-lg leading-tight">
|
||||
{focusEntryCopy.title}
|
||||
</h1>
|
||||
|
||||
<div className="w-full max-w-xl mx-auto space-y-8">
|
||||
<input
|
||||
ref={entryInputRef}
|
||||
value={entryDraft}
|
||||
onChange={(event) => handleEntryDraftChange(event.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
|
||||
placeholder={focusEntryCopy.inputPlaceholder}
|
||||
className="h-12 w-full rounded-[1rem] border border-slate-200/88 bg-white px-4 text-[15px] text-brand-dark outline-none transition focus:border-brand-primary/38 focus:ring-2 focus:ring-brand-primary/12"
|
||||
className={glassInputClass}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
{startHref ? (
|
||||
<Link href={startHref} className={cn(startButtonClassName, 'sm:w-[164px]')}>
|
||||
{focusEntryCopy.startNow}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => entryInputRef.current?.focus()}
|
||||
className={cn(startButtonClassName, 'sm:w-[164px]')}
|
||||
onClick={handleStartSession}
|
||||
disabled={!isGoalReady || isStartingSession}
|
||||
className={primaryGlassBtnClass}
|
||||
>
|
||||
{focusEntryCopy.startNow}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextStep}
|
||||
disabled={!isGoalReady || isStartingSession}
|
||||
className={secondaryGlassBtnClass}
|
||||
>
|
||||
{focusEntryCopy.nextStep}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Suggestions / Manage - very minimal */}
|
||||
<div className="pt-8 flex flex-col items-center gap-4 opacity-70 hover:opacity-100 transition-opacity">
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{ENTRY_SUGGESTIONS.map((suggestion) => {
|
||||
const isActive = selectedPlanItemId === null && trimmedEntryGoal === suggestion.goal;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectSuggestion(suggestion.goal)}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-3 py-1.5 text-sm transition',
|
||||
'rounded-full px-4 py-1.5 text-sm transition-all border',
|
||||
isActive
|
||||
? 'border-brand-primary/26 bg-brand-primary/10 text-brand-dark'
|
||||
: 'border-slate-200/84 bg-white/72 text-brand-dark/68 hover:bg-white',
|
||||
? 'bg-white/20 border-white text-white'
|
||||
: 'bg-transparent border-white/20 text-white/70 hover:border-white/40 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{suggestion.label}
|
||||
@@ -405,81 +369,141 @@ export const FocusDashboardWidget = () => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200/80 pt-4">
|
||||
<p className="text-xs text-brand-dark/54">{focusEntryCopy.ritualMeta}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManageSheetOpen(true)}
|
||||
disabled={!canManagePlan}
|
||||
className="text-sm font-medium text-brand-primary transition hover:text-brand-primary/82 disabled:cursor-not-allowed disabled:text-brand-dark/34"
|
||||
className="text-sm font-medium text-white/50 hover:text-white transition-colors underline underline-offset-4 decoration-white/20"
|
||||
>
|
||||
{focusEntryCopy.manageBlocks}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{previewItems.length > 0 ? (
|
||||
<div className="space-y-3 border-t border-slate-200/80 pt-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-brand-dark">{focusEntryCopy.previewTitle}</p>
|
||||
<p className="text-xs leading-6 text-brand-dark/54">
|
||||
{focusEntryCopy.previewDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{previewItems.map((item) => {
|
||||
const isSelected = selectedPlanItemId === null && trimmedEntryGoal === item.title;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
return (
|
||||
{/* Step 2: Ritual Setup */}
|
||||
<div className={cn(
|
||||
"w-full max-w-4xl transition-all duration-700 absolute",
|
||||
step === 'ritual'
|
||||
? 'opacity-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 translate-y-8 pointer-events-none'
|
||||
)}>
|
||||
<div className={panelGlassClass}>
|
||||
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/10">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs uppercase tracking-widest text-white/40 mb-2">Today's Focus</p>
|
||||
<p className="text-xl md:text-2xl font-light text-white truncate pr-4">{trimmedEntryGoal}</p>
|
||||
</div>
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectPlanItem(item)}
|
||||
className={cn(
|
||||
previewButtonClassName,
|
||||
isSelected && 'border-brand-primary/24 bg-brand-primary/8',
|
||||
)}
|
||||
onClick={() => setStep('goal')}
|
||||
className="rounded-full border border-white/20 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'truncate text-[15px] font-medium',
|
||||
isSelected ? 'text-brand-dark' : 'text-brand-dark/78',
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
수정
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{source === 'unavailable' && !isLoading ? (
|
||||
<p className="border-t border-slate-200/80 pt-4 text-xs text-brand-dark/54">
|
||||
{focusEntryCopy.apiUnavailableNote}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
|
||||
<div className="space-y-8">
|
||||
{/* Microstep */}
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-white/80">
|
||||
{focusEntryCopy.microStepTitle}
|
||||
</span>
|
||||
<input
|
||||
ref={microStepInputRef}
|
||||
value={microStepDraft}
|
||||
onChange={(e) => setMicroStepDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleStartSession()}
|
||||
placeholder={focusEntryCopy.microStepPlaceholder}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder:text-white/30 outline-none transition-all focus:border-white/30 focus:bg-white/10"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-white/40">{focusEntryCopy.microStepHelper}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<p className="text-xs text-brand-dark/54">
|
||||
{isLoading ? focusEntryCopy.reviewFallback : reviewLine}
|
||||
</p>
|
||||
<Link
|
||||
href="/stats"
|
||||
className="text-xs font-medium text-brand-primary transition hover:text-brand-primary/82"
|
||||
{/* Timer */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white/80">몰입 리듬</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[{id: '25-5', label: '25 / 5'}, {id: '50-10', label: '50 / 10'}, {id: '90-20', label: '90 / 20'}].map(timer => (
|
||||
<button
|
||||
key={timer.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTimerId(timer.id)}
|
||||
className={cn(itemCardGlassClass, selectedTimerId === timer.id && itemCardGlassSelectedClass)}
|
||||
>
|
||||
{focusEntryCopy.reviewLinkLabel}
|
||||
</Link>
|
||||
<span className="text-sm font-medium">{timer.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && source === 'api' ? <p className="px-1 text-xs text-rose-500">{error}</p> : null}
|
||||
<div className="space-y-8">
|
||||
{/* Scene */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white/80">배경 공간</p>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 snap-x hide-scrollbar">
|
||||
{SCENE_THEMES.map(scene => (
|
||||
<button
|
||||
key={scene.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSceneId(scene.id)}
|
||||
className={cn(
|
||||
'group relative h-24 min-w-[120px] rounded-xl border border-white/10 text-left snap-start transition-all overflow-hidden bg-white/5 active:scale-95 cursor-pointer',
|
||||
selectedSceneId === scene.id && 'border-white/40 shadow-[0_0_20px_rgba(255,255,255,0.1)]'
|
||||
)}
|
||||
style={getSceneStageBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40 transition-opacity group-hover:bg-black/20" />
|
||||
<span className="absolute bottom-2 left-2 text-sm font-medium z-10 text-white text-shadow-sm">{scene.name}</span>
|
||||
{selectedSceneId === scene.id && (
|
||||
<span className="absolute top-2 right-2 z-20 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm border border-white/40 text-white text-[10px]">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sound */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white/80">사운드</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{SOUND_PRESETS.map(sound => (
|
||||
<button
|
||||
key={sound.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSoundId(sound.id)}
|
||||
className={cn(itemCardGlassClass, "py-3", selectedSoundId === sound.id && itemCardGlassSelectedClass)}
|
||||
>
|
||||
<span className="text-sm font-medium">{sound.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 pt-6 flex justify-end border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartSession}
|
||||
disabled={isStartingSession}
|
||||
className={primaryGlassBtnClass}
|
||||
>
|
||||
{isStartingSession ? '공간으로 이동 중...' : '입장하기'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan Sheet & Paywall */}
|
||||
<FocusPlanManageSheet
|
||||
isOpen={manageSheetOpen}
|
||||
planItems={planItems}
|
||||
@@ -533,6 +557,6 @@ export const FocusDashboardWidget = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
63
src/widgets/space-focus-hud/ui/FloatingGoalWidget.tsx
Normal file
63
src/widgets/space-focus-hud/ui/FloatingGoalWidget.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
interface FloatingGoalWidgetProps {
|
||||
goal: string;
|
||||
microStep?: string | null;
|
||||
onGoalCompleteRequest?: () => void;
|
||||
hasActiveSession?: boolean;
|
||||
sessionPhase?: 'focus' | 'break' | null;
|
||||
}
|
||||
|
||||
export const FloatingGoalWidget = ({
|
||||
goal,
|
||||
microStep,
|
||||
onGoalCompleteRequest,
|
||||
hasActiveSession,
|
||||
sessionPhase,
|
||||
}: FloatingGoalWidgetProps) => {
|
||||
const [isMicroStepCompleted, setIsMicroStepCompleted] = useState(false);
|
||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.timerHud.goalFallback;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed left-0 top-0 z-20 w-full max-w-[800px] h-48 bg-[radial-gradient(ellipse_at_top_left,rgba(0,0,0,0.6)_0%,rgba(0,0,0,0)_60%)]">
|
||||
<div className="flex flex-col items-start gap-4 p-8 md:p-12">
|
||||
{/* Main Goal */}
|
||||
<div className="pointer-events-auto group relative flex items-center gap-4">
|
||||
<h2 className="text-2xl md:text-[1.75rem] font-medium tracking-tight text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.8)] [text-shadow:0_4px_24px_rgba(0,0,0,0.6)]">
|
||||
{normalizedGoal}
|
||||
</h2>
|
||||
{hasActiveSession && sessionPhase === 'focus' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGoalCompleteRequest}
|
||||
className="opacity-0 group-hover:opacity-100 shrink-0 rounded-full border border-white/20 bg-black/40 backdrop-blur-md px-3.5 py-1.5 text-[11px] font-medium text-white/90 shadow-lg transition-all hover:bg-black/60 hover:text-white"
|
||||
>
|
||||
목표 달성
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Micro Step */}
|
||||
{microStep && !isMicroStepCompleted && (
|
||||
<div className="pointer-events-auto flex items-center gap-3.5 animate-in fade-in slide-in-from-top-2 duration-500 bg-black/10 backdrop-blur-[2px] rounded-full pr-4 py-1 -ml-1 border border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMicroStepCompleted(true)}
|
||||
className="flex h-6 w-6 ml-1 items-center justify-center rounded-full border border-white/40 bg-black/20 shadow-inner transition-all hover:bg-white/20 hover:scale-110 active:scale-95"
|
||||
aria-label="첫 단계 완료"
|
||||
>
|
||||
<span className="sr-only">첫 단계 완료</span>
|
||||
</button>
|
||||
<span className="text-[15px] font-medium text-white/95 drop-shadow-[0_2px_4px_rgba(0,0,0,0.6)] [text-shadow:0_2px_12px_rgba(0,0,0,0.5)]">
|
||||
{microStep}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,13 +8,11 @@ import { cn } from '@/shared/lib/cn';
|
||||
interface GoalCompleteSheetProps {
|
||||
open: boolean;
|
||||
currentGoal: string;
|
||||
onConfirm: (nextGoal: string) => void;
|
||||
onConfirm: (nextGoal: string) => Promise<boolean> | boolean;
|
||||
onRest: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GOAL_SUGGESTIONS = copy.space.goalComplete.suggestions;
|
||||
|
||||
export const GoalCompleteSheet = ({
|
||||
open,
|
||||
currentGoal,
|
||||
@@ -24,6 +22,7 @@ export const GoalCompleteSheet = ({
|
||||
}: GoalCompleteSheetProps) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -56,14 +55,24 @@ export const GoalCompleteSheet = ({
|
||||
}, [currentGoal]);
|
||||
|
||||
const canConfirm = draft.trim().length > 0;
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!canConfirm) {
|
||||
if (!canConfirm || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
onConfirm(draft.trim());
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const didAdvance = await onConfirm(draft.trim());
|
||||
|
||||
if (didAdvance) {
|
||||
onClose();
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -99,20 +108,6 @@ export const GoalCompleteSheet = ({
|
||||
placeholder={placeholder}
|
||||
className="h-9 w-full rounded-xl border border-white/14 bg-white/[0.04] px-3 text-sm text-white placeholder:text-white/40 focus:border-sky-200/42 focus:outline-none"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{GOAL_SUGGESTIONS.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => setDraft(suggestion)}
|
||||
className="rounded-full border border-white/16 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/76 transition-colors hover:bg-white/[0.1]"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="mt-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -123,10 +118,10 @@ export const GoalCompleteSheet = ({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canConfirm}
|
||||
disabled={!canConfirm || isSubmitting}
|
||||
className="rounded-full border border-sky-200/42 bg-sky-300/84 px-3.5 py-1.5 text-xs font-semibold text-slate-900 transition-colors hover:bg-sky-300 disabled:cursor-not-allowed disabled:border-white/16 disabled:bg-white/[0.08] disabled:text-white/48"
|
||||
>
|
||||
{copy.space.goalComplete.confirmButton}
|
||||
{isSubmitting ? copy.space.goalComplete.confirmPending : copy.space.goalComplete.confirmButton}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
||||
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
|
||||
import { FloatingGoalWidget } from './FloatingGoalWidget';
|
||||
import { GoalCompleteSheet } from './GoalCompleteSheet';
|
||||
|
||||
interface SpaceFocusHudWidgetProps {
|
||||
goal: string;
|
||||
microStep?: string | null;
|
||||
timerLabel: string;
|
||||
timeDisplay?: string;
|
||||
visible: boolean;
|
||||
@@ -19,12 +21,13 @@ interface SpaceFocusHudWidgetProps {
|
||||
onStartRequested?: () => void;
|
||||
onPauseRequested?: () => void;
|
||||
onRestartRequested?: () => void;
|
||||
onGoalUpdate: (nextGoal: string) => void | Promise<void>;
|
||||
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
|
||||
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
||||
}
|
||||
|
||||
export const SpaceFocusHudWidget = ({
|
||||
goal,
|
||||
microStep,
|
||||
timerLabel,
|
||||
timeDisplay,
|
||||
visible,
|
||||
@@ -86,9 +89,15 @@ export const SpaceFocusHudWidget = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingGoalWidget
|
||||
goal={goal}
|
||||
microStep={microStep}
|
||||
onGoalCompleteRequest={handleOpenCompleteSheet}
|
||||
hasActiveSession={hasActiveSession}
|
||||
sessionPhase={sessionPhase}
|
||||
/>
|
||||
<SpaceTimerHudWidget
|
||||
timerLabel={timerLabel}
|
||||
goal={goal}
|
||||
timeDisplay={timeDisplay}
|
||||
isImmersionMode
|
||||
hasActiveSession={hasActiveSession}
|
||||
@@ -99,7 +108,6 @@ export const SpaceFocusHudWidget = ({
|
||||
canPause={canPauseSession}
|
||||
canReset={canRestartSession}
|
||||
className="pr-[4.2rem]"
|
||||
onGoalCompleteRequest={handleOpenCompleteSheet}
|
||||
onStartClick={onStartRequested}
|
||||
onPauseClick={onPauseRequested}
|
||||
onResetClick={onRestartRequested}
|
||||
@@ -121,8 +129,7 @@ export const SpaceFocusHudWidget = ({
|
||||
}, 5 * 60 * 1000);
|
||||
}}
|
||||
onConfirm={(nextGoal) => {
|
||||
void onGoalUpdate(nextGoal);
|
||||
setSheetOpen(false);
|
||||
return Promise.resolve(onGoalUpdate(nextGoal));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
||||
import type { SceneAssetMap } from '@/entities/media';
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session';
|
||||
import type {
|
||||
GoalChip,
|
||||
SoundPreset,
|
||||
TimerPreset,
|
||||
} from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { SceneSelectCarousel } from '@/features/scene-select';
|
||||
import { SessionGoalField } from '@/features/session-goal';
|
||||
import { Button } from '@/shared/ui';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
type RitualPopover = 'space' | 'timer' | 'sound';
|
||||
type SelectionPopover = 'space' | 'timer' | 'sound';
|
||||
|
||||
interface SpaceSetupDrawerWidgetProps {
|
||||
open: boolean;
|
||||
@@ -86,7 +90,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
resumeHint,
|
||||
}: SpaceSetupDrawerWidgetProps) => {
|
||||
const { setup } = copy.space;
|
||||
const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null);
|
||||
const [openPopover, setOpenPopover] = useState<SelectionPopover | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const selectedScene = useMemo(() => {
|
||||
@@ -133,7 +137,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const togglePopover = (popover: RitualPopover) => {
|
||||
const togglePopover = (popover: SelectionPopover) => {
|
||||
setOpenPopover((current) => (current === popover ? null : popover));
|
||||
};
|
||||
|
||||
@@ -280,7 +284,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form id="space-setup-ritual-form" className="space-y-3" onSubmit={handleSubmit}>
|
||||
<form id="space-setup-form" className="space-y-3" onSubmit={handleSubmit}>
|
||||
<SessionGoalField
|
||||
autoFocus={open}
|
||||
goalInput={goalInput}
|
||||
@@ -294,7 +298,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
{!canStart ? <p className="text-[10px] text-white/56">{setup.readyHint}</p> : null}
|
||||
<Button
|
||||
type="submit"
|
||||
form="space-setup-ritual-form"
|
||||
form="space-setup-form"
|
||||
size="full"
|
||||
disabled={!canStart}
|
||||
className={cn(
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
|
||||
interface SpaceTimerHudWidgetProps {
|
||||
timerLabel: string;
|
||||
goal: string;
|
||||
timeDisplay?: string;
|
||||
className?: string;
|
||||
hasActiveSession?: boolean;
|
||||
@@ -24,14 +23,12 @@ interface SpaceTimerHudWidgetProps {
|
||||
onStartClick?: () => void;
|
||||
onPauseClick?: () => void;
|
||||
onResetClick?: () => void;
|
||||
onGoalCompleteRequest?: () => void;
|
||||
}
|
||||
|
||||
const HUD_ACTIONS = copy.space.timerHud.actions;
|
||||
|
||||
export const SpaceTimerHudWidget = ({
|
||||
timerLabel,
|
||||
goal,
|
||||
timeDisplay = '25:00',
|
||||
className,
|
||||
hasActiveSession = false,
|
||||
@@ -45,10 +42,8 @@ export const SpaceTimerHudWidget = ({
|
||||
onStartClick,
|
||||
onPauseClick,
|
||||
onResetClick,
|
||||
onGoalCompleteRequest,
|
||||
}: SpaceTimerHudWidgetProps) => {
|
||||
const { isBreatheMode, triggerRestart } = useRestart30s();
|
||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.timerHud.goalFallback;
|
||||
const modeLabel = isBreatheMode
|
||||
? RECOVERY_30S_MODE_LABEL
|
||||
: !hasActiveSession
|
||||
@@ -60,63 +55,41 @@ export const SpaceTimerHudWidget = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none fixed inset-x-0 z-20 px-4 pr-16 sm:px-6',
|
||||
'pointer-events-none fixed inset-x-0 z-20 flex justify-center px-4 sm:px-6',
|
||||
className,
|
||||
)}
|
||||
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 0.35rem)' }}
|
||||
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 2rem)' }}
|
||||
>
|
||||
<div className="relative mx-auto w-full max-w-xl pointer-events-auto">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 z-0 h-28 w-[min(760px,96vw)] -translate-x-1/2 -translate-y-1/2 bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.2)_0%,rgba(2,6,23,0.12)_45%,rgba(2,6,23,0)_78%)]"
|
||||
/>
|
||||
<div className="relative pointer-events-auto">
|
||||
<section
|
||||
className={cn(
|
||||
'relative z-10 flex h-[4.85rem] items-center justify-between gap-3 overflow-hidden rounded-2xl px-3.5 py-2 transition-colors',
|
||||
'relative z-10 flex h-[3.5rem] items-center justify-between gap-6 overflow-hidden rounded-full px-5 transition-colors',
|
||||
isImmersionMode
|
||||
? 'border border-white/12 bg-black/22 backdrop-blur-md'
|
||||
: 'border border-white/12 bg-black/24 backdrop-blur-md',
|
||||
? 'border border-white/10 bg-black/20 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)]'
|
||||
: 'border border-white/15 bg-black/30 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.16)]',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] font-semibold uppercase tracking-[0.16em]',
|
||||
isImmersionMode ? 'text-white/90' : 'text-white/88',
|
||||
'w-14 text-right text-[10px] font-bold uppercase tracking-[0.15em] opacity-80',
|
||||
sessionPhase === 'break' ? 'text-emerald-400' : 'text-brand-primary'
|
||||
)}
|
||||
>
|
||||
{modeLabel}
|
||||
</span>
|
||||
<span className="w-[1px] h-4 bg-white/10" />
|
||||
<span
|
||||
className={cn(
|
||||
'text-[1.7rem] font-semibold tracking-tight sm:text-[1.78rem]',
|
||||
isImmersionMode ? 'text-white/90' : 'text-white/92',
|
||||
'w-20 text-[1.4rem] font-medium tracking-tight text-center',
|
||||
isImmersionMode ? 'text-white/90' : 'text-white',
|
||||
)}
|
||||
>
|
||||
{timeDisplay}
|
||||
</span>
|
||||
<span className={cn('text-[11px]', isImmersionMode ? 'text-white/65' : 'text-white/65')}>
|
||||
{timerLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex min-w-0 items-center gap-2">
|
||||
<p className={cn('min-w-0 truncate text-sm', isImmersionMode ? 'text-white/88' : 'text-white/86')}>
|
||||
<span className="text-white/62">{copy.space.timerHud.goalPrefix}</span>
|
||||
<span className="text-white/90">{normalizedGoal}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGoalCompleteRequest}
|
||||
className="shrink-0 rounded-full border border-white/16 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/70 transition-colors hover:bg-white/[0.1] hover:text-white/86"
|
||||
>
|
||||
{copy.space.timerHud.completeButton}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 pl-2 border-l border-white/10">
|
||||
{HUD_ACTIONS.map((action) => {
|
||||
const isStartAction = action.id === 'start';
|
||||
const isPauseAction = action.id === 'pause';
|
||||
@@ -136,32 +109,20 @@ export const SpaceTimerHudWidget = ({
|
||||
aria-pressed={isHighlighted}
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
if (isStartAction) {
|
||||
onStartClick?.();
|
||||
}
|
||||
|
||||
if (isPauseAction) {
|
||||
onPauseClick?.();
|
||||
}
|
||||
|
||||
if (isResetAction) {
|
||||
onResetClick?.();
|
||||
}
|
||||
if (isStartAction) onStartClick?.();
|
||||
if (isPauseAction) onPauseClick?.();
|
||||
if (isResetAction) onResetClick?.();
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-9 w-9 items-center justify-center rounded-full border text-sm transition-[transform,background-color,border-color,box-shadow,color,opacity] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80 active:translate-y-px active:scale-[0.95] disabled:cursor-not-allowed disabled:opacity-38 disabled:shadow-none',
|
||||
'shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_8px_18px_rgba(2,6,23,0.18)]',
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-full text-sm transition-all duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 active:scale-95 disabled:cursor-not-allowed disabled:opacity-30',
|
||||
isImmersionMode
|
||||
? 'border-white/14 bg-black/28 text-white/82 hover:border-white/22 hover:bg-white/[0.09]'
|
||||
: 'border-white/14 bg-black/28 text-white/84 hover:border-white/22 hover:bg-white/[0.09]',
|
||||
? 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
: 'text-white/80 hover:bg-white/15 hover:text-white',
|
||||
isStartAction && isHighlighted
|
||||
? 'border-sky-200/56 bg-sky-200/20 text-sky-50 shadow-[inset_0_1px_0_rgba(255,255,255,0.12),0_10px_22px_rgba(56,189,248,0.24)]'
|
||||
? 'bg-white/10 text-white shadow-sm'
|
||||
: '',
|
||||
isPauseAction && isHighlighted
|
||||
? 'border-amber-200/52 bg-amber-200/18 text-amber-50 shadow-[inset_0_1px_0_rgba(255,255,255,0.12),0_10px_22px_rgba(251,191,36,0.18)]'
|
||||
: '',
|
||||
isResetAction && !isDisabled
|
||||
? 'hover:border-white/26 hover:bg-white/[0.12] hover:text-white'
|
||||
? 'bg-white/10 text-white shadow-sm'
|
||||
: '',
|
||||
)}
|
||||
>
|
||||
@@ -170,10 +131,9 @@ export const SpaceTimerHudWidget = ({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Restart30sAction
|
||||
onTrigger={triggerRestart}
|
||||
className={cn(isImmersionMode ? 'text-white/72 hover:text-white/92' : 'text-white/74 hover:text-white/92')}
|
||||
className="h-8 w-8 ml-1"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { usePlanTier } from '@/entities/plan';
|
||||
import type { RecentThought } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
||||
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from './types';
|
||||
import type { PlanTier } from '@/entities/plan';
|
||||
|
||||
interface UseSpaceToolsDockHandlersParams {
|
||||
setIdle: (idle: boolean) => void;
|
||||
@@ -44,7 +44,7 @@ export const useSpaceToolsDockHandlers = ({
|
||||
}: UseSpaceToolsDockHandlersParams) => {
|
||||
const { toolsDock } = copy.space;
|
||||
const [noteDraft, setNoteDraft] = useState('');
|
||||
const [plan, setPlan] = useState<PlanTier>('normal');
|
||||
const { plan, setPlan } = usePlanTier();
|
||||
|
||||
const openUtilityPanel = useCallback((panel: SpaceUtilityPanelId) => {
|
||||
setIdle(false);
|
||||
@@ -148,11 +148,11 @@ export const useSpaceToolsDockHandlers = ({
|
||||
|
||||
const handleSelectProFeature = useCallback((featureId: string) => {
|
||||
const label =
|
||||
featureId === 'scene-packs'
|
||||
? toolsDock.featureLabels.scenePacks
|
||||
: featureId === 'sound-packs'
|
||||
? toolsDock.featureLabels.soundPacks
|
||||
: toolsDock.featureLabels.profiles;
|
||||
featureId === 'daily-plan'
|
||||
? toolsDock.featureLabels.dailyPlan
|
||||
: featureId === 'rituals'
|
||||
? toolsDock.featureLabels.rituals
|
||||
: toolsDock.featureLabels.weeklyReview;
|
||||
|
||||
onStatusMessage({ message: toolsDock.proFeaturePending(label) });
|
||||
}, [onStatusMessage, toolsDock.featureLabels]);
|
||||
|
||||
@@ -79,82 +79,32 @@ export const FocusModeAnchors = ({
|
||||
type="button"
|
||||
aria-label={copy.space.toolsDock.popoverCloseAria}
|
||||
onClick={onClosePopover}
|
||||
className="fixed inset-0 z-30"
|
||||
className="fixed inset-0 z-30 cursor-default"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FocusRightRail
|
||||
isIdle={isIdle}
|
||||
thoughtCount={thoughtCount}
|
||||
onOpenInbox={onOpenInbox}
|
||||
onOpenControlCenter={onOpenControlCenter}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
anchorContainerClassName,
|
||||
'left-[calc(env(safe-area-inset-left,0px)+0.75rem)]',
|
||||
isIdle ? 'opacity-34' : 'opacity-82',
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div aria-hidden className={anchorHaloClassName} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleNotes}
|
||||
className={anchorButtonClassName}
|
||||
>
|
||||
<span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
|
||||
<span>{copy.space.toolsDock.notesButton} {formatThoughtCount(thoughtCount)}</span>
|
||||
<span aria-hidden className="text-white/60">▾</span>
|
||||
</button>
|
||||
|
||||
{openPopover === 'notes' ? (
|
||||
<QuickNotesPopover
|
||||
openPopover={openPopover}
|
||||
noteDraft={noteDraft}
|
||||
onDraftChange={onNoteDraftChange}
|
||||
onDraftEnter={onNoteSubmit}
|
||||
onSubmit={onNoteSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
anchorContainerClassName,
|
||||
'right-[calc(env(safe-area-inset-right,0px)+0.75rem)]',
|
||||
isIdle ? 'opacity-34' : 'opacity-82',
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div aria-hidden className={anchorHaloClassName} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSound}
|
||||
className={anchorButtonClassName}
|
||||
>
|
||||
<span aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span>
|
||||
<span className="max-w-[132px] truncate">{selectedSoundLabel}</span>
|
||||
<span aria-hidden className="text-white/60">▾</span>
|
||||
</button>
|
||||
|
||||
{openPopover === 'sound' ? (
|
||||
<QuickSoundPopover
|
||||
selectedSoundLabel={selectedSoundLabel}
|
||||
isSoundMuted={isSoundMuted}
|
||||
soundVolume={soundVolume}
|
||||
volumeFeedback={volumeFeedback}
|
||||
quickSoundPresets={quickSoundPresets}
|
||||
selectedPresetId={selectedPresetId}
|
||||
onOpenInbox={onOpenInbox}
|
||||
onOpenControlCenter={onOpenControlCenter}
|
||||
onToggleNotes={onToggleNotes}
|
||||
onToggleSound={onToggleSound}
|
||||
onNoteDraftChange={onNoteDraftChange}
|
||||
onNoteSubmit={onNoteSubmit}
|
||||
onToggleMute={onToggleMute}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onVolumeKeyDown={onVolumeKeyDown}
|
||||
onSelectPreset={onSelectPreset}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,54 +1,180 @@
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import type { SoundPreset } from '@/entities/session';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { formatThoughtCount, RAIL_ICON } from './constants';
|
||||
import type { SpaceAnchorPopoverId } from '../model/types';
|
||||
import { formatThoughtCount, RAIL_ICON, ANCHOR_ICON } from './constants';
|
||||
import { QuickNotesPopover } from './popovers/QuickNotesPopover';
|
||||
import { QuickSoundPopover } from './popovers/QuickSoundPopover';
|
||||
|
||||
interface FocusRightRailProps {
|
||||
isIdle: boolean;
|
||||
thoughtCount: number;
|
||||
openPopover: SpaceAnchorPopoverId | null;
|
||||
noteDraft: string;
|
||||
selectedSoundLabel: string;
|
||||
isSoundMuted: boolean;
|
||||
soundVolume: number;
|
||||
volumeFeedback: string | null;
|
||||
quickSoundPresets: SoundPreset[];
|
||||
selectedPresetId: string;
|
||||
onOpenInbox: () => void;
|
||||
onOpenControlCenter: () => void;
|
||||
onToggleNotes: () => void;
|
||||
onToggleSound: () => void;
|
||||
onNoteDraftChange: (value: string) => void;
|
||||
onNoteSubmit: () => void;
|
||||
onToggleMute: () => void;
|
||||
onVolumeChange: (nextVolume: number) => void;
|
||||
onVolumeKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
|
||||
onSelectPreset: (presetId: string) => void;
|
||||
}
|
||||
|
||||
export const FocusRightRail = ({
|
||||
isIdle,
|
||||
thoughtCount,
|
||||
openPopover,
|
||||
noteDraft,
|
||||
selectedSoundLabel,
|
||||
isSoundMuted,
|
||||
soundVolume,
|
||||
volumeFeedback,
|
||||
quickSoundPresets,
|
||||
selectedPresetId,
|
||||
onOpenInbox,
|
||||
onOpenControlCenter,
|
||||
onToggleNotes,
|
||||
onToggleSound,
|
||||
onNoteDraftChange,
|
||||
onNoteSubmit,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
onVolumeKeyDown,
|
||||
onSelectPreset,
|
||||
}: FocusRightRailProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-1/2 -translate-y-1/2',
|
||||
isIdle ? 'opacity-34' : 'opacity-78',
|
||||
'fixed z-30 transition-all duration-500 right-[calc(env(safe-area-inset-right,0px)+1.5rem)] top-1/2 -translate-y-1/2',
|
||||
isIdle ? 'opacity-0 translate-x-4 pointer-events-none' : 'opacity-100 translate-x-0',
|
||||
)}
|
||||
>
|
||||
<div className="rounded-2xl border border-white/14 bg-black/22 p-1.5 backdrop-blur-md">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative flex flex-col gap-2 rounded-full border border-white/10 bg-black/20 p-2.5 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.2)]">
|
||||
|
||||
{/* Notes Toggle */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copy.space.toolsDock.notesButton}
|
||||
onClick={onToggleNotes}
|
||||
className={cn(
|
||||
"relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200",
|
||||
openPopover === 'notes' ? "bg-white/20 text-white shadow-inner" : "bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{ANCHOR_ICON.notes}
|
||||
</button>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
|
||||
{copy.space.toolsDock.notesButton}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{openPopover === 'notes' ? (
|
||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-4">
|
||||
<QuickNotesPopover
|
||||
noteDraft={noteDraft}
|
||||
onDraftChange={onNoteDraftChange}
|
||||
onDraftEnter={onNoteSubmit}
|
||||
onSubmit={onNoteSubmit}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Sound Toggle */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="사운드"
|
||||
onClick={onToggleSound}
|
||||
className={cn(
|
||||
"relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200",
|
||||
openPopover === 'sound' ? "bg-white/20 text-white shadow-inner" : "bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{ANCHOR_ICON.sound}
|
||||
</button>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
|
||||
사운드
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{openPopover === 'sound' ? (
|
||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-4">
|
||||
<QuickSoundPopover
|
||||
selectedSoundLabel={selectedSoundLabel}
|
||||
isSoundMuted={isSoundMuted}
|
||||
soundVolume={soundVolume}
|
||||
volumeFeedback={volumeFeedback}
|
||||
quickSoundPresets={quickSoundPresets}
|
||||
selectedPresetId={selectedPresetId}
|
||||
onToggleMute={onToggleMute}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onVolumeKeyDown={onVolumeKeyDown}
|
||||
onSelectPreset={onSelectPreset}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="w-6 h-px bg-white/10 mx-auto my-1" />
|
||||
|
||||
{/* Inbox Button */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copy.space.inbox.openInboxAriaLabel}
|
||||
title={copy.space.inbox.openInboxTitle}
|
||||
onClick={onOpenInbox}
|
||||
className="relative inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
|
||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full bg-transparent text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{RAIL_ICON.inbox}
|
||||
{thoughtCount > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 inline-flex min-w-[0.95rem] items-center justify-center rounded-full bg-sky-200/28 px-1 py-0.5 text-[8px] font-semibold text-sky-50">
|
||||
<span className="absolute 0 top-0 right-0 inline-flex min-w-[1rem] items-center justify-center rounded-full bg-brand-primary px-1 py-0.5 text-[9px] font-bold text-white shadow-sm ring-2 ring-black/20">
|
||||
{formatThoughtCount(thoughtCount)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
|
||||
{copy.space.inbox.openInboxTitle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control Center Button */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copy.space.rightRail.openQuickControlsAriaLabel}
|
||||
title={copy.space.rightRail.openQuickControlsTitle}
|
||||
onClick={onOpenControlCenter}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-transparent text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{RAIL_ICON.controlCenter}
|
||||
</button>
|
||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
|
||||
{copy.space.rightRail.openQuickControlsTitle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,11 +15,10 @@ export const QuickNotesPopover = ({
|
||||
}: QuickNotesPopoverProps) => {
|
||||
return (
|
||||
<div
|
||||
className="mb-2 w-[min(320px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
|
||||
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', left: 0 }}
|
||||
className="mb-2 w-[320px] rounded-[1.5rem] border border-white/10 bg-black/30 p-5 shadow-[0_24px_60px_rgba(0,0,0,0.4)] backdrop-blur-2xl animate-in fade-in zoom-in-95 slide-in-from-right-4 duration-300 origin-right"
|
||||
>
|
||||
<p className="text-[11px] text-white/56">{copy.space.quickNotes.title}</p>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<p className="text-[11px] font-medium uppercase tracking-widest text-white/50">{copy.space.quickNotes.title}</p>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<input
|
||||
value={noteDraft}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
@@ -32,17 +31,21 @@ export const QuickNotesPopover = ({
|
||||
onDraftEnter();
|
||||
}}
|
||||
placeholder={copy.space.quickNotes.placeholder}
|
||||
className="h-8 min-w-0 flex-1 rounded-lg border border-white/14 bg-white/[0.04] px-2.5 text-xs text-white placeholder:text-white/38 focus:border-sky-200/42 focus:outline-none"
|
||||
autoFocus
|
||||
className="w-full border-b border-white/20 bg-transparent pb-2 text-sm text-white placeholder:text-white/30 transition-colors focus:border-white/60 focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] text-white/40">{copy.space.quickNotes.hint}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
className="h-8 rounded-lg border border-sky-200/34 bg-sky-200/14 px-2.5 text-xs text-white/88"
|
||||
disabled={!noteDraft.trim()}
|
||||
className="rounded-full bg-white/10 px-4 py-1.5 text-xs font-medium text-white transition-all hover:bg-white/20 active:scale-95 disabled:opacity-30"
|
||||
>
|
||||
{copy.space.quickNotes.submit}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-white/52">{copy.space.quickNotes.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,22 +30,27 @@ export const QuickSoundPopover = ({
|
||||
}: QuickSoundPopoverProps) => {
|
||||
return (
|
||||
<div
|
||||
className="mb-2 w-[min(288px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
|
||||
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', right: 0 }}
|
||||
className="mb-2 w-[320px] rounded-[1.5rem] border border-white/10 bg-black/30 p-5 shadow-[0_24px_60px_rgba(0,0,0,0.4)] backdrop-blur-2xl animate-in fade-in zoom-in-95 slide-in-from-right-4 duration-300 origin-right"
|
||||
>
|
||||
<p className="text-[11px] text-white/56">{copy.space.quickSound.currentSound}</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-white/88">{selectedSoundLabel}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] font-medium uppercase tracking-widest text-white/50">{copy.space.quickSound.currentSound}</p>
|
||||
<span className="text-[11px] font-medium text-white/90 bg-white/10 px-2 py-0.5 rounded-md">
|
||||
{volumeFeedback ?? (isSoundMuted ? '0%' : `${soundVolume}%`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-base font-medium text-white/90">{selectedSoundLabel}</p>
|
||||
|
||||
<div className="mt-3 rounded-xl border border-white/14 bg-white/[0.04] px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mt-5 rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isSoundMuted ? copy.space.quickSound.unmuteAriaLabel : copy.space.quickSound.muteAriaLabel}
|
||||
onClick={onToggleMute}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/16 bg-white/[0.05] text-xs text-white/80 transition-colors hover:bg-white/[0.12]"
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/10 text-[13px] text-white/80 transition-all hover:bg-white/20 active:scale-95"
|
||||
>
|
||||
🔇
|
||||
{isSoundMuted ? '🔇' : '🔊'}
|
||||
</button>
|
||||
<div className="relative flex w-full items-center">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
@@ -55,16 +60,21 @@ export const QuickSoundPopover = ({
|
||||
onChange={(event) => onVolumeChange(Number(event.target.value))}
|
||||
onKeyDown={onVolumeKeyDown}
|
||||
aria-label={copy.space.quickSound.volumeAriaLabel}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-white/18 accent-sky-200"
|
||||
className="absolute z-10 w-full cursor-pointer appearance-none bg-transparent accent-white outline-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md"
|
||||
/>
|
||||
<span className="w-9 text-right text-[11px] text-white/66">
|
||||
{volumeFeedback ?? (isSoundMuted ? '0%' : `${soundVolume}%`)}
|
||||
</span>
|
||||
<div className="h-1.5 w-full rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-white/90 transition-all duration-150 ease-out"
|
||||
style={{ width: `${soundVolume}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[11px] text-white/56">{copy.space.quickSound.quickSwitch}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<div className="mt-6">
|
||||
<p className="text-[10px] font-medium uppercase tracking-widest text-white/40 mb-3">{copy.space.quickSound.quickSwitch}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickSoundPresets.map((preset) => {
|
||||
const selected = preset.id === selectedPresetId;
|
||||
|
||||
@@ -74,10 +84,10 @@ export const QuickSoundPopover = ({
|
||||
type="button"
|
||||
onClick={() => onSelectPreset(preset.id)}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
|
||||
'rounded-full border px-3 py-1.5 text-[11px] font-medium transition-all active:scale-95',
|
||||
selected
|
||||
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
|
||||
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
|
||||
? 'border-white/40 bg-white/20 text-white shadow-sm'
|
||||
: 'border-transparent bg-white/5 text-white/60 hover:bg-white/15 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
@@ -86,5 +96,6 @@ export const QuickSoundPopover = ({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useWorkspaceMediaDiagnostics } from './useWorkspaceMediaDiagnostics';
|
||||
interface UseSpaceWorkspaceSelectionParams {
|
||||
initialSceneId: string;
|
||||
initialGoal: string;
|
||||
initialFocusPlanItemId: string | null;
|
||||
initialTimerLabel: string;
|
||||
sceneQuery: string | null;
|
||||
goalQuery: string;
|
||||
@@ -64,6 +65,7 @@ const getVisibleSetupScenes = (selectedScene: SceneTheme) => {
|
||||
export const useSpaceWorkspaceSelection = ({
|
||||
initialSceneId,
|
||||
initialGoal,
|
||||
initialFocusPlanItemId,
|
||||
initialTimerLabel,
|
||||
sceneQuery,
|
||||
goalQuery,
|
||||
@@ -86,6 +88,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
const [selectedSceneId, setSelectedSceneId] = useState(initialSceneId);
|
||||
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
|
||||
const [goalInput, setGoalInput] = useState(initialGoal);
|
||||
const [linkedFocusPlanItemId, setLinkedFocusPlanItemId] = useState<string | null>(initialFocusPlanItemId);
|
||||
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
|
||||
const [resumeGoal, setResumeGoal] = useState('');
|
||||
const [showResumePrompt, setShowResumePrompt] = useState(false);
|
||||
@@ -262,6 +265,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
|
||||
const handleGoalChipSelect = useCallback((chip: GoalChip) => {
|
||||
setShowResumePrompt(false);
|
||||
setLinkedFocusPlanItemId(null);
|
||||
setSelectedGoalId(chip.id);
|
||||
setGoalInput(chip.label);
|
||||
}, []);
|
||||
@@ -271,6 +275,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
setShowResumePrompt(false);
|
||||
}
|
||||
|
||||
setLinkedFocusPlanItemId(null);
|
||||
setGoalInput(value);
|
||||
|
||||
if (value.trim().length === 0) {
|
||||
@@ -385,6 +390,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
setSelectedTimerLabel(nextTimerLabel);
|
||||
setSelectedPresetId(nextSoundPresetId);
|
||||
setGoalInput(currentSession.goal);
|
||||
setLinkedFocusPlanItemId(currentSession.focusPlanItemId ?? null);
|
||||
setSelectedGoalId(null);
|
||||
setShowResumePrompt(false);
|
||||
});
|
||||
@@ -418,6 +424,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
goalInput,
|
||||
linkedFocusPlanItemId,
|
||||
selectedGoalId,
|
||||
resumeGoal,
|
||||
showResumePrompt,
|
||||
@@ -428,6 +435,7 @@ export const useSpaceWorkspaceSelection = ({
|
||||
setupScenes,
|
||||
canStart,
|
||||
setGoalInput,
|
||||
setLinkedFocusPlanItemId,
|
||||
setSelectedGoalId,
|
||||
setShowResumePrompt,
|
||||
setResumeGoal,
|
||||
|
||||
@@ -17,6 +17,7 @@ interface UseSpaceWorkspaceSessionControlsParams {
|
||||
canStart: boolean;
|
||||
currentSession: FocusSession | null;
|
||||
goalInput: string;
|
||||
linkedFocusPlanItemId: string | null;
|
||||
selectedSceneId: string;
|
||||
selectedTimerLabel: string;
|
||||
selectedPresetId: string;
|
||||
@@ -28,18 +29,24 @@ interface UseSpaceWorkspaceSessionControlsParams {
|
||||
sceneId: string;
|
||||
goal: string;
|
||||
timerPresetId: string;
|
||||
soundPresetId: string;
|
||||
soundPresetId: string | null;
|
||||
focusPlanItemId?: string;
|
||||
entryPoint: SessionEntryPoint;
|
||||
}) => Promise<FocusSession | null>;
|
||||
pauseSession: () => Promise<FocusSession | null>;
|
||||
resumeSession: () => Promise<FocusSession | null>;
|
||||
restartCurrentPhase: () => Promise<FocusSession | null>;
|
||||
completeSession: (input: {
|
||||
completionType: 'goal-complete';
|
||||
advanceGoal: (input: {
|
||||
completedGoal: string;
|
||||
}) => Promise<FocusSession | null>;
|
||||
nextGoal: string;
|
||||
sceneId: string;
|
||||
timerPresetId: string;
|
||||
soundPresetId: string;
|
||||
focusPlanItemId?: string;
|
||||
}) => Promise<{ nextSession: FocusSession } | null>;
|
||||
abandonSession: () => Promise<boolean>;
|
||||
setGoalInput: (value: string) => void;
|
||||
setLinkedFocusPlanItemId: (value: string | null) => void;
|
||||
setSelectedGoalId: (value: string | null) => void;
|
||||
setShowResumePrompt: (value: boolean) => void;
|
||||
}
|
||||
@@ -54,6 +61,7 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
canStart,
|
||||
currentSession,
|
||||
goalInput,
|
||||
linkedFocusPlanItemId,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
selectedPresetId,
|
||||
@@ -65,9 +73,10 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
pauseSession,
|
||||
resumeSession,
|
||||
restartCurrentPhase,
|
||||
completeSession,
|
||||
advanceGoal,
|
||||
abandonSession,
|
||||
setGoalInput,
|
||||
setLinkedFocusPlanItemId,
|
||||
setSelectedGoalId,
|
||||
setShowResumePrompt,
|
||||
}: UseSpaceWorkspaceSessionControlsParams) => {
|
||||
@@ -110,6 +119,7 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
goal: trimmedGoal,
|
||||
timerPresetId,
|
||||
soundPresetId: selectedPresetId,
|
||||
focusPlanItemId: linkedFocusPlanItemId ?? undefined,
|
||||
entryPoint: pendingSessionEntryPoint,
|
||||
});
|
||||
|
||||
@@ -129,6 +139,7 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
selectedPresetId,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
linkedFocusPlanItemId,
|
||||
setPreviewPlaybackState,
|
||||
startSession,
|
||||
]);
|
||||
@@ -222,33 +233,61 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
|
||||
const handleGoalAdvance = useCallback(async (nextGoal: string) => {
|
||||
const trimmedNextGoal = nextGoal.trim();
|
||||
const trimmedCurrentGoal = goalInput.trim();
|
||||
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
|
||||
|
||||
if (!trimmedNextGoal) {
|
||||
return;
|
||||
if (!trimmedNextGoal || !trimmedCurrentGoal || !timerPresetId || !currentSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentSession) {
|
||||
const completedSession = await completeSession({
|
||||
completionType: 'goal-complete',
|
||||
completedGoal: goalInput.trim(),
|
||||
await unlockPlayback(resolveSoundPlaybackUrl(selectedPresetId));
|
||||
|
||||
const nextState = await advanceGoal({
|
||||
completedGoal: trimmedCurrentGoal,
|
||||
nextGoal: trimmedNextGoal,
|
||||
sceneId: selectedSceneId,
|
||||
timerPresetId,
|
||||
soundPresetId: selectedPresetId,
|
||||
focusPlanItemId: linkedFocusPlanItemId ?? undefined,
|
||||
});
|
||||
|
||||
if (!completedSession) {
|
||||
if (!nextState) {
|
||||
pushStatusLine({
|
||||
message: copy.space.workspace.goalCompleteSyncFailed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
setGoalInput(trimmedNextGoal);
|
||||
setLinkedFocusPlanItemId(nextState.nextSession.focusPlanItemId ?? null);
|
||||
setSelectedGoalId(null);
|
||||
setShowResumePrompt(false);
|
||||
setPendingSessionEntryPoint('goal-complete');
|
||||
setPreviewPlaybackState('paused');
|
||||
setPreviewPlaybackState('running');
|
||||
setWorkspaceMode('focus');
|
||||
pushStatusLine({
|
||||
message: copy.space.workspace.nextGoalReady,
|
||||
message: copy.space.workspace.nextGoalStarted,
|
||||
});
|
||||
}, [completeSession, currentSession, goalInput, pushStatusLine, setGoalInput, setPendingSessionEntryPoint, setPreviewPlaybackState, setSelectedGoalId]);
|
||||
return true;
|
||||
}, [
|
||||
advanceGoal,
|
||||
currentSession,
|
||||
goalInput,
|
||||
linkedFocusPlanItemId,
|
||||
pushStatusLine,
|
||||
resolveSoundPlaybackUrl,
|
||||
selectedPresetId,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
setGoalInput,
|
||||
setLinkedFocusPlanItemId,
|
||||
setPendingSessionEntryPoint,
|
||||
setPreviewPlaybackState,
|
||||
setSelectedGoalId,
|
||||
setShowResumePrompt,
|
||||
setWorkspaceMode,
|
||||
unlockPlayback,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousBodyOverflow = document.body.style.overflow;
|
||||
|
||||
@@ -1,45 +1,48 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { getSceneById, SCENE_THEMES } from '@/entities/scene';
|
||||
import {
|
||||
getSceneStageBackgroundStyle,
|
||||
getSceneStagePhotoUrl,
|
||||
preloadAssetImage,
|
||||
useMediaCatalog,
|
||||
} from '@/entities/media';
|
||||
} from "@/entities/media";
|
||||
import { getSceneById, SCENE_THEMES } from "@/entities/scene";
|
||||
import { GOAL_CHIPS, SOUND_PRESETS, useThoughtInbox } from "@/entities/session";
|
||||
import { useFocusSessionEngine } from "@/features/focus-session";
|
||||
import {
|
||||
GOAL_CHIPS,
|
||||
SOUND_PRESETS,
|
||||
useThoughtInbox,
|
||||
} from '@/entities/session';
|
||||
import { useFocusSessionEngine } from '@/features/focus-session';
|
||||
import { useSoundPlayback, useSoundPresetSelection } from '@/features/sound-preset';
|
||||
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
|
||||
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
|
||||
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
|
||||
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
|
||||
import type { SessionEntryPoint, WorkspaceMode } from '../model/types';
|
||||
import { useSpaceWorkspaceSelection } from '../model/useSpaceWorkspaceSelection';
|
||||
import { useSpaceWorkspaceSessionControls } from '../model/useSpaceWorkspaceSessionControls';
|
||||
useSoundPlayback,
|
||||
useSoundPresetSelection,
|
||||
} from "@/features/sound-preset";
|
||||
import { useHudStatusLine } from "@/shared/lib/useHudStatusLine";
|
||||
import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud";
|
||||
import { SpaceSetupDrawerWidget } from "@/widgets/space-setup-drawer";
|
||||
import { SpaceToolsDockWidget } from "@/widgets/space-tools-dock";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { SessionEntryPoint, WorkspaceMode } from "../model/types";
|
||||
import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection";
|
||||
import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls";
|
||||
import {
|
||||
resolveFocusTimeDisplayFromTimerLabel,
|
||||
resolveInitialSceneId,
|
||||
resolveInitialSoundPreset,
|
||||
resolveInitialTimerLabel,
|
||||
resolveTimerLabelFromPresetId,
|
||||
TIMER_SELECTION_PRESETS,
|
||||
resolveFocusTimeDisplayFromTimerLabel,
|
||||
} from '../model/workspaceSelection';
|
||||
import { FocusTopToast } from './FocusTopToast';
|
||||
} from "../model/workspaceSelection";
|
||||
import { FocusTopToast } from "./FocusTopToast";
|
||||
|
||||
export const SpaceWorkspaceWidget = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const sceneQuery = searchParams.get('scene') ?? searchParams.get('room');
|
||||
const goalQuery = searchParams.get('goal')?.trim() ?? '';
|
||||
const soundQuery = searchParams.get('sound');
|
||||
const timerQuery = searchParams.get('timer');
|
||||
const hasQueryOverrides = Boolean(sceneQuery || goalQuery || soundQuery || timerQuery);
|
||||
const router = useRouter();
|
||||
const sceneQuery = searchParams.get("scene") ?? searchParams.get("room");
|
||||
const goalQuery = searchParams.get("goal")?.trim() ?? "";
|
||||
const focusPlanItemIdQuery = searchParams.get("planItemId");
|
||||
const soundQuery = searchParams.get("sound");
|
||||
const timerQuery = searchParams.get("timer");
|
||||
const hasQueryOverrides = Boolean(
|
||||
sceneQuery || goalQuery || focusPlanItemIdQuery || soundQuery || timerQuery,
|
||||
);
|
||||
|
||||
const {
|
||||
thoughts,
|
||||
@@ -60,22 +63,39 @@ export const SpaceWorkspaceWidget = () => {
|
||||
hasResolvedManifest,
|
||||
} = useMediaCatalog();
|
||||
|
||||
const initialSceneId = useMemo(() => resolveInitialSceneId(sceneQuery, undefined), [sceneQuery]);
|
||||
const initialScene = useMemo(() => getSceneById(initialSceneId) ?? SCENE_THEMES[0], [initialSceneId]);
|
||||
const initialSoundPresetId = useMemo(() => resolveInitialSoundPreset(
|
||||
const initialSceneId = useMemo(
|
||||
() => resolveInitialSceneId(sceneQuery, undefined),
|
||||
[sceneQuery],
|
||||
);
|
||||
const initialScene = useMemo(
|
||||
() => getSceneById(initialSceneId) ?? SCENE_THEMES[0],
|
||||
[initialSceneId],
|
||||
);
|
||||
const initialSoundPresetId = useMemo(
|
||||
() =>
|
||||
resolveInitialSoundPreset(
|
||||
soundQuery,
|
||||
undefined,
|
||||
initialScene.recommendedSoundPresetId,
|
||||
), [initialScene.recommendedSoundPresetId, soundQuery]);
|
||||
const initialTimerLabel = useMemo(() => resolveInitialTimerLabel(
|
||||
),
|
||||
[initialScene.recommendedSoundPresetId, soundQuery],
|
||||
);
|
||||
const initialTimerLabel = useMemo(
|
||||
() =>
|
||||
resolveInitialTimerLabel(
|
||||
timerQuery,
|
||||
undefined,
|
||||
initialScene.recommendedTimerPresetId,
|
||||
), [initialScene.recommendedTimerPresetId, timerQuery]);
|
||||
),
|
||||
[initialScene.recommendedTimerPresetId, timerQuery],
|
||||
);
|
||||
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('setup');
|
||||
const [previewPlaybackState, setPreviewPlaybackState] = useState<'running' | 'paused'>('paused');
|
||||
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] = useState<SessionEntryPoint>('space-setup');
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("setup");
|
||||
const [previewPlaybackState, setPreviewPlaybackState] = useState<
|
||||
"running" | "paused"
|
||||
>("paused");
|
||||
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] =
|
||||
useState<SessionEntryPoint>("space-setup");
|
||||
|
||||
const {
|
||||
selectedPresetId,
|
||||
@@ -85,9 +105,9 @@ export const SpaceWorkspaceWidget = () => {
|
||||
isMuted,
|
||||
setMuted,
|
||||
} = useSoundPresetSelection(initialSoundPresetId);
|
||||
|
||||
const {
|
||||
currentSession,
|
||||
isBootstrapping,
|
||||
isMutating: isSessionMutating,
|
||||
timeDisplay,
|
||||
playbackState,
|
||||
@@ -97,15 +117,16 @@ export const SpaceWorkspaceWidget = () => {
|
||||
resumeSession,
|
||||
restartCurrentPhase,
|
||||
updateCurrentSelection,
|
||||
completeSession,
|
||||
advanceGoal,
|
||||
abandonSession,
|
||||
} = useFocusSessionEngine();
|
||||
|
||||
const isFocusMode = workspaceMode === 'focus';
|
||||
const isFocusMode = workspaceMode === "focus";
|
||||
const resolvedPlaybackState = currentSession?.state ?? previewPlaybackState;
|
||||
const shouldPlaySound = isFocusMode && resolvedPlaybackState === 'running';
|
||||
const shouldPlaySound = isFocusMode && resolvedPlaybackState === "running";
|
||||
|
||||
const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode);
|
||||
const { activeStatus, pushStatusLine, runActiveAction } =
|
||||
useHudStatusLine(isFocusMode);
|
||||
|
||||
const { error: soundPlaybackError, unlockPlayback } = useSoundPlayback({
|
||||
selectedPresetId,
|
||||
@@ -116,7 +137,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
});
|
||||
|
||||
const resolveSoundPlaybackUrl = (presetId: string) => {
|
||||
if (presetId === 'silent') {
|
||||
if (presetId === "silent") {
|
||||
return null;
|
||||
}
|
||||
const asset = soundAssetMap[presetId];
|
||||
@@ -126,6 +147,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
const selection = useSpaceWorkspaceSelection({
|
||||
initialSceneId,
|
||||
initialGoal: goalQuery,
|
||||
initialFocusPlanItemId: focusPlanItemIdQuery,
|
||||
initialTimerLabel,
|
||||
sceneQuery,
|
||||
goalQuery,
|
||||
@@ -156,6 +178,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
canStart: selection.canStart,
|
||||
currentSession,
|
||||
goalInput: selection.goalInput,
|
||||
linkedFocusPlanItemId: selection.linkedFocusPlanItemId,
|
||||
selectedSceneId: selection.selectedSceneId,
|
||||
selectedTimerLabel: selection.selectedTimerLabel,
|
||||
selectedPresetId,
|
||||
@@ -167,27 +190,47 @@ export const SpaceWorkspaceWidget = () => {
|
||||
pauseSession,
|
||||
resumeSession,
|
||||
restartCurrentPhase,
|
||||
completeSession,
|
||||
advanceGoal,
|
||||
abandonSession,
|
||||
setGoalInput: selection.setGoalInput,
|
||||
setLinkedFocusPlanItemId: selection.setLinkedFocusPlanItemId,
|
||||
setSelectedGoalId: selection.setSelectedGoalId,
|
||||
setShowResumePrompt: selection.setShowResumePrompt,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBootstrapping && !currentSession && !hasQueryOverrides) {
|
||||
router.replace("/app");
|
||||
}
|
||||
}, [isBootstrapping, currentSession, hasQueryOverrides, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const preferMobile =
|
||||
typeof window !== 'undefined' ? window.matchMedia('(max-width: 767px)').matches : false;
|
||||
preloadAssetImage(getSceneStagePhotoUrl(selection.selectedScene, selection.selectedSceneAsset, { preferMobile }));
|
||||
typeof window !== "undefined"
|
||||
? window.matchMedia("(max-width: 767px)").matches
|
||||
: false;
|
||||
preloadAssetImage(
|
||||
getSceneStagePhotoUrl(
|
||||
selection.selectedScene,
|
||||
selection.selectedSceneAsset,
|
||||
{ preferMobile },
|
||||
),
|
||||
);
|
||||
}, [selection.selectedScene, selection.selectedSceneAsset]);
|
||||
|
||||
const resolvedTimeDisplay = timeDisplay ?? resolveFocusTimeDisplayFromTimerLabel(selection.selectedTimerLabel);
|
||||
const resolvedTimeDisplay =
|
||||
timeDisplay ??
|
||||
resolveFocusTimeDisplayFromTimerLabel(selection.selectedTimerLabel);
|
||||
|
||||
return (
|
||||
<div className="relative h-dvh overflow-hidden text-white">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -inset-8 bg-cover bg-center will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none"
|
||||
style={getSceneStageBackgroundStyle(selection.selectedScene, selection.selectedSceneAsset)}
|
||||
style={getSceneStageBackgroundStyle(
|
||||
selection.selectedScene,
|
||||
selection.selectedSceneAsset,
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
@@ -208,8 +251,12 @@ export const SpaceWorkspaceWidget = () => {
|
||||
timerPresets={TIMER_SELECTION_PRESETS}
|
||||
canStart={selection.canStart}
|
||||
onSceneSelect={selection.handleSelectScene}
|
||||
onTimerSelect={(timerLabel) => selection.handleSelectTimer(timerLabel, true)}
|
||||
onSoundSelect={(presetId) => selection.handleSelectSound(presetId, true)}
|
||||
onTimerSelect={(timerLabel) =>
|
||||
selection.handleSelectTimer(timerLabel, true)
|
||||
}
|
||||
onSoundSelect={(presetId) =>
|
||||
selection.handleSelectSound(presetId, true)
|
||||
}
|
||||
onGoalChange={selection.handleGoalChange}
|
||||
onGoalChipSelect={selection.handleGoalChipSelect}
|
||||
onStart={controls.handleSetupFocusOpen}
|
||||
@@ -221,10 +268,13 @@ export const SpaceWorkspaceWidget = () => {
|
||||
selection.setGoalInput(selection.resumeGoal);
|
||||
selection.setSelectedGoalId(null);
|
||||
selection.setShowResumePrompt(false);
|
||||
controls.openFocusMode(selection.resumeGoal, 'resume-restore');
|
||||
controls.openFocusMode(
|
||||
selection.resumeGoal,
|
||||
"resume-restore",
|
||||
);
|
||||
},
|
||||
onStartFresh: () => {
|
||||
selection.setGoalInput('');
|
||||
selection.setGoalInput("");
|
||||
selection.setSelectedGoalId(null);
|
||||
selection.setShowResumePrompt(false);
|
||||
},
|
||||
@@ -235,12 +285,13 @@ export const SpaceWorkspaceWidget = () => {
|
||||
|
||||
<SpaceFocusHudWidget
|
||||
goal={selection.goalInput.trim()}
|
||||
microStep={currentSession?.microStep ?? null}
|
||||
timerLabel={selection.selectedTimerLabel}
|
||||
timeDisplay={resolvedTimeDisplay}
|
||||
visible={isFocusMode}
|
||||
hasActiveSession={Boolean(currentSession)}
|
||||
playbackState={resolvedPlaybackState}
|
||||
sessionPhase={phase ?? 'focus'}
|
||||
sessionPhase={phase ?? "focus"}
|
||||
isSessionActionPending={isSessionMutating}
|
||||
canStartSession={controls.canStartSession}
|
||||
canPauseSession={controls.canPauseSession}
|
||||
@@ -260,7 +311,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
|
||||
<FocusTopToast
|
||||
visible={isFocusMode && Boolean(activeStatus)}
|
||||
message={activeStatus?.message ?? ''}
|
||||
message={activeStatus?.message ?? ""}
|
||||
actionLabel={activeStatus?.action?.label}
|
||||
onAction={runActiveAction}
|
||||
/>
|
||||
@@ -276,15 +327,25 @@ export const SpaceWorkspaceWidget = () => {
|
||||
thoughtCount={thoughtCount}
|
||||
selectedPresetId={selectedPresetId}
|
||||
onSceneSelect={selection.handleSelectScene}
|
||||
onTimerSelect={(timerLabel) => selection.handleSelectTimer(timerLabel, true)}
|
||||
onQuickSoundSelect={(presetId) => selection.handleSelectSound(presetId, true)}
|
||||
onTimerSelect={(timerLabel) =>
|
||||
selection.handleSelectTimer(timerLabel, true)
|
||||
}
|
||||
onQuickSoundSelect={(presetId) =>
|
||||
selection.handleSelectSound(presetId, true)
|
||||
}
|
||||
sceneRecommendedSoundLabel={selection.selectedScene.recommendedSound}
|
||||
sceneRecommendedTimerLabel={resolveTimerLabelFromPresetId(selection.selectedScene.recommendedTimerPresetId) ?? selection.selectedTimerLabel}
|
||||
sceneRecommendedTimerLabel={
|
||||
resolveTimerLabelFromPresetId(
|
||||
selection.selectedScene.recommendedTimerPresetId,
|
||||
) ?? selection.selectedTimerLabel
|
||||
}
|
||||
soundVolume={masterVolume}
|
||||
onSetSoundVolume={setMasterVolume}
|
||||
isSoundMuted={isMuted}
|
||||
onSetSoundMuted={setMuted}
|
||||
onCaptureThought={(note) => addThought(note, selection.selectedScene.name)}
|
||||
onCaptureThought={(note) =>
|
||||
addThought(note, selection.selectedScene.name)
|
||||
}
|
||||
onDeleteThought={removeThought}
|
||||
onSetThoughtCompleted={setThoughtCompleted}
|
||||
onRestoreThought={restoreThought}
|
||||
|
||||
@@ -1,34 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { useFocusStats } from '@/features/stats';
|
||||
|
||||
const StatSection = ({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: Array<{ id: string; label: string; value: string; delta: string }>;
|
||||
}) => {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-brand-dark">{title}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="rounded-xl border border-brand-dark/10 bg-white/80 p-4 backdrop-blur-sm"
|
||||
>
|
||||
<p className="text-xs text-brand-dark/58">{item.label}</p>
|
||||
<p className="mt-2 text-xl font-semibold text-brand-dark">{item.value}</p>
|
||||
<p className="mt-1 text-xs text-brand-primary/90">{item.delta}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
const formatMinutes = (minutes: number) => {
|
||||
const safeMinutes = Math.max(0, minutes);
|
||||
@@ -42,55 +16,84 @@ const formatMinutes = (minutes: number) => {
|
||||
return `${hourPart}h ${minutePart}m`;
|
||||
};
|
||||
|
||||
const FactualStatCard = ({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
}) => {
|
||||
return (
|
||||
<article className="rounded-2xl border border-brand-dark/10 bg-white/82 p-4 backdrop-blur-sm">
|
||||
<p className="text-xs text-brand-dark/58">{label}</p>
|
||||
<p className="mt-2 text-xl font-semibold text-brand-dark">{value}</p>
|
||||
<p className="mt-1 text-xs text-brand-dark/48">{hint}</p>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatsOverviewWidget = () => {
|
||||
const { stats } = copy;
|
||||
const { summary, isLoading, error, source, refetch } = useFocusStats();
|
||||
|
||||
const todayItems = [
|
||||
{
|
||||
id: 'today-focus',
|
||||
label: stats.todayFocus,
|
||||
value: formatMinutes(summary.today.focusMinutes),
|
||||
delta: source === 'api' ? stats.apiLabel : stats.mockLabel,
|
||||
hint: source === 'api' ? stats.apiLabel : stats.mockLabel,
|
||||
},
|
||||
{
|
||||
id: 'today-cycles',
|
||||
id: 'today-complete',
|
||||
label: stats.completedCycles,
|
||||
value: `${summary.today.completedCycles}${stats.countUnit}`,
|
||||
delta: `${summary.today.completedCycles > 0 ? '+' : ''}${summary.today.completedCycles}`,
|
||||
hint: '오늘 완료한 focus cycle',
|
||||
},
|
||||
{
|
||||
id: 'today-entry',
|
||||
label: stats.sessionEntries,
|
||||
value: `${summary.today.sessionEntries}${stats.countUnit}`,
|
||||
delta: source === 'api' ? stats.syncedApi : stats.temporary,
|
||||
hint: '오늘 space에 들어간 횟수',
|
||||
},
|
||||
];
|
||||
|
||||
const weeklyItems = [
|
||||
{
|
||||
id: 'week-focus',
|
||||
label: stats.last7DaysFocus,
|
||||
value: formatMinutes(summary.last7Days.focusMinutes),
|
||||
delta: source === 'api' ? stats.actualAggregate : stats.mockAggregate,
|
||||
hint: '최근 7일 총 focus 시간',
|
||||
},
|
||||
{
|
||||
id: 'week-best-day',
|
||||
label: stats.bestDay,
|
||||
value: summary.last7Days.bestDayLabel,
|
||||
delta: formatMinutes(summary.last7Days.bestDayFocusMinutes),
|
||||
id: 'week-started',
|
||||
label: '시작한 세션',
|
||||
value: `${summary.last7Days.startedSessions}${stats.countUnit}`,
|
||||
hint: '최근 7일 시작 횟수',
|
||||
},
|
||||
{
|
||||
id: 'week-consistency',
|
||||
label: stats.streak,
|
||||
value: `${summary.last7Days.streakDays}${stats.dayUnit}`,
|
||||
delta: summary.last7Days.streakDays > 0 ? stats.streakActive : stats.streakStart,
|
||||
id: 'week-completed',
|
||||
label: '완료한 세션',
|
||||
value: `${summary.last7Days.completedSessions}${stats.countUnit}`,
|
||||
hint: '최근 7일 goal/timer 완료 횟수',
|
||||
},
|
||||
{
|
||||
id: 'week-carry',
|
||||
label: '이월된 블록',
|
||||
value: `${summary.last7Days.carriedOverCount}${stats.countUnit}`,
|
||||
hint: '다음 날로 이어진 계획 수',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_18%_0%,rgba(167,204,237,0.45),transparent_50%),radial-gradient(circle_at_88%_8%,rgba(191,219,254,0.4),transparent_42%),linear-gradient(170deg,#f8fafc_0%,#eef4fb_52%,#e9f1fa_100%)] text-brand-dark">
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-10 pt-6 sm:px-6">
|
||||
<header className="mb-6 flex items-center justify-between rounded-xl border border-brand-dark/12 bg-white/72 px-4 py-3 backdrop-blur-sm">
|
||||
<div className="mx-auto w-full max-w-5xl px-4 pb-10 pt-6 sm:px-6">
|
||||
<header className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-brand-dark/12 bg-white/72 px-4 py-3 backdrop-blur-sm">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{stats.title}</h1>
|
||||
<p className="mt-1 text-xs text-brand-dark/56">해석형 insight 없이 factual summary만 표시합니다.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/app"
|
||||
className="rounded-lg border border-brand-dark/16 px-3 py-1.5 text-xs text-brand-dark/82 transition hover:bg-white/90"
|
||||
@@ -100,7 +103,7 @@ export const StatsOverviewWidget = () => {
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-xl border border-brand-dark/12 bg-white/70 px-4 py-3 backdrop-blur-sm">
|
||||
<section className="rounded-2xl border border-brand-dark/12 bg-white/70 px-4 py-3 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-brand-dark/72">
|
||||
@@ -126,12 +129,27 @@ export const StatsOverviewWidget = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<StatSection title={stats.today} items={todayItems} />
|
||||
<StatSection title={stats.last7Days} items={weeklyItems} />
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-brand-dark">{stats.today}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{todayItems.map((item) => (
|
||||
<FactualStatCard key={item.id} label={item.label} value={item.value} hint={item.hint} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-brand-dark">{stats.last7Days}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{weeklyItems.map((item) => (
|
||||
<FactualStatCard key={item.id} label={item.label} value={item.value} hint={item.hint} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-brand-dark">{stats.chartTitle}</h2>
|
||||
<div className="rounded-xl border border-dashed border-brand-dark/18 bg-white/65 p-5">
|
||||
<div className="rounded-2xl border border-dashed border-brand-dark/18 bg-white/65 p-5">
|
||||
<div className="h-52 rounded-lg border border-brand-dark/12 bg-[linear-gradient(180deg,rgba(148,163,184,0.15),rgba(148,163,184,0.04))] p-4">
|
||||
{summary.trend.length > 0 ? (
|
||||
<div className="flex h-full items-end gap-2">
|
||||
@@ -145,9 +163,7 @@ export const StatsOverviewWidget = () => {
|
||||
style={{ height: `${barHeight}%` }}
|
||||
title={stats.barTitle(point.date, point.focusMinutes)}
|
||||
/>
|
||||
<span className="text-[10px] text-brand-dark/56">
|
||||
{point.date.slice(5)}
|
||||
</span>
|
||||
<span className="text-[10px] text-brand-dark/56">{point.date.slice(5)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -155,9 +171,7 @@ export const StatsOverviewWidget = () => {
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-brand-dark/56">
|
||||
{summary.trend.length > 0
|
||||
? stats.chartWithTrend
|
||||
: stats.chartWithoutTrend}
|
||||
{summary.trend.length > 0 ? stats.chartWithTrend : stats.chartWithoutTrend}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user