Compare commits
10 Commits
3fbeee059a
...
5f7ca99f44
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f7ca99f44 | |||
| 8917cd8e77 | |||
| c64a08ddf2 | |||
| 1c7c6d396f | |||
| 3c6c5e6aa0 | |||
| 922342b115 | |||
| a056fc841e | |||
| f3f0518588 | |||
| b1bafd5e9a | |||
| 245746a996 |
@@ -4,6 +4,30 @@ Last Updated: 2026-03-05
|
||||
|
||||
## DONE
|
||||
|
||||
- Focus 피드백 채널 단일화:
|
||||
- HUD 내부 status line을 제거하고 상단 중앙 고정 토스트로 통합
|
||||
- Notes 저장/Undo, Goal 전환, 잠금 안내 피드백이 동일 위치에서 노출
|
||||
- Free 코어 루프 개방:
|
||||
- Quick Controls Time의 `90/20` 잠금을 제거
|
||||
- 기본 Sound 잠금 제거로 Free에서도 기본 3~6 프리셋 선택 가능
|
||||
- Pro 가치 재배치:
|
||||
- Pro 잠금 대상을 `Scene Packs / Sound Packs / Profiles`로 재정의
|
||||
- 기본 Scene/Time/Sound는 잠금 없이 선택 중심으로 정리
|
||||
- Control Center UI 재구성:
|
||||
- Scene/Time/Sound 중심 구조 유지
|
||||
- 추천 조합을 정보 1줄로 축소(비인터랙션)
|
||||
- 하단에 Packs/Profiles 요약 카드(작은 🔒 배지) 추가
|
||||
- Paywall 의도 기반 트리거 적용:
|
||||
- 잠금 카드 클릭 시에만 Paywall Sheet 오픈
|
||||
- Plan Pill(NORMAL) 클릭은 즉시 결제창 대신 상태 안내만 표시
|
||||
- Paywall Sheet를 3개 가치 포인트 + 2개 CTA로 간결화
|
||||
- Focus-First 전환:
|
||||
- Quick Controls의 `기본/몰입` 토글 제거
|
||||
- HUD를 외부 모드 상태 없이 기본 몰입 톤으로 고정
|
||||
- 컨트롤 노출은 패널 열림 상태에서만 보이도록 단순화
|
||||
- 표시 정책 옵션 추가:
|
||||
- Quick Controls 패널 하단에 `컨트롤 자동 숨김` 옵션 추가
|
||||
- 옵션 ON 상태에서 Control Center 8초 무입력 시 자동 닫힘 처리
|
||||
- Quick Controls 모드 전환 UI 재정렬:
|
||||
- 헤더에서 모드 토글 UI를 제거하고 `Plan + 닫기`만 유지
|
||||
- 패널 바디 첫 섹션에 `기본/몰입` segmented pill 배치
|
||||
@@ -24,8 +48,12 @@ Last Updated: 2026-03-05
|
||||
- 우하단 Sound Quick 경로를 override 적용의 명시적 경로로 분리:
|
||||
- `onQuickSoundSelect` 콜백으로 연결해 `override.sound` 규칙을 코드 레벨에서 고정
|
||||
- 세션 상태 더미 저장/복원 추가:
|
||||
- `sceneId`, `timerPresetId`, `soundPresetId`, `override(sound/timer)`를 localStorage에 저장
|
||||
- `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`를 localStorage에 저장
|
||||
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
|
||||
- `/space` 진입 Resume CTA 추가:
|
||||
- 저장된 목표가 있고 쿼리 오버라이드가 없을 때 `지난 한 조각 이어서` 블록 1회 노출
|
||||
- `이어서 시작`: 저장 목표로 즉시 Focus 진입
|
||||
- `새로 시작`: 목표를 비워 새 세션 입력 흐름으로 전환
|
||||
- 세션 복구 운영 문서 추가:
|
||||
- `docs/06_commit_convention.md`
|
||||
- `docs/07_session_recovery.md`
|
||||
@@ -125,10 +153,9 @@ Last Updated: 2026-03-05
|
||||
|
||||
## NEXT
|
||||
|
||||
1. Scene 추천 매핑(`recommendedSoundPresetId`, `recommendedTimerPresetId`)의 큐레이션 품질 점검 및 보정
|
||||
2. Setup Drawer에서 수동 선택한 타이머/사운드도 override 정책과 사용자 기대치가 일치하는지 UX 검증
|
||||
3. Focus 전환/Scene 변경/추천 복원 시 HUD 피드백 노출 정책(무표시 vs 최소 표시) 최종 확정
|
||||
4. 터치 환경에서 우측 도구 레일 발견성(미니 핸들 UX) 보완 여부 확정
|
||||
1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 전환 감도/카피 마감
|
||||
2. Notes(쓰기) / Inbox(읽기·정리) 복귀 흐름과 30초 숨고르기 톤 정리
|
||||
3. Stage 가독성/모션/레이어 폴리시 최종 통일
|
||||
|
||||
## RISKS
|
||||
|
||||
@@ -150,18 +177,27 @@ Last Updated: 2026-03-05
|
||||
- 전체 배경 블러 강도 증가로 저사양 환경에서 GPU 부담이 늘 수 있어 실기기 체감 점검 필요
|
||||
- 밝은 배경 사진과 라이트 헤더 조합에서 상단 경계 인지가 약해질 수 있어 대비 점검 필요
|
||||
- 등급 칩 최소폭 증가로 초소형 화면에서 헤더 가로 여유가 줄어들 수 있어 간격 점검 필요
|
||||
- Plan Pill에서 바로 결제창이 열리지 않도록 바뀌어, 일부 사용자는 업그레이드 진입 경로를 늦게 인지할 수 있음
|
||||
- Packs 카드가 더미 상태이므로 Pro 가치 설명 카피가 약하면 클릭 동기가 낮아질 수 있음
|
||||
|
||||
## CHANGED FILES
|
||||
|
||||
- `src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx`
|
||||
- (최근 workflow 반영)
|
||||
- `src/widgets/space-workspace/ui/FocusTopToast.tsx`
|
||||
- `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
|
||||
- `src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx`
|
||||
- `src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx`
|
||||
- `src/entities/plan/model/mockPlan.ts`
|
||||
- `src/entities/plan/model/types.ts`
|
||||
- `src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx`
|
||||
- `src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx`
|
||||
- `src/features/paywall-sheet/ui/PaywallSheetContent.tsx`
|
||||
- `src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx`
|
||||
- `src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx`
|
||||
- `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
|
||||
- `src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx`
|
||||
- `src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx`
|
||||
- `src/entities/room/model/types.ts`
|
||||
- `src/entities/room/model/rooms.ts`
|
||||
- `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
|
||||
- `src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx`
|
||||
- `src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx`
|
||||
- `src/widgets/space-tools-dock/model/applyQuickPack.ts` (삭제)
|
||||
- `docs/06_commit_convention.md`
|
||||
- `docs/07_session_recovery.md`
|
||||
|
||||
@@ -14,16 +14,31 @@ Last Updated: 2026-03-05
|
||||
|
||||
## 현재 우선순위
|
||||
|
||||
1. Scene 추천 매핑 품질 점검(공간별 사운드/타이머 추천값 보정)
|
||||
2. override 정책(수동 선택 후 Scene 변경 시 유지)의 사용자 기대치 검증
|
||||
3. 터치 환경 도구 레일 발견성(미니 핸들 UX) 보완 여부 결정
|
||||
1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 마감 품질 점검
|
||||
2. Notes(쓰기) / Inbox(읽기·정리) 복귀 동선과 30초 숨고르기 카피 정리
|
||||
3. Stage 가독성/모션/레이어 폴리시 최종 정리
|
||||
|
||||
## 최근 세션 상태
|
||||
|
||||
- Quick Controls 모드 전환 UI를 헤더에서 제거하고 패널 바디 첫 섹션으로 이동했다.
|
||||
- 헤더는 Plan + 닫기만 유지
|
||||
- 바디에는 `기본/몰입` segmented pill + 설명 1줄을 배치
|
||||
- 모드 상태는 Focus HUD 톤과 연동되도록 workspace 경로에 연결했다.
|
||||
- Focus 피드백 채널을 상단 중앙 1곳으로 통합했다.
|
||||
- HUD 내부 status line 제거
|
||||
- Notes/Goal/잠금 피드백이 동일 위치 토스트로 표시
|
||||
- 기본 기능 잠금을 해소했다.
|
||||
- Time `90/20`을 Free로 개방
|
||||
- 기본 Sound 잠금 제거
|
||||
- Pro 잠금 구조를 Packs/Profiles 중심으로 재구성했다.
|
||||
- `Scene Packs / Sound Packs / Profiles` 요약 카드 추가
|
||||
- 기본 Scene/Time/Sound는 잠금 없이 선택 가능
|
||||
- Paywall 시트는 잠금 카드 클릭에서만 열리도록 바꿨다.
|
||||
- Plan Pill(NORMAL) 클릭은 즉시 결제창 오픈 대신 상태 안내만 노출
|
||||
- Paywall 카피를 3개 가치 포인트 + 2개 CTA로 간결화
|
||||
- Focus-First 구조로 전환했다.
|
||||
- Quick Controls의 모드 전환 토글(기본/몰입)을 제거했다.
|
||||
- HUD는 외부 모드 상태 없이 기본 몰입 톤으로 유지한다.
|
||||
- 컨트롤 노출은 패널 열림 상태에서만 보이도록 단순화했다.
|
||||
- Quick Controls 패널 내부에 표시 정책 옵션을 추가했다.
|
||||
- 옵션: `컨트롤 자동 숨김`
|
||||
- ON 상태에서 Control Center가 8초 무입력이면 자동 닫힘 처리
|
||||
- `/space`에 Scene 추천 자동 적용 규칙을 도입했다.
|
||||
- Room 데이터에 `recommendedSoundPresetId`, `recommendedTimerPresetId`를 추가했다.
|
||||
- 초기 진입/Scene 변경 시 override가 없는 항목만 추천값으로 자동 반영된다.
|
||||
@@ -35,8 +50,11 @@ Last Updated: 2026-03-05
|
||||
- 추천 정보 1줄 + `추천으로 되돌리기`만 유지
|
||||
- 우하단 Sound Quick 선택 경로를 `onQuickSoundSelect`로 분리해 override.sound 규칙을 명시했다.
|
||||
- `/space` 선택 상태 로컬 저장/복원을 추가했다.
|
||||
- 저장: `sceneId`, `timerPresetId`, `soundPresetId`, `override(sound/timer)`
|
||||
- 저장: `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`
|
||||
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
|
||||
- `/space` 진입 시 Resume CTA를 추가했다.
|
||||
- 저장된 목표가 있고 쿼리 오버라이드가 없으면 `지난 한 조각 이어서`를 1회 노출
|
||||
- `이어서 시작`은 즉시 Focus 진입, `새로 시작`은 목표를 비운 새 세션으로 전환
|
||||
- 세션 복구용 문서/템플릿/스크립트가 준비되어 있다.
|
||||
- `workFlow.md`는 토큰 절약 모드를 사용한다.
|
||||
- `/space` 하단 사운드 바를 제거하고 오른쪽 `🎧 Sound` 시트로 이동했다.
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import storybook from "eslint-plugin-storybook";
|
||||
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
let storybookConfig = [];
|
||||
|
||||
try {
|
||||
// Optional dependency: lint should still run when Storybook plugin is not installed.
|
||||
const storybook = await import("eslint-plugin-storybook");
|
||||
storybookConfig = storybook.default?.configs?.["flat/recommended"] ?? [];
|
||||
} catch {}
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
@@ -16,7 +21,7 @@ const eslintConfig = defineConfig([
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
...storybook.configs["flat/recommended"]
|
||||
...storybookConfig
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import type { PlanLockedPack } from './types';
|
||||
import type { ProFeatureCard } from './types';
|
||||
|
||||
export const PRO_LOCKED_ROOM_IDS = ['outer-space', 'snow-mountain'];
|
||||
export const PRO_LOCKED_TIMER_LABELS = ['90/20'];
|
||||
export const PRO_LOCKED_SOUND_IDS = ['cafe-work', 'fireplace'];
|
||||
export const PRO_LOCKED_ROOM_IDS: string[] = [];
|
||||
export const PRO_LOCKED_TIMER_LABELS: string[] = [];
|
||||
export const PRO_LOCKED_SOUND_IDS: string[] = [];
|
||||
|
||||
export const PRO_PRESET_PACKS: PlanLockedPack[] = [
|
||||
export const PRO_FEATURE_CARDS: ProFeatureCard[] = [
|
||||
{
|
||||
id: 'deep-work',
|
||||
name: 'Deep Work',
|
||||
description: '긴 몰입 세션을 위한 무드 묶음',
|
||||
id: 'scene-packs',
|
||||
name: 'Scene Packs',
|
||||
description: '프리미엄 공간 묶음과 장면 변주',
|
||||
},
|
||||
{
|
||||
id: 'gentle',
|
||||
name: 'Gentle',
|
||||
description: '저자극 휴식 중심 프리셋',
|
||||
id: 'sound-packs',
|
||||
name: 'Sound Packs',
|
||||
description: '확장 사운드 프리셋 묶음',
|
||||
},
|
||||
{
|
||||
id: 'cafe',
|
||||
name: 'Cafe',
|
||||
description: '카페톤 배경과 사운드 조합',
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
description: '내 기본 세팅 저장/불러오기',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,3 +5,11 @@ export interface PlanLockedPack {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type ProFeatureId = 'scene-packs' | 'sound-packs' | 'profiles';
|
||||
|
||||
export interface ProFeatureCard {
|
||||
id: ProFeatureId;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
@@ -1,70 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface PaywallSheetContentProps {
|
||||
onStartPro: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type BillingCycle = 'monthly' | 'yearly';
|
||||
|
||||
const BILLING_OPTIONS: Array<{ id: BillingCycle; label: string; caption: string }> = [
|
||||
{ id: 'monthly', label: '월간', caption: '월 9,900원 (더미)' },
|
||||
{ id: 'yearly', label: '연간', caption: '연 79,000원 (더미)' },
|
||||
];
|
||||
|
||||
const VALUE_POINTS = [
|
||||
'더 많은 공간 / 고화질 배경',
|
||||
'작업용 BGM / 사운드 확장',
|
||||
'프리셋 팩 / 고급 타이머',
|
||||
'프리미엄 Scene Packs',
|
||||
'확장 Sound Packs',
|
||||
'프로필 저장 / 불러오기',
|
||||
];
|
||||
|
||||
export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContentProps) => {
|
||||
const [cycle, setCycle] = useState<BillingCycle>('monthly');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-lg font-semibold tracking-tight text-white">PRO로 더 깊게</h3>
|
||||
<p className="text-xs text-white/62">필요할 때만 잠금 해제하고, 무대는 그대로 유지해요.</p>
|
||||
<h3 className="text-lg font-semibold tracking-tight text-white">PRO에서 더 많은 공간과 사운드를 열어둘 수 있어요.</h3>
|
||||
<p className="text-xs text-white/62">잠금 항목을 누른 순간에만 열리는 더미 결제 시트입니다.</p>
|
||||
</header>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{VALUE_POINTS.map((point) => (
|
||||
<li key={point} className="rounded-xl border border-white/14 bg-white/[0.04] px-3 py-2 text-sm text-white/86">
|
||||
<li key={point} className="rounded-xl border border-white/14 bg-white/[0.04] px-3 py-2 text-sm text-white/82">
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<section className="rounded-2xl border border-white/14 bg-white/[0.04] p-3">
|
||||
<p className="text-xs text-white/60">가격</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{BILLING_OPTIONS.map((option) => {
|
||||
const selected = option.id === cycle;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => setCycle(option.id)}
|
||||
className={cn(
|
||||
'rounded-xl border px-2.5 py-2 text-left text-xs transition-colors',
|
||||
selected
|
||||
? 'border-sky-200/40 bg-sky-200/14 text-white'
|
||||
: 'border-white/16 bg-white/[0.03] text-white/72 hover:bg-white/[0.08]',
|
||||
)}
|
||||
>
|
||||
<p className="font-medium">{option.label}</p>
|
||||
<p className="mt-0.5 text-[10px] text-white/56">{option.caption}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,32 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useToast } from '@/shared/ui';
|
||||
import {
|
||||
RECOVERY_30S_BUTTON_LABEL,
|
||||
RECOVERY_30S_TOAST_MESSAGE,
|
||||
} from './copy';
|
||||
|
||||
const MODE_DURATION_MS = 2000;
|
||||
const HINT_DURATION_MS = 1800;
|
||||
|
||||
export const useRestart30s = () => {
|
||||
const { pushToast } = useToast();
|
||||
const [isBreatheMode, setBreatheMode] = useState(false);
|
||||
const [hintMessage, setHintMessage] = useState<string | null>(null);
|
||||
const resetTimerRef = useRef<number | null>(null);
|
||||
const hintTimerRef = useRef<number | null>(null);
|
||||
|
||||
const clearTimers = () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
resetTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (hintTimerRef.current !== null) {
|
||||
window.clearTimeout(hintTimerRef.current);
|
||||
hintTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,16 +23,6 @@ export const useRestart30s = () => {
|
||||
const triggerRestart = () => {
|
||||
clearTimers();
|
||||
setBreatheMode(true);
|
||||
setHintMessage(RECOVERY_30S_TOAST_MESSAGE);
|
||||
|
||||
pushToast({
|
||||
title: RECOVERY_30S_BUTTON_LABEL,
|
||||
description: RECOVERY_30S_TOAST_MESSAGE,
|
||||
});
|
||||
|
||||
hintTimerRef.current = window.setTimeout(() => {
|
||||
setHintMessage(null);
|
||||
}, HINT_DURATION_MS);
|
||||
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
setBreatheMode(false);
|
||||
@@ -56,7 +31,6 @@ export const useRestart30s = () => {
|
||||
|
||||
return {
|
||||
isBreatheMode,
|
||||
hintMessage,
|
||||
triggerRestart,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,36 +3,32 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { PlanTier } from '@/entities/plan';
|
||||
import {
|
||||
PRO_LOCKED_ROOM_IDS,
|
||||
PRO_LOCKED_TIMER_LABELS,
|
||||
PRO_FEATURE_CARDS,
|
||||
} from '@/entities/plan';
|
||||
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room';
|
||||
import type { TimerPreset } from '@/entities/session';
|
||||
import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
||||
import { Toggle } from '@/shared/ui';
|
||||
|
||||
interface ControlCenterSheetWidgetProps {
|
||||
plan: PlanTier;
|
||||
rooms: RoomTheme[];
|
||||
selectedRoomId: string;
|
||||
selectedTimerLabel: string;
|
||||
selectedSoundPresetId: string;
|
||||
sceneRecommendedSoundLabel: string;
|
||||
sceneRecommendedTimerLabel: string;
|
||||
timerPresets: TimerPreset[];
|
||||
autoHideControls: boolean;
|
||||
onAutoHideControlsChange: (next: boolean) => void;
|
||||
onSelectRoom: (roomId: string) => void;
|
||||
onSelectTimer: (timerLabel: string) => void;
|
||||
onSelectSound: (presetId: string) => void;
|
||||
onSelectProFeature: (featureId: string) => void;
|
||||
onLockedClick: (source: string) => void;
|
||||
onResetToRecommended: () => void;
|
||||
}
|
||||
|
||||
const LockBadge = () => {
|
||||
return (
|
||||
<span className="absolute right-2 top-2 rounded-full border border-white/20 bg-black/46 px-1.5 py-0.5 text-[9px] font-semibold tracking-[0.08em] text-white/86">
|
||||
LOCK PRO
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const SectionTitle = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<header className="flex items-end justify-between gap-2">
|
||||
@@ -47,13 +43,17 @@ export const ControlCenterSheetWidget = ({
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
selectedTimerLabel,
|
||||
selectedSoundPresetId,
|
||||
sceneRecommendedSoundLabel,
|
||||
sceneRecommendedTimerLabel,
|
||||
timerPresets,
|
||||
autoHideControls,
|
||||
onAutoHideControlsChange,
|
||||
onSelectRoom,
|
||||
onSelectTimer,
|
||||
onSelectSound,
|
||||
onSelectProFeature,
|
||||
onLockedClick,
|
||||
onResetToRecommended,
|
||||
}: ControlCenterSheetWidgetProps) => {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const isPro = plan === 'pro';
|
||||
@@ -81,18 +81,12 @@ export const ControlCenterSheetWidget = ({
|
||||
>
|
||||
{rooms.slice(0, 6).map((room) => {
|
||||
const selected = room.id === selectedRoomId;
|
||||
const locked = !isPro && PRO_LOCKED_ROOM_IDS.includes(room.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={room.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (locked) {
|
||||
onLockedClick(`공간: ${room.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectRoom(room.id);
|
||||
}}
|
||||
className={cn(
|
||||
@@ -101,13 +95,12 @@ export const ControlCenterSheetWidget = ({
|
||||
reducedMotion ? '' : 'hover:-translate-y-0.5',
|
||||
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16',
|
||||
)}
|
||||
>
|
||||
<div aria-hidden className="absolute inset-0 bg-cover bg-center" style={getRoomCardBackgroundStyle(room)} />
|
||||
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" />
|
||||
{locked ? <LockBadge /> : null}
|
||||
<div className="absolute inset-x-2 bottom-2 min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white/90">{room.name}</p>
|
||||
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p>
|
||||
>
|
||||
<div aria-hidden className="absolute inset-0 bg-cover bg-center" style={getRoomCardBackgroundStyle(room)} />
|
||||
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" />
|
||||
<div className="absolute inset-x-2 bottom-2 min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white/90">{room.name}</p>
|
||||
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -120,18 +113,12 @@ export const ControlCenterSheetWidget = ({
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{timerPresets.slice(0, 3).map((preset) => {
|
||||
const selected = preset.label === selectedTimerLabel;
|
||||
const locked = !isPro && PRO_LOCKED_TIMER_LABELS.includes(preset.label);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (locked) {
|
||||
onLockedClick(`타이머: ${preset.label}`);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectTimer(preset.label);
|
||||
}}
|
||||
className={cn(
|
||||
@@ -143,7 +130,37 @@ export const ControlCenterSheetWidget = ({
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
{locked ? <span className="ml-1 text-[9px] text-white/66">LOCK PRO</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2.5 rounded-2xl border border-white/12 bg-black/22 p-3.5 backdrop-blur-md">
|
||||
<SectionTitle
|
||||
title="Sound"
|
||||
description={SOUND_PRESETS.find((preset) => preset.id === selectedSoundPresetId)?.label ?? '기본'}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SOUND_PRESETS.slice(0, 6).map((preset) => {
|
||||
const selected = preset.id === selectedSoundPresetId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectSound(preset.id);
|
||||
}}
|
||||
className={cn(
|
||||
'relative rounded-xl border px-3 py-2 text-[11px]',
|
||||
colorMotionClass,
|
||||
selected
|
||||
? 'border-sky-200/42 bg-sky-200/16 text-white'
|
||||
: 'border-white/18 bg-white/[0.04] text-white/74 hover:bg-white/[0.1]',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -152,17 +169,54 @@ export const ControlCenterSheetWidget = ({
|
||||
|
||||
<div className="space-y-1.5 rounded-xl border border-white/12 bg-white/[0.03] px-3 py-2.5">
|
||||
<p className="text-[11px] text-white/58">추천: {sceneRecommendedSoundLabel} · {sceneRecommendedTimerLabel}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetToRecommended}
|
||||
className={cn(
|
||||
'text-left text-[11px] text-white/72 transition-colors hover:text-white/90',
|
||||
colorMotionClass,
|
||||
)}
|
||||
>
|
||||
추천으로 되돌리기
|
||||
</button>
|
||||
<p className="text-[10px] text-white/48">추천 조합은 참고 정보로만 제공돼요.</p>
|
||||
</div>
|
||||
|
||||
<section className="space-y-2 rounded-2xl border border-white/12 bg-black/18 p-3 backdrop-blur-md">
|
||||
<SectionTitle title="Packs" description="확장/개인화" />
|
||||
<div className="space-y-1.5">
|
||||
{PRO_FEATURE_CARDS.map((feature) => {
|
||||
const locked = !isPro;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={feature.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (locked) {
|
||||
onLockedClick(feature.name);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectProFeature(feature.id);
|
||||
}}
|
||||
className="flex w-full items-center justify-between rounded-xl border border-white/14 bg-white/[0.03] px-3 py-2 text-left transition-colors hover:bg-white/[0.08]"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-white/88">{feature.name}</p>
|
||||
<p className="mt-0.5 text-[10px] text-white/56">{feature.description}</p>
|
||||
</div>
|
||||
{locked ? <span className="text-xs text-white/70">🔒</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2 rounded-2xl border border-white/12 bg-black/18 px-3 py-2.5 backdrop-blur-md">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] text-white/72">컨트롤 자동 숨김</p>
|
||||
<p className="mt-0.5 text-[10px] text-white/52">입력이 없으면 잠시 후 패널을 닫아요.</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={autoHideControls}
|
||||
onChange={onAutoHideControlsChange}
|
||||
ariaLabel="컨트롤 자동 숨김"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HudStatusLineItem, HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
||||
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
||||
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
||||
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
|
||||
import { GoalCompleteSheet } from './GoalCompleteSheet';
|
||||
@@ -10,8 +10,6 @@ interface SpaceFocusHudWidgetProps {
|
||||
timerLabel: string;
|
||||
visible: boolean;
|
||||
onGoalUpdate: (nextGoal: string) => void;
|
||||
statusLine: HudStatusLineItem | null;
|
||||
onStatusAction: () => void;
|
||||
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
||||
}
|
||||
|
||||
@@ -20,8 +18,6 @@ export const SpaceFocusHudWidget = ({
|
||||
timerLabel,
|
||||
visible,
|
||||
onGoalUpdate,
|
||||
statusLine,
|
||||
onStatusAction,
|
||||
onStatusMessage,
|
||||
}: SpaceFocusHudWidgetProps) => {
|
||||
const reducedMotion = useReducedMotion();
|
||||
@@ -100,18 +96,9 @@ export const SpaceFocusHudWidget = ({
|
||||
<SpaceTimerHudWidget
|
||||
timerLabel={timerLabel}
|
||||
goal={goal}
|
||||
statusLine={
|
||||
statusLine
|
||||
? {
|
||||
message: statusLine.message,
|
||||
actionLabel: statusLine.action?.label,
|
||||
}
|
||||
: null
|
||||
}
|
||||
isImmersionMode
|
||||
className="pr-[4.2rem]"
|
||||
onGoalCompleteRequest={handleOpenCompleteSheet}
|
||||
onStatusAction={onStatusAction}
|
||||
onPlaybackStateChange={(state) => {
|
||||
if (reducedMotion) {
|
||||
playbackStateRef.current = state;
|
||||
|
||||
@@ -28,6 +28,11 @@ interface SpaceSetupDrawerWidgetProps {
|
||||
onGoalChange: (value: string) => void;
|
||||
onGoalChipSelect: (chip: GoalChip) => void;
|
||||
onStart: () => void;
|
||||
resumeHint?: {
|
||||
goal: string;
|
||||
onResume: () => void;
|
||||
onStartFresh: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface SummaryChipProps {
|
||||
@@ -74,6 +79,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
onGoalChange,
|
||||
onGoalChipSelect,
|
||||
onStart,
|
||||
resumeHint,
|
||||
}: SpaceSetupDrawerWidgetProps) => {
|
||||
const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -151,6 +157,29 @@ export const SpaceSetupDrawerWidget = ({
|
||||
<p className="text-xs text-white/60">목표만 적으면 바로 Focus 모드로 넘어가요.</p>
|
||||
</header>
|
||||
|
||||
{resumeHint ? (
|
||||
<div className="mb-3 rounded-2xl border border-white/14 bg-black/22 px-3 py-2.5">
|
||||
<p className="text-[11px] text-white/62">지난 한 조각 이어서</p>
|
||||
<p className="mt-1 truncate text-sm text-white/88">{resumeHint.goal}</p>
|
||||
<div className="mt-2 flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resumeHint.onStartFresh}
|
||||
className="rounded-full border border-white/16 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/72 transition-colors hover:bg-white/[0.1]"
|
||||
>
|
||||
새로 시작
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resumeHint.onResume}
|
||||
className="rounded-full border border-sky-200/34 bg-sky-200/14 px-2.5 py-1 text-[11px] text-white/90 transition-colors hover:bg-sky-200/22"
|
||||
>
|
||||
이어서 시작
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative mb-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<SummaryChip
|
||||
|
||||
@@ -12,13 +12,8 @@ interface SpaceTimerHudWidgetProps {
|
||||
goal: string;
|
||||
className?: string;
|
||||
isImmersionMode?: boolean;
|
||||
statusLine?: {
|
||||
message: string;
|
||||
actionLabel?: string;
|
||||
} | null;
|
||||
onPlaybackStateChange?: (state: 'running' | 'paused') => void;
|
||||
onGoalCompleteRequest?: () => void;
|
||||
onStatusAction?: () => void;
|
||||
}
|
||||
|
||||
const HUD_ACTIONS = [
|
||||
@@ -32,10 +27,8 @@ export const SpaceTimerHudWidget = ({
|
||||
goal,
|
||||
className,
|
||||
isImmersionMode = false,
|
||||
statusLine = null,
|
||||
onPlaybackStateChange,
|
||||
onGoalCompleteRequest,
|
||||
onStatusAction,
|
||||
}: SpaceTimerHudWidgetProps) => {
|
||||
const { isBreatheMode, triggerRestart } = useRestart30s();
|
||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
|
||||
@@ -132,30 +125,6 @@ export const SpaceTimerHudWidget = ({
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-2 left-3.5 z-[12] max-w-[72%]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/12 bg-black/24 px-2.5 py-1 text-[10px] text-white/72 backdrop-blur-sm transition-all duration-[220ms] ease-out motion-reduce:duration-0',
|
||||
statusLine ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{statusLine?.message ?? ''}</span>
|
||||
{statusLine?.actionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStatusAction}
|
||||
className="pointer-events-auto shrink-0 text-[10px] font-medium text-white/84 underline-offset-2 transition-colors hover:text-white hover:underline"
|
||||
>
|
||||
{statusLine.actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { ExitHoldButton } from '@/features/exit-hold';
|
||||
import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet';
|
||||
import { PlanPill } from '@/features/plan-pill';
|
||||
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
|
||||
import { useToast } from '@/shared/ui';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { ControlCenterSheetWidget } from '@/widgets/control-center-sheet';
|
||||
import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
|
||||
@@ -40,8 +39,8 @@ interface SpaceToolsDockWidgetProps {
|
||||
onDeleteThought: (thoughtId: string) => RecentThought | null;
|
||||
onSetThoughtCompleted: (thoughtId: string, isCompleted: boolean) => RecentThought | null;
|
||||
onRestoreThought: (thought: RecentThought) => void;
|
||||
onRestoreThoughts: (thoughts: RecentThought[]) => void;
|
||||
onClearInbox: () => RecentThought[];
|
||||
onResetToSceneRecommended: () => void;
|
||||
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
||||
onExitRequested: () => void;
|
||||
}
|
||||
@@ -68,14 +67,14 @@ export const SpaceToolsDockWidget = ({
|
||||
onDeleteThought,
|
||||
onSetThoughtCompleted,
|
||||
onRestoreThought,
|
||||
onRestoreThoughts,
|
||||
onClearInbox,
|
||||
onResetToSceneRecommended,
|
||||
onStatusMessage,
|
||||
onExitRequested,
|
||||
}: SpaceToolsDockWidgetProps) => {
|
||||
const { pushToast } = useToast();
|
||||
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
|
||||
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
|
||||
const [autoHideControls, setAutoHideControls] = useState(true);
|
||||
const [noteDraft, setNoteDraft] = useState('');
|
||||
const [volumeFeedback, setVolumeFeedback] = useState<string | null>(null);
|
||||
const [plan, setPlan] = useState<PlanTier>('normal');
|
||||
@@ -120,8 +119,23 @@ export const SpaceToolsDockWidget = ({
|
||||
}, [openPopover]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocusMode || openPopover || utilityPanel) {
|
||||
if (isFocusMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
setOpenPopover(null);
|
||||
setUtilityPanel(null);
|
||||
setIdle(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [isFocusMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocusMode || openPopover || utilityPanel) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -158,7 +172,45 @@ export const SpaceToolsDockWidget = ({
|
||||
};
|
||||
}, [isFocusMode, openPopover, utilityPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (utilityPanel !== 'control-center' || !autoHideControls) {
|
||||
return;
|
||||
}
|
||||
|
||||
let timerId: number | null = null;
|
||||
const closeDelayMs = 8000;
|
||||
|
||||
const armCloseTimer = () => {
|
||||
if (timerId) {
|
||||
window.clearTimeout(timerId);
|
||||
}
|
||||
|
||||
timerId = window.setTimeout(() => {
|
||||
setUtilityPanel((current) => (current === 'control-center' ? null : current));
|
||||
}, closeDelayMs);
|
||||
};
|
||||
|
||||
const resetTimer = () => {
|
||||
armCloseTimer();
|
||||
};
|
||||
|
||||
armCloseTimer();
|
||||
window.addEventListener('pointermove', resetTimer);
|
||||
window.addEventListener('pointerdown', resetTimer);
|
||||
window.addEventListener('keydown', resetTimer);
|
||||
|
||||
return () => {
|
||||
if (timerId) {
|
||||
window.clearTimeout(timerId);
|
||||
}
|
||||
window.removeEventListener('pointermove', resetTimer);
|
||||
window.removeEventListener('pointerdown', resetTimer);
|
||||
window.removeEventListener('keydown', resetTimer);
|
||||
};
|
||||
}, [autoHideControls, utilityPanel]);
|
||||
|
||||
const openUtilityPanel = (panel: SpaceUtilityPanelId) => {
|
||||
setIdle(false);
|
||||
setOpenPopover(null);
|
||||
setUtilityPanel(panel);
|
||||
};
|
||||
@@ -222,7 +274,25 @@ export const SpaceToolsDockWidget = ({
|
||||
};
|
||||
|
||||
const handleInboxClear = () => {
|
||||
onClearInbox();
|
||||
const snapshot = onClearInbox();
|
||||
|
||||
if (snapshot.length === 0) {
|
||||
onStatusMessage({ message: '비울 항목이 없어요.' });
|
||||
return;
|
||||
}
|
||||
|
||||
onStatusMessage({
|
||||
message: '모두 비워짐',
|
||||
durationMs: 4200,
|
||||
priority: 'undo',
|
||||
action: {
|
||||
label: '실행취소',
|
||||
onClick: () => {
|
||||
onRestoreThoughts(snapshot);
|
||||
onStatusMessage({ message: '복원했어요.' });
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlanPillClick = () => {
|
||||
@@ -231,17 +301,28 @@ export const SpaceToolsDockWidget = ({
|
||||
return;
|
||||
}
|
||||
|
||||
openUtilityPanel('paywall');
|
||||
onStatusMessage({ message: 'NORMAL 플랜 사용 중 · 잠금 항목에서만 업그레이드할 수 있어요.' });
|
||||
};
|
||||
|
||||
const handleLockedClick = (source: string) => {
|
||||
pushToast({ title: `${source}은(는) PRO 기능이에요.` });
|
||||
onStatusMessage({ message: `${source}은(는) PRO 기능이에요.` });
|
||||
openUtilityPanel('paywall');
|
||||
};
|
||||
|
||||
const handleSelectProFeature = (featureId: string) => {
|
||||
const label =
|
||||
featureId === 'scene-packs'
|
||||
? 'Scene Packs'
|
||||
: featureId === 'sound-packs'
|
||||
? 'Sound Packs'
|
||||
: 'Profiles';
|
||||
|
||||
onStatusMessage({ message: `${label} 준비 중(더미)` });
|
||||
};
|
||||
|
||||
const handleStartPro = () => {
|
||||
setPlan('pro');
|
||||
pushToast({ title: '결제(더미)' });
|
||||
onStatusMessage({ message: '결제(더미)' });
|
||||
openUtilityPanel('control-center');
|
||||
};
|
||||
|
||||
@@ -283,7 +364,7 @@ export const SpaceToolsDockWidget = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{openPopover ? (
|
||||
{isFocusMode && openPopover ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="팝오버 닫기"
|
||||
@@ -326,7 +407,10 @@ export const SpaceToolsDockWidget = ({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenPopover((current) => (current === 'notes' ? null : 'notes'))}
|
||||
onClick={() => {
|
||||
setIdle(false);
|
||||
setOpenPopover((current) => (current === 'notes' ? null : 'notes'));
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100"
|
||||
>
|
||||
<span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
|
||||
@@ -358,7 +442,10 @@ export const SpaceToolsDockWidget = ({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenPopover((current) => (current === 'sound' ? null : 'sound'))}
|
||||
onClick={() => {
|
||||
setIdle(false);
|
||||
setOpenPopover((current) => (current === 'sound' ? null : 'sound'));
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100"
|
||||
>
|
||||
<span aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span>
|
||||
@@ -393,7 +480,7 @@ export const SpaceToolsDockWidget = ({
|
||||
) : null}
|
||||
|
||||
<SpaceSideSheet
|
||||
open={utilityPanel !== null}
|
||||
open={isFocusMode && utilityPanel !== null}
|
||||
title={utilityPanel ? UTILITY_PANEL_TITLE[utilityPanel] : ''}
|
||||
subtitle={utilityPanel === 'control-center' ? '배경 · 타이머 · 사운드를 그 자리에서 바꿔요.' : undefined}
|
||||
headerAction={
|
||||
@@ -411,17 +498,21 @@ export const SpaceToolsDockWidget = ({
|
||||
rooms={rooms}
|
||||
selectedRoomId={selectedRoomId}
|
||||
selectedTimerLabel={selectedTimerLabel}
|
||||
selectedSoundPresetId={selectedPresetId}
|
||||
sceneRecommendedSoundLabel={sceneRecommendedSoundLabel}
|
||||
sceneRecommendedTimerLabel={sceneRecommendedTimerLabel}
|
||||
timerPresets={timerPresets}
|
||||
autoHideControls={autoHideControls}
|
||||
onAutoHideControlsChange={setAutoHideControls}
|
||||
onSelectRoom={(roomId) => {
|
||||
onRoomSelect(roomId);
|
||||
}}
|
||||
onSelectTimer={(label) => {
|
||||
onTimerSelect(label);
|
||||
}}
|
||||
onSelectSound={onQuickSoundSelect}
|
||||
onSelectProFeature={handleSelectProFeature}
|
||||
onLockedClick={handleLockedClick}
|
||||
onResetToRecommended={onResetToSceneRecommended}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -444,8 +535,8 @@ export const SpaceToolsDockWidget = ({
|
||||
{utilityPanel === 'manage-plan' ? (
|
||||
<ManagePlanSheetContent
|
||||
onClose={() => setUtilityPanel(null)}
|
||||
onManage={() => pushToast({ title: '구독 관리(더미)' })}
|
||||
onRestore={() => pushToast({ title: '구매 복원(더미)' })}
|
||||
onManage={() => onStatusMessage({ message: '구독 관리(더미)' })}
|
||||
onRestore={() => onStatusMessage({ message: '구매 복원(더미)' })}
|
||||
/>
|
||||
) : null}
|
||||
</SpaceSideSheet>
|
||||
|
||||
44
src/widgets/space-workspace/ui/FocusTopToast.tsx
Normal file
44
src/widgets/space-workspace/ui/FocusTopToast.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface FocusTopToastProps {
|
||||
visible: boolean;
|
||||
message: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export const FocusTopToast = ({
|
||||
visible,
|
||||
message,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: FocusTopToastProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none fixed inset-x-0 z-50 flex justify-center px-3 transition-all duration-[220ms] ease-out motion-reduce:duration-0',
|
||||
'top-[calc(env(safe-area-inset-top,0px)+0.75rem)]',
|
||||
visible ? 'translate-y-0 opacity-100' : '-translate-y-1 opacity-0',
|
||||
)}
|
||||
aria-hidden={!visible}
|
||||
>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="pointer-events-auto inline-flex max-w-[min(420px,92vw)] items-center gap-2 rounded-full border border-white/14 bg-black/32 px-3 py-1.5 text-xs text-white/86 shadow-[0_8px_24px_rgba(2,6,23,0.28)] backdrop-blur-md"
|
||||
>
|
||||
<span className="truncate">{message}</span>
|
||||
{actionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAction}
|
||||
className="shrink-0 text-xs font-medium text-white/92 underline underline-offset-2 transition-colors hover:text-white"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -20,6 +20,7 @@ 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 { FocusTopToast } from './FocusTopToast';
|
||||
|
||||
type WorkspaceMode = 'setup' | 'focus';
|
||||
type SelectionOverride = {
|
||||
@@ -30,6 +31,7 @@ interface StoredWorkspaceSelection {
|
||||
sceneId?: string;
|
||||
timerPresetId?: string;
|
||||
soundPresetId?: string;
|
||||
goal?: string;
|
||||
override?: Partial<SelectionOverride>;
|
||||
}
|
||||
|
||||
@@ -142,6 +144,13 @@ const resolveInitialTimerLabel = (
|
||||
export const SpaceWorkspaceWidget = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const storedSelection = useMemo(() => readStoredWorkspaceSelection(), []);
|
||||
const roomQuery = searchParams.get('room');
|
||||
const goalQuery = searchParams.get('goal');
|
||||
const soundQuery = searchParams.get('sound');
|
||||
const timerQuery = searchParams.get('timer');
|
||||
const storedGoal = storedSelection.goal?.trim() ?? '';
|
||||
const hasQueryOverrides = Boolean(roomQuery || goalQuery || soundQuery || timerQuery);
|
||||
const canOfferResume = storedGoal.length > 0 && !hasQueryOverrides;
|
||||
const {
|
||||
thoughts,
|
||||
thoughtCount,
|
||||
@@ -149,19 +158,20 @@ export const SpaceWorkspaceWidget = () => {
|
||||
removeThought,
|
||||
clearThoughts,
|
||||
restoreThought,
|
||||
restoreThoughts,
|
||||
setThoughtCompleted,
|
||||
} = useThoughtInbox();
|
||||
|
||||
const initialRoomId = resolveInitialRoomId(searchParams.get('room'), storedSelection.sceneId);
|
||||
const initialRoomId = resolveInitialRoomId(roomQuery, storedSelection.sceneId);
|
||||
const initialRoom = getRoomById(initialRoomId) ?? ROOM_THEMES[0];
|
||||
const initialGoal = searchParams.get('goal')?.trim() ?? '';
|
||||
const initialGoal = goalQuery?.trim() ?? '';
|
||||
const initialSoundPresetId = resolveInitialSoundPreset(
|
||||
searchParams.get('sound'),
|
||||
soundQuery,
|
||||
storedSelection.soundPresetId,
|
||||
initialRoom.recommendedSoundPresetId,
|
||||
);
|
||||
const initialTimerLabel = resolveInitialTimerLabel(
|
||||
searchParams.get('timer'),
|
||||
timerQuery,
|
||||
storedSelection.timerPresetId,
|
||||
initialRoom.recommendedTimerPresetId,
|
||||
);
|
||||
@@ -171,6 +181,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
|
||||
const [goalInput, setGoalInput] = useState(initialGoal);
|
||||
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
|
||||
const [showResumePrompt, setShowResumePrompt] = useState(canOfferResume);
|
||||
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
|
||||
sound: Boolean(storedSelection.override?.sound),
|
||||
timer: Boolean(storedSelection.override?.timer),
|
||||
@@ -263,34 +274,17 @@ export const SpaceWorkspaceWidget = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetToSceneRecommended = () => {
|
||||
const room = getRoomById(selectedRoomId);
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionOverride({ sound: false, timer: false });
|
||||
|
||||
const recommendedTimerLabel = resolveTimerLabelFromPresetId(room.recommendedTimerPresetId);
|
||||
|
||||
if (recommendedTimerLabel) {
|
||||
setSelectedTimerLabel(recommendedTimerLabel);
|
||||
}
|
||||
|
||||
if (SOUND_PRESETS.some((preset) => preset.id === room.recommendedSoundPresetId)) {
|
||||
setSelectedPresetId(room.recommendedSoundPresetId);
|
||||
}
|
||||
|
||||
pushStatusLine({ message: '추천으로 되돌림(더미)' });
|
||||
};
|
||||
|
||||
const handleGoalChipSelect = (chip: GoalChip) => {
|
||||
setShowResumePrompt(false);
|
||||
setSelectedGoalId(chip.id);
|
||||
setGoalInput(chip.label);
|
||||
};
|
||||
|
||||
const handleGoalChange = (value: string) => {
|
||||
if (showResumePrompt) {
|
||||
setShowResumePrompt(false);
|
||||
}
|
||||
|
||||
setGoalInput(value);
|
||||
|
||||
if (value.trim().length === 0) {
|
||||
@@ -303,6 +297,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowResumePrompt(false);
|
||||
setWorkspaceMode('focus');
|
||||
};
|
||||
|
||||
@@ -329,6 +324,11 @@ export const SpaceWorkspaceWidget = () => {
|
||||
}
|
||||
|
||||
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
|
||||
const normalizedGoal = goalInput.trim().length > 0
|
||||
? goalInput.trim()
|
||||
: showResumePrompt
|
||||
? storedGoal
|
||||
: '';
|
||||
|
||||
window.localStorage.setItem(
|
||||
WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
@@ -336,10 +336,11 @@ export const SpaceWorkspaceWidget = () => {
|
||||
sceneId: selectedRoomId,
|
||||
timerPresetId,
|
||||
soundPresetId: selectedPresetId,
|
||||
goal: normalizedGoal,
|
||||
override: selectionOverride,
|
||||
}),
|
||||
);
|
||||
}, [selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride]);
|
||||
}, [goalInput, selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt, storedGoal]);
|
||||
|
||||
return (
|
||||
<div className="relative h-dvh overflow-hidden text-white">
|
||||
@@ -371,14 +372,30 @@ export const SpaceWorkspaceWidget = () => {
|
||||
onGoalChange={handleGoalChange}
|
||||
onGoalChipSelect={handleGoalChipSelect}
|
||||
onStart={handleStart}
|
||||
resumeHint={
|
||||
showResumePrompt
|
||||
? {
|
||||
goal: storedGoal,
|
||||
onResume: () => {
|
||||
setGoalInput(storedGoal);
|
||||
setSelectedGoalId(null);
|
||||
setShowResumePrompt(false);
|
||||
setWorkspaceMode('focus');
|
||||
},
|
||||
onStartFresh: () => {
|
||||
setGoalInput('');
|
||||
setSelectedGoalId(null);
|
||||
setShowResumePrompt(false);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<SpaceFocusHudWidget
|
||||
goal={goalInput.trim()}
|
||||
timerLabel={selectedTimerLabel}
|
||||
visible={isFocusMode}
|
||||
statusLine={activeStatus}
|
||||
onStatusAction={runActiveAction}
|
||||
onStatusMessage={pushStatusLine}
|
||||
onGoalUpdate={(nextGoal) => {
|
||||
setGoalInput(nextGoal);
|
||||
@@ -386,6 +403,13 @@ export const SpaceWorkspaceWidget = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<FocusTopToast
|
||||
visible={isFocusMode && Boolean(activeStatus)}
|
||||
message={activeStatus?.message ?? ''}
|
||||
actionLabel={activeStatus?.action?.label}
|
||||
onAction={runActiveAction}
|
||||
/>
|
||||
|
||||
<SpaceToolsDockWidget
|
||||
isFocusMode={isFocusMode}
|
||||
rooms={setupRooms}
|
||||
@@ -400,7 +424,6 @@ export const SpaceWorkspaceWidget = () => {
|
||||
onQuickSoundSelect={(presetId) => handleSelectSound(presetId, true)}
|
||||
sceneRecommendedSoundLabel={selectedRoom.recommendedSound}
|
||||
sceneRecommendedTimerLabel={resolveTimerLabelFromPresetId(selectedRoom.recommendedTimerPresetId) ?? selectedTimerLabel}
|
||||
onResetToSceneRecommended={handleResetToSceneRecommended}
|
||||
soundVolume={masterVolume}
|
||||
onSetSoundVolume={setMasterVolume}
|
||||
isSoundMuted={isMuted}
|
||||
@@ -409,6 +432,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
onDeleteThought={removeThought}
|
||||
onSetThoughtCompleted={setThoughtCompleted}
|
||||
onRestoreThought={restoreThought}
|
||||
onRestoreThoughts={restoreThoughts}
|
||||
onClearInbox={clearThoughts}
|
||||
onStatusMessage={pushStatusLine}
|
||||
onExitRequested={handleExitRequested}
|
||||
|
||||
Reference in New Issue
Block a user