refactor(space): scene 도메인과 current session 계약을 정리

This commit is contained in:
2026-03-09 13:05:44 +09:00
parent 8184915cb1
commit 675014166a
19 changed files with 243 additions and 208 deletions

View File

@@ -1,2 +0,0 @@
export * from './model/rooms';
export * from './model/types';

View File

@@ -0,0 +1,2 @@
export * from './model/scenes';
export * from './model/types';

View File

@@ -1,5 +1,5 @@
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import type { RoomTheme } from './types'; import type { SceneTheme } from './types';
const HUB_CURATION_ORDER = [ const HUB_CURATION_ORDER = [
'quiet-library', 'quiet-library',
@@ -9,9 +9,9 @@ const HUB_CURATION_ORDER = [
'fireplace', 'fireplace',
] as const; ] as const;
const HUB_RECOMMENDED_ROOM_COUNT = 3; const HUB_RECOMMENDED_SCENE_COUNT = 3;
export const ROOM_THEMES: RoomTheme[] = [ export const SCENE_THEMES: SceneTheme[] = [
{ {
id: 'rain-window', id: 'rain-window',
name: '비 오는 창가', name: '비 오는 창가',
@@ -234,65 +234,65 @@ export const ROOM_THEMES: RoomTheme[] = [
}, },
]; ];
export const getRoomById = (roomId: string) => { export const getSceneById = (id: string) => {
return ROOM_THEMES.find((room) => room.id === roomId); return SCENE_THEMES.find((scene) => scene.id === id);
}; };
export const getRoomCardPhotoUrl = (room: RoomTheme) => { export const getSceneCardPhotoUrl = (scene: SceneTheme) => {
// Swap managedCardPhotoUrl when you start serving first-party assets. // Swap managedCardPhotoUrl when you start serving first-party assets.
return room.managedCardPhotoUrl ?? room.cardPhotoUrl; return scene.managedCardPhotoUrl ?? scene.cardPhotoUrl;
}; };
export const getRoomCardBackgroundStyle = (room: RoomTheme): CSSProperties => { export const getSceneCardBackgroundStyle = (scene: SceneTheme): CSSProperties => {
return { return {
backgroundImage: `url('${getRoomCardPhotoUrl(room)}')`, backgroundImage: `url('${getSceneCardPhotoUrl(scene)}')`,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
}; };
}; };
export const getRoomBackgroundStyle = (room: RoomTheme): CSSProperties => { export const getSceneBackgroundStyle = (scene: SceneTheme): CSSProperties => {
return { return {
backgroundImage: `url('${getRoomCardPhotoUrl(room)}'), linear-gradient(160deg, #1e293b 0%, #0f172a 100%)`, backgroundImage: `url('${getSceneCardPhotoUrl(scene)}'), linear-gradient(160deg, #1e293b 0%, #0f172a 100%)`,
backgroundSize: 'cover, cover', backgroundSize: 'cover, cover',
backgroundPosition: 'center, center', backgroundPosition: 'center, center',
backgroundRepeat: 'no-repeat, no-repeat', backgroundRepeat: 'no-repeat, no-repeat',
}; };
}; };
const uniqueByRoomId = (rooms: Array<RoomTheme | undefined>) => { const uniqueBySceneId = (scenes: Array<SceneTheme | undefined>) => {
const seen = new Set<string>(); const seen = new Set<string>();
return rooms.filter((room): room is RoomTheme => { return scenes.filter((scene): scene is SceneTheme => {
if (!room || seen.has(room.id)) { if (!scene || seen.has(scene.id)) {
return false; return false;
} }
seen.add(room.id); seen.add(scene.id);
return true; return true;
}); });
}; };
export const getHubRoomSections = ( export const getHubSceneSections = (
rooms: RoomTheme[], scenes: SceneTheme[],
selectedRoomId: string, selectedSceneId: string,
recommendedCount = HUB_RECOMMENDED_ROOM_COUNT, recommendedCount = HUB_RECOMMENDED_SCENE_COUNT,
) => { ) => {
const roomById = new Map(rooms.map((room) => [room.id, room] as const)); const sceneById = new Map(scenes.map((scene) => [scene.id, scene] as const));
const selectedRoom = roomById.get(selectedRoomId); const selectedScene = sceneById.get(selectedSceneId);
const curatedRooms = HUB_CURATION_ORDER.map((id) => roomById.get(id)); const curatedScenes = HUB_CURATION_ORDER.map((id) => sceneById.get(id));
const recommendedRooms = uniqueByRoomId([ const recommendedScenes = uniqueBySceneId([
selectedRoom, selectedScene,
...curatedRooms, ...curatedScenes,
...rooms, ...scenes,
]).slice(0, recommendedCount); ]).slice(0, recommendedCount);
const recommendedRoomIds = new Set(recommendedRooms.map((room) => room.id)); const recommendedSceneIds = new Set(recommendedScenes.map((scene) => scene.id));
const allRooms = [...recommendedRooms, ...rooms.filter((room) => !recommendedRoomIds.has(room.id))]; const allScenes = [...recommendedScenes, ...scenes.filter((scene) => !recommendedSceneIds.has(scene.id))];
return { return {
recommendedRooms, recommendedScenes,
allRooms, allScenes,
}; };
}; };

View File

@@ -1,16 +1,16 @@
export type RoomTag = '저자극' | '움직임 적음' | '딥워크' | '감성'; export type SceneTag = '저자극' | '움직임 적음' | '딥워크' | '감성';
export interface RoomPresence { export interface ScenePresence {
focus: number; focus: number;
break: number; break: number;
away: number; away: number;
} }
export interface RoomTheme { export interface SceneTheme {
id: string; id: string;
name: string; name: string;
description: string; description: string;
tags: RoomTag[]; tags: SceneTag[];
recommendedSound: string; recommendedSound: string;
recommendedSoundPresetId: string; recommendedSoundPresetId: string;
recommendedTimerPresetId: string; recommendedTimerPresetId: string;
@@ -21,7 +21,7 @@ export interface RoomTheme {
googleImageSearchUrl: string; googleImageSearchUrl: string;
managedCardPhotoUrl: string | null; managedCardPhotoUrl: string | null;
activeMembers: number; activeMembers: number;
presence: RoomPresence; presence: ScenePresence;
previewImage: string; previewImage: string;
previewGradient: string; previewGradient: string;
} }

View File

@@ -72,25 +72,25 @@ export const RECENT_THOUGHTS: RecentThought[] = [
{ {
id: 'thought-1', id: 'thought-1',
text: '내일 미팅 전에 제안서 첫 문단만 다시 다듬기', text: '내일 미팅 전에 제안서 첫 문단만 다시 다듬기',
roomName: '도서관', sceneName: '도서관',
capturedAt: '방금 전', capturedAt: '방금 전',
}, },
{ {
id: 'thought-2', id: 'thought-2',
text: '기획 문서의 핵심 흐름을 한 문장으로 정리해두기', text: '기획 문서의 핵심 흐름을 한 문장으로 정리해두기',
roomName: '비 오는 창가', sceneName: '비 오는 창가',
capturedAt: '24분 전', capturedAt: '24분 전',
}, },
{ {
id: 'thought-3', id: 'thought-3',
text: '오후에 확인할 이슈 번호만 메모하고 지금 작업 복귀', text: '오후에 확인할 이슈 번호만 메모하고 지금 작업 복귀',
roomName: '숲', sceneName: '숲',
capturedAt: '1시간 전', capturedAt: '1시간 전',
}, },
{ {
id: 'thought-4', id: 'thought-4',
text: '리뷰 코멘트는 오늘 17시 이후에 한 번에 처리', text: '리뷰 코멘트는 오늘 17시 이후에 한 번에 처리',
roomName: '벽난로', sceneName: '벽난로',
capturedAt: '어제', capturedAt: '어제',
}, },
]; ];

View File

@@ -36,7 +36,7 @@ export interface FocusStatCard {
export interface RecentThought { export interface RecentThought {
id: string; id: string;
text: string; text: string;
roomName: string; sceneName: string;
capturedAt: string; capturedAt: string;
isCompleted?: boolean; isCompleted?: boolean;
} }

View File

@@ -24,15 +24,37 @@ const readStoredThoughts = () => {
return []; return [];
} }
return parsed.filter((thought): thought is RecentThought => { return parsed.flatMap((thought): RecentThought[] => {
return ( if (!thought || typeof thought !== 'object') {
thought && return [];
typeof thought.id === 'string' && }
typeof thought.text === 'string' &&
typeof thought.roomName === 'string' && const sceneName =
typeof thought.capturedAt === 'string' && typeof thought.sceneName === 'string'
(typeof thought.isCompleted === 'undefined' || typeof thought.isCompleted === 'boolean') ? thought.sceneName
); : typeof thought.roomName === 'string'
? thought.roomName
: null;
if (
typeof thought.id !== 'string' ||
typeof thought.text !== 'string' ||
typeof thought.capturedAt !== 'string' ||
!sceneName ||
(typeof thought.isCompleted !== 'undefined' && typeof thought.isCompleted !== 'boolean')
) {
return [];
}
return [
{
id: thought.id,
text: thought.text,
sceneName,
capturedAt: thought.capturedAt,
isCompleted: thought.isCompleted,
},
];
}); });
} catch { } catch {
return []; return [];
@@ -70,7 +92,7 @@ export const useThoughtInbox = () => {
}; };
}, []); }, []);
const addThought = useCallback((text: string, roomName: string) => { const addThought = useCallback((text: string, sceneName: string) => {
const trimmedText = text.trim(); const trimmedText = text.trim();
if (!trimmedText) { if (!trimmedText) {
@@ -80,7 +102,7 @@ export const useThoughtInbox = () => {
const thought: RecentThought = { const thought: RecentThought = {
id: `thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, id: `thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
text: trimmedText, text: trimmedText,
roomName, sceneName,
capturedAt: '방금 전', capturedAt: '방금 전',
isCompleted: false, isCompleted: false,
}; };

View File

@@ -6,7 +6,7 @@ export type FocusSessionCompletionType = 'goal-complete' | 'timer-complete';
export interface FocusSession { export interface FocusSession {
id: string; id: string;
roomId: string; sceneId: string;
goal: string; goal: string;
timerPresetId: string; timerPresetId: string;
soundPresetId: string | null; soundPresetId: string | null;
@@ -24,7 +24,7 @@ export interface FocusSession {
} }
export interface StartFocusSessionRequest { export interface StartFocusSessionRequest {
roomId: string; sceneId: string;
goal: string; goal: string;
timerPresetId: string; timerPresetId: string;
soundPresetId?: string | null; soundPresetId?: string | null;
@@ -53,7 +53,7 @@ export const focusSessionApi = {
/** /**
* Backend Codex: * Backend Codex:
* - 새로운 집중 세션을 생성하고 즉시 running 상태로 시작한다. * - 새로운 집중 세션을 생성하고 즉시 running 상태로 시작한다.
* - roomId, goal, timerPresetId, soundPresetId를 저장한다. * - sceneId, goal, timerPresetId, soundPresetId를 저장한다.
* - 응답에는 생성 직후의 세션 상태와 남은 시간 계산에 필요한 시각 필드를 포함한다. * - 응답에는 생성 직후의 세션 상태와 남은 시간 계산에 필요한 시각 필드를 포함한다.
*/ */
startSession: async (payload: StartFocusSessionRequest): Promise<FocusSession> => { startSession: async (payload: StartFocusSessionRequest): Promise<FocusSession> => {
@@ -66,11 +66,12 @@ export const focusSessionApi = {
/** /**
* Backend Codex: * Backend Codex:
* - 현재 세션의 현재 phase를 일시정지한다. * - 현재 세션의 현재 phase를 일시정지한다.
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
* - phaseRemainingSeconds를 정확히 저장해 재개 시 이어서 동작하게 한다. * - phaseRemainingSeconds를 정확히 저장해 재개 시 이어서 동작하게 한다.
* - 이미 paused 상태여도 멱등적으로 최신 세션 상태를 반환한다. * - 이미 paused 상태여도 멱등적으로 최신 세션 상태를 반환한다.
*/ */
pauseSession: async (sessionId: string): Promise<FocusSession> => { pauseSession: async (): Promise<FocusSession> => {
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/pause`, { return apiClient<FocusSession>('api/v1/focus-sessions/current/pause', {
method: 'POST', method: 'POST',
}); });
}, },
@@ -78,11 +79,12 @@ export const focusSessionApi = {
/** /**
* Backend Codex: * Backend Codex:
* - 일시정지된 세션을 재개하고 새 phaseEndsAt/serverNow를 반환한다. * - 일시정지된 세션을 재개하고 새 phaseEndsAt/serverNow를 반환한다.
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
* - 이미 running 상태여도 멱등적으로 최신 세션 상태를 반환한다. * - 이미 running 상태여도 멱등적으로 최신 세션 상태를 반환한다.
* - 남은 시간을 다시 계산할 수 있게 phaseRemainingSeconds도 함께 내려준다. * - 남은 시간을 다시 계산할 수 있게 phaseRemainingSeconds도 함께 내려준다.
*/ */
resumeSession: async (sessionId: string): Promise<FocusSession> => { resumeSession: async (): Promise<FocusSession> => {
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/resume`, { return apiClient<FocusSession>('api/v1/focus-sessions/current/resume', {
method: 'POST', method: 'POST',
}); });
}, },
@@ -90,11 +92,12 @@ export const focusSessionApi = {
/** /**
* Backend Codex: * Backend Codex:
* - 현재 진행 중인 phase를 처음 길이로 다시 시작한다. * - 현재 진행 중인 phase를 처음 길이로 다시 시작한다.
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
* - focus/break 어느 phase인지 유지한 채 phaseStartedAt, phaseEndsAt, phaseRemainingSeconds를 갱신한다. * - focus/break 어느 phase인지 유지한 채 phaseStartedAt, phaseEndsAt, phaseRemainingSeconds를 갱신한다.
* - 클라이언트의 Reset 버튼은 이 API 응답으로 즉시 HUD를 다시 그린다. * - 클라이언트의 Reset 버튼은 이 API 응답으로 즉시 HUD를 다시 그린다.
*/ */
restartCurrentPhase: async (sessionId: string): Promise<FocusSession> => { restartCurrentPhase: async (): Promise<FocusSession> => {
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/restart-phase`, { return apiClient<FocusSession>('api/v1/focus-sessions/current/restart-phase', {
method: 'POST', method: 'POST',
}); });
}, },
@@ -102,14 +105,12 @@ export const focusSessionApi = {
/** /**
* Backend Codex: * Backend Codex:
* - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다. * - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다.
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
* - completionType으로 goal-complete / timer-complete을 구분해 저장한다. * - completionType으로 goal-complete / timer-complete을 구분해 저장한다.
* - 완료된 세션 스냅샷을 반환하거나, 최소한 성공적으로 완료되었음을 알 수 있는 응답을 보낸다. * - 완료된 세션 스냅샷을 반환하거나, 최소한 성공적으로 완료되었음을 알 수 있는 응답을 보낸다.
*/ */
completeSession: async ( completeSession: async (payload: CompleteFocusSessionRequest): Promise<FocusSession> => {
sessionId: string, return apiClient<FocusSession>('api/v1/focus-sessions/current/complete', {
payload: CompleteFocusSessionRequest,
): Promise<FocusSession> => {
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/complete`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
@@ -118,11 +119,12 @@ export const focusSessionApi = {
/** /**
* Backend Codex: * Backend Codex:
* - 현재 세션을 중도 종료(abandon) 처리한다. * - 현재 세션을 중도 종료(abandon) 처리한다.
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
* - 통계에서는 abandon 여부를 구분할 수 있게 저장한다. * - 통계에서는 abandon 여부를 구분할 수 있게 저장한다.
* - 성공 시 204 또는 빈 성공 응답을 반환한다. * - 성공 시 204 또는 빈 성공 응답을 반환한다.
*/ */
abandonSession: async (sessionId: string): Promise<void> => { abandonSession: async (): Promise<void> => {
return apiClient<void>(`api/v1/focus-sessions/${sessionId}/abandon`, { return apiClient<void>('api/v1/focus-sessions/current/abandon', {
method: 'POST', method: 'POST',
expectNoContent: true, expectNoContent: true,
}); });

View File

@@ -199,7 +199,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
} }
const session = await runMutation( const session = await runMutation(
() => focusSessionApi.pauseSession(currentSession.id), () => focusSessionApi.pauseSession(),
'세션을 일시정지하지 못했어요.', '세션을 일시정지하지 못했어요.',
); );
@@ -211,7 +211,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
} }
const session = await runMutation( const session = await runMutation(
() => focusSessionApi.resumeSession(currentSession.id), () => focusSessionApi.resumeSession(),
'세션을 다시 시작하지 못했어요.', '세션을 다시 시작하지 못했어요.',
); );
@@ -223,7 +223,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
} }
const session = await runMutation( const session = await runMutation(
() => focusSessionApi.restartCurrentPhase(currentSession.id), () => focusSessionApi.restartCurrentPhase(),
'현재 페이즈를 다시 시작하지 못했어요.', '현재 페이즈를 다시 시작하지 못했어요.',
); );
@@ -235,7 +235,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
} }
const session = await runMutation( const session = await runMutation(
() => focusSessionApi.completeSession(currentSession.id, payload), () => focusSessionApi.completeSession(payload),
'세션을 완료 처리하지 못했어요.', '세션을 완료 처리하지 못했어요.',
); );
@@ -251,7 +251,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
} }
const result = await runMutation( const result = await runMutation(
() => focusSessionApi.abandonSession(currentSession.id), () => focusSessionApi.abandonSession(),
'세션을 종료하지 못했어요.', '세션을 종료하지 못했어요.',
); );

View File

@@ -38,7 +38,7 @@ export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, classN
{thought.text} {thought.text}
</p> </p>
<p className="mt-1.5 text-[11px] text-white/54"> <p className="mt-1.5 text-[11px] text-white/54">
{thought.roomName} · {thought.capturedAt} {thought.sceneName} · {thought.capturedAt}
</p> </p>
<div className="absolute right-2 top-2 flex items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"> <div className="absolute right-2 top-2 flex items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100">
<button <button

View File

@@ -0,0 +1 @@
export * from './ui/SceneSelectCarousel';

View File

@@ -1,36 +1,36 @@
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room'; import { getSceneCardBackgroundStyle, type SceneTheme } from '@/entities/scene';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
interface SpaceSelectCarouselProps { interface SceneSelectCarouselProps {
rooms: RoomTheme[]; scenes: SceneTheme[];
selectedRoomId: string; selectedSceneId: string;
onSelect: (roomId: string) => void; onSelect: (sceneId: string) => void;
} }
export const SpaceSelectCarousel = ({ export const SceneSelectCarousel = ({
rooms, scenes,
selectedRoomId, selectedSceneId,
onSelect, onSelect,
}: SpaceSelectCarouselProps) => { }: SceneSelectCarouselProps) => {
return ( return (
<div className="-mx-1 overflow-x-auto px-1 pb-1"> <div className="-mx-1 overflow-x-auto px-1 pb-1">
<div className="flex min-w-full gap-2.5"> <div className="flex min-w-full gap-2.5">
{rooms.map((room) => { {scenes.map((scene) => {
const selected = room.id === selectedRoomId; const selected = scene.id === selectedSceneId;
return ( return (
<button <button
key={room.id} key={scene.id}
type="button" type="button"
onClick={() => onSelect(room.id)} onClick={() => onSelect(scene.id)}
className={cn( className={cn(
'group relative h-24 min-w-[138px] overflow-hidden rounded-xl border text-left sm:min-w-[148px]', 'group relative h-24 min-w-[138px] overflow-hidden rounded-xl border text-left sm:min-w-[148px]',
selected selected
? 'border-sky-200/38 shadow-[0_0_0_1px_rgba(186,230,253,0.2),0_0_10px_rgba(56,189,248,0.12)]' ? 'border-sky-200/38 shadow-[0_0_0_1px_rgba(186,230,253,0.2),0_0_10px_rgba(56,189,248,0.12)]'
: 'border-white/16 hover:border-white/24', : 'border-white/16 hover:border-white/24',
)} )}
style={getRoomCardBackgroundStyle(room)} style={getSceneCardBackgroundStyle(scene)}
aria-label={`${room.name} 선택`} aria-label={`${scene.name} 선택`}
> >
<span className="absolute inset-x-0 bottom-0 h-[56%] bg-[linear-gradient(180deg,rgba(2,6,23,0)_0%,rgba(2,6,23,0.2)_52%,rgba(2,6,23,0.24)_100%)]" /> <span className="absolute inset-x-0 bottom-0 h-[56%] bg-[linear-gradient(180deg,rgba(2,6,23,0)_0%,rgba(2,6,23,0.2)_52%,rgba(2,6,23,0.24)_100%)]" />
{selected ? ( {selected ? (
@@ -39,8 +39,8 @@ export const SpaceSelectCarousel = ({
</span> </span>
) : null} ) : null}
<div className="absolute inset-x-2 bottom-2"> <div className="absolute inset-x-2 bottom-2">
<p className="truncate text-sm font-semibold text-white/96">{room.name}</p> <p className="truncate text-sm font-semibold text-white/96">{scene.name}</p>
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p> <p className="truncate text-[11px] text-white/66">{scene.vibeLabel}</p>
</div> </div>
</button> </button>
); );

View File

@@ -1 +0,0 @@
export * from './ui/SpaceSelectCarousel';

View File

@@ -7,6 +7,7 @@
*/ */
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { useAuthStore } from '@/store/useAuthStore';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
const TOKEN_COOKIE_KEY = 'vr_access_token'; const TOKEN_COOKIE_KEY = 'vr_access_token';
@@ -41,6 +42,16 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null; return typeof value === 'object' && value !== null;
}; };
const readAccessToken = () => {
const storeToken = useAuthStore.getState().accessToken;
if (storeToken) {
return storeToken;
}
return Cookies.get(TOKEN_COOKIE_KEY) ?? null;
};
const readErrorMessage = async (response: Response) => { const readErrorMessage = async (response: Response) => {
const contentType = response.headers.get('content-type') ?? ''; const contentType = response.headers.get('content-type') ?? '';
@@ -67,7 +78,7 @@ export const apiClient = async <T>(
...requestOptions ...requestOptions
} = options; } = options;
const url = `${API_BASE_URL}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`; const url = `${API_BASE_URL}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
const token = Cookies.get(TOKEN_COOKIE_KEY); const token = readAccessToken();
const defaultHeaders: Record<string, string> = { const defaultHeaders: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };

View File

@@ -5,7 +5,7 @@ import type { PlanTier } from '@/entities/plan';
import { import {
PRO_FEATURE_CARDS, PRO_FEATURE_CARDS,
} from '@/entities/plan'; } from '@/entities/plan';
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room'; import { getSceneCardBackgroundStyle, type SceneTheme } from '@/entities/scene';
import { SOUND_PRESETS, type TimerPreset } from '@/entities/session'; import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
import { useReducedMotion } from '@/shared/lib/useReducedMotion'; import { useReducedMotion } from '@/shared/lib/useReducedMotion';
@@ -13,8 +13,8 @@ import { Toggle } from '@/shared/ui';
interface ControlCenterSheetWidgetProps { interface ControlCenterSheetWidgetProps {
plan: PlanTier; plan: PlanTier;
rooms: RoomTheme[]; scenes: SceneTheme[];
selectedRoomId: string; selectedSceneId: string;
selectedTimerLabel: string; selectedTimerLabel: string;
selectedSoundPresetId: string; selectedSoundPresetId: string;
sceneRecommendedSoundLabel: string; sceneRecommendedSoundLabel: string;
@@ -22,7 +22,7 @@ interface ControlCenterSheetWidgetProps {
timerPresets: TimerPreset[]; timerPresets: TimerPreset[];
autoHideControls: boolean; autoHideControls: boolean;
onAutoHideControlsChange: (next: boolean) => void; onAutoHideControlsChange: (next: boolean) => void;
onSelectRoom: (roomId: string) => void; onSelectScene: (sceneId: string) => void;
onSelectTimer: (timerLabel: string) => void; onSelectTimer: (timerLabel: string) => void;
onSelectSound: (presetId: string) => void; onSelectSound: (presetId: string) => void;
onSelectProFeature: (featureId: string) => void; onSelectProFeature: (featureId: string) => void;
@@ -40,8 +40,8 @@ const SectionTitle = ({ title, description }: { title: string; description: stri
export const ControlCenterSheetWidget = ({ export const ControlCenterSheetWidget = ({
plan, plan,
rooms, scenes,
selectedRoomId, selectedSceneId,
selectedTimerLabel, selectedTimerLabel,
selectedSoundPresetId, selectedSoundPresetId,
sceneRecommendedSoundLabel, sceneRecommendedSoundLabel,
@@ -49,7 +49,7 @@ export const ControlCenterSheetWidget = ({
timerPresets, timerPresets,
autoHideControls, autoHideControls,
onAutoHideControlsChange, onAutoHideControlsChange,
onSelectRoom, onSelectScene,
onSelectTimer, onSelectTimer,
onSelectSound, onSelectSound,
onSelectProFeature, onSelectProFeature,
@@ -64,14 +64,14 @@ export const ControlCenterSheetWidget = ({
? 'transition-none' ? 'transition-none'
: 'transition-colors duration-[220ms] ease-out'; : 'transition-colors duration-[220ms] ease-out';
const selectedRoom = useMemo(() => { const selectedScene = useMemo(() => {
return rooms.find((room) => room.id === selectedRoomId) ?? rooms[0]; return scenes.find((scene) => scene.id === selectedSceneId) ?? scenes[0];
}, [rooms, selectedRoomId]); }, [scenes, selectedSceneId]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<section className="space-y-2.5 rounded-2xl border border-white/12 bg-black/22 p-3.5 backdrop-blur-md"> <section className="space-y-2.5 rounded-2xl border border-white/12 bg-black/22 p-3.5 backdrop-blur-md">
<SectionTitle title="Scene" description={selectedRoom?.name ?? '공간'} /> <SectionTitle title="Background" description={selectedScene?.name ?? '기본 배경'} />
<div <div
className={cn( className={cn(
'-mx-1 flex gap-2.5 overflow-x-auto px-1 pb-1.5 snap-x snap-mandatory scrollbar-none', '-mx-1 flex gap-2.5 overflow-x-auto px-1 pb-1.5 snap-x snap-mandatory scrollbar-none',
@@ -79,15 +79,15 @@ export const ControlCenterSheetWidget = ({
)} )}
style={{ scrollBehavior: reducedMotion ? 'auto' : 'smooth' }} style={{ scrollBehavior: reducedMotion ? 'auto' : 'smooth' }}
> >
{rooms.slice(0, 6).map((room) => { {scenes.slice(0, 6).map((scene) => {
const selected = room.id === selectedRoomId; const selected = scene.id === selectedSceneId;
return ( return (
<button <button
key={room.id} key={scene.id}
type="button" type="button"
onClick={() => { onClick={() => {
onSelectRoom(room.id); onSelectScene(scene.id);
}} }}
className={cn( className={cn(
'relative h-24 w-[130px] shrink-0 snap-start overflow-hidden rounded-xl border text-left', 'relative h-24 w-[130px] shrink-0 snap-start overflow-hidden rounded-xl border text-left',
@@ -96,11 +96,11 @@ export const ControlCenterSheetWidget = ({
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16', 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-cover bg-center" style={getSceneCardBackgroundStyle(scene)} />
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" /> <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"> <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-sm font-medium text-white/90">{scene.name}</p>
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p> <p className="truncate text-[11px] text-white/66">{scene.vibeLabel}</p>
</div> </div>
</button> </button>
); );

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { RoomTheme } from '@/entities/room'; import type { SceneTheme } from '@/entities/scene';
import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session'; import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session';
import { SpaceSelectCarousel } from '@/features/space-select'; import { SceneSelectCarousel } from '@/features/scene-select';
import { SessionGoalField } from '@/features/session-goal'; import { SessionGoalField } from '@/features/session-goal';
import { Button } from '@/shared/ui'; import { Button } from '@/shared/ui';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
@@ -12,8 +12,8 @@ type RitualPopover = 'space' | 'timer' | 'sound';
interface SpaceSetupDrawerWidgetProps { interface SpaceSetupDrawerWidgetProps {
open: boolean; open: boolean;
rooms: RoomTheme[]; scenes: SceneTheme[];
selectedRoomId: string; selectedSceneId: string;
selectedTimerLabel: string; selectedTimerLabel: string;
selectedSoundPresetId: string; selectedSoundPresetId: string;
goalInput: string; goalInput: string;
@@ -22,7 +22,7 @@ interface SpaceSetupDrawerWidgetProps {
soundPresets: SoundPreset[]; soundPresets: SoundPreset[];
timerPresets: TimerPreset[]; timerPresets: TimerPreset[];
canStart: boolean; canStart: boolean;
onRoomSelect: (roomId: string) => void; onSceneSelect: (sceneId: string) => void;
onTimerSelect: (timerLabel: string) => void; onTimerSelect: (timerLabel: string) => void;
onSoundSelect: (soundPresetId: string) => void; onSoundSelect: (soundPresetId: string) => void;
onGoalChange: (value: string) => void; onGoalChange: (value: string) => void;
@@ -63,8 +63,8 @@ const SummaryChip = ({ label, value, open, onClick }: SummaryChipProps) => {
export const SpaceSetupDrawerWidget = ({ export const SpaceSetupDrawerWidget = ({
open, open,
rooms, scenes,
selectedRoomId, selectedSceneId,
selectedTimerLabel, selectedTimerLabel,
selectedSoundPresetId, selectedSoundPresetId,
goalInput, goalInput,
@@ -73,7 +73,7 @@ export const SpaceSetupDrawerWidget = ({
soundPresets, soundPresets,
timerPresets, timerPresets,
canStart, canStart,
onRoomSelect, onSceneSelect,
onTimerSelect, onTimerSelect,
onSoundSelect, onSoundSelect,
onGoalChange, onGoalChange,
@@ -84,9 +84,9 @@ export const SpaceSetupDrawerWidget = ({
const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null); const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null); const panelRef = useRef<HTMLDivElement | null>(null);
const selectedRoom = useMemo(() => { const selectedScene = useMemo(() => {
return rooms.find((room) => room.id === selectedRoomId) ?? rooms[0]; return scenes.find((scene) => scene.id === selectedSceneId) ?? scenes[0];
}, [rooms, selectedRoomId]); }, [scenes, selectedSceneId]);
const selectedSoundLabel = useMemo(() => { const selectedSoundLabel = useMemo(() => {
return ( return (
@@ -183,8 +183,8 @@ export const SpaceSetupDrawerWidget = ({
<div className="relative mb-3"> <div className="relative mb-3">
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<SummaryChip <SummaryChip
label="공간" label="배경"
value={selectedRoom?.name ?? '기본 공간'} value={selectedScene?.name ?? '기본 배경'}
open={openPopover === 'space'} open={openPopover === 'space'}
onClick={() => togglePopover('space')} onClick={() => togglePopover('space')}
/> />
@@ -204,11 +204,11 @@ export const SpaceSetupDrawerWidget = ({
{openPopover === 'space' ? ( {openPopover === 'space' ? (
<div className="absolute left-0 right-0 top-[calc(100%+0.5rem)] z-20 rounded-2xl border border-white/14 bg-slate-950/80 p-2.5 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"> <div className="absolute left-0 right-0 top-[calc(100%+0.5rem)] z-20 rounded-2xl border border-white/14 bg-slate-950/80 p-2.5 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none">
<SpaceSelectCarousel <SceneSelectCarousel
rooms={rooms.slice(0, 4)} scenes={scenes.slice(0, 4)}
selectedRoomId={selectedRoomId} selectedSceneId={selectedSceneId}
onSelect={(roomId) => { onSelect={(sceneId) => {
onRoomSelect(roomId); onSceneSelect(sceneId);
setOpenPopover(null); setOpenPopover(null);
}} }}
/> />

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { PlanTier } from '@/entities/plan'; import type { PlanTier } from '@/entities/plan';
import type { RoomTheme } from '@/entities/room'; import type { SceneTheme } from '@/entities/scene';
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session'; import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
import { ExitHoldButton } from '@/features/exit-hold'; import { ExitHoldButton } from '@/features/exit-hold';
import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet'; import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet';
@@ -19,8 +19,8 @@ import { QuickSoundPopover } from './popovers/QuickSoundPopover';
import { InboxToolPanel } from './panels/InboxToolPanel'; import { InboxToolPanel } from './panels/InboxToolPanel';
interface SpaceToolsDockWidgetProps { interface SpaceToolsDockWidgetProps {
isFocusMode: boolean; isFocusMode: boolean;
rooms: RoomTheme[]; scenes: SceneTheme[];
selectedRoomId: string; selectedSceneId: string;
selectedTimerLabel: string; selectedTimerLabel: string;
timerPresets: TimerPreset[]; timerPresets: TimerPreset[];
selectedPresetId: string; selectedPresetId: string;
@@ -32,7 +32,7 @@ interface SpaceToolsDockWidgetProps {
thoughtCount: number; thoughtCount: number;
sceneRecommendedSoundLabel: string; sceneRecommendedSoundLabel: string;
sceneRecommendedTimerLabel: string; sceneRecommendedTimerLabel: string;
onRoomSelect: (roomId: string) => void; onSceneSelect: (sceneId: string) => void;
onTimerSelect: (timerLabel: string) => void; onTimerSelect: (timerLabel: string) => void;
onQuickSoundSelect: (presetId: string) => void; onQuickSoundSelect: (presetId: string) => void;
onCaptureThought: (note: string) => RecentThought | null; onCaptureThought: (note: string) => RecentThought | null;
@@ -47,8 +47,8 @@ interface SpaceToolsDockWidgetProps {
export const SpaceToolsDockWidget = ({ export const SpaceToolsDockWidget = ({
isFocusMode, isFocusMode,
rooms, scenes,
selectedRoomId, selectedSceneId,
selectedTimerLabel, selectedTimerLabel,
timerPresets, timerPresets,
selectedPresetId, selectedPresetId,
@@ -60,7 +60,7 @@ export const SpaceToolsDockWidget = ({
thoughtCount, thoughtCount,
sceneRecommendedSoundLabel, sceneRecommendedSoundLabel,
sceneRecommendedTimerLabel, sceneRecommendedTimerLabel,
onRoomSelect, onSceneSelect,
onTimerSelect, onTimerSelect,
onQuickSoundSelect, onQuickSoundSelect,
onCaptureThought, onCaptureThought,
@@ -495,8 +495,8 @@ export const SpaceToolsDockWidget = ({
{utilityPanel === 'control-center' ? ( {utilityPanel === 'control-center' ? (
<ControlCenterSheetWidget <ControlCenterSheetWidget
plan={plan} plan={plan}
rooms={rooms} scenes={scenes}
selectedRoomId={selectedRoomId} selectedSceneId={selectedSceneId}
selectedTimerLabel={selectedTimerLabel} selectedTimerLabel={selectedTimerLabel}
selectedSoundPresetId={selectedPresetId} selectedSoundPresetId={selectedPresetId}
sceneRecommendedSoundLabel={sceneRecommendedSoundLabel} sceneRecommendedSoundLabel={sceneRecommendedSoundLabel}
@@ -504,8 +504,8 @@ export const SpaceToolsDockWidget = ({
timerPresets={timerPresets} timerPresets={timerPresets}
autoHideControls={autoHideControls} autoHideControls={autoHideControls}
onAutoHideControlsChange={setAutoHideControls} onAutoHideControlsChange={setAutoHideControls}
onSelectRoom={(roomId) => { onSelectScene={(sceneId) => {
onRoomSelect(roomId); onSceneSelect(sceneId);
}} }}
onSelectTimer={(label) => { onSelectTimer={(label) => {
onTimerSelect(label); onTimerSelect(label);

View File

@@ -1,26 +1,26 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import type { RoomTheme } from '@/entities/room'; import type { SceneTheme } from '@/entities/scene';
import type { TimerPreset } from '@/entities/session'; import type { TimerPreset } from '@/entities/session';
import { DEFAULT_PRESET_OPTIONS } from '@/shared/config/settingsOptions'; import { DEFAULT_PRESET_OPTIONS } from '@/shared/config/settingsOptions';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
interface SettingsToolPanelProps { interface SettingsToolPanelProps {
rooms: RoomTheme[]; scenes: SceneTheme[];
selectedRoomId: string; selectedSceneId: string;
selectedTimerLabel: string; selectedTimerLabel: string;
timerPresets: TimerPreset[]; timerPresets: TimerPreset[];
onSelectRoom: (roomId: string) => void; onSelectScene: (sceneId: string) => void;
onSelectTimer: (timerLabel: string) => void; onSelectTimer: (timerLabel: string) => void;
} }
export const SettingsToolPanel = ({ export const SettingsToolPanel = ({
rooms, scenes,
selectedRoomId, selectedSceneId,
selectedTimerLabel, selectedTimerLabel,
timerPresets, timerPresets,
onSelectRoom, onSelectScene,
onSelectTimer, onSelectTimer,
}: SettingsToolPanelProps) => { }: SettingsToolPanelProps) => {
const [reduceMotion, setReduceMotion] = useState(false); const [reduceMotion, setReduceMotion] = useState(false);
@@ -59,17 +59,17 @@ export const SettingsToolPanel = ({
</section> </section>
<section className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3"> <section className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3">
<p className="text-sm font-medium text-white"></p> <p className="text-sm font-medium text-white"></p>
<p className="mt-1 text-xs text-white/58"> .</p> <p className="mt-1 text-xs text-white/58"> scene .</p>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{rooms.slice(0, 4).map((room) => { {scenes.slice(0, 4).map((scene) => {
const selected = room.id === selectedRoomId; const selected = scene.id === selectedSceneId;
return ( return (
<button <button
key={room.id} key={scene.id}
type="button" type="button"
onClick={() => onSelectRoom(room.id)} onClick={() => onSelectScene(scene.id)}
className={cn( className={cn(
'rounded-full border px-3 py-1.5 text-xs transition-colors', 'rounded-full border px-3 py-1.5 text-xs transition-colors',
selected selected
@@ -77,7 +77,7 @@ export const SettingsToolPanel = ({
: 'border-white/18 bg-white/8 text-white/80 hover:bg-white/14', : 'border-white/18 bg-white/8 text-white/80 hover:bg-white/14',
)} )}
> >
{room.name} {scene.name}
</button> </button>
); );
})} })}

View File

@@ -3,10 +3,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { import {
getRoomBackgroundStyle, getSceneBackgroundStyle,
getRoomById, getSceneById,
ROOM_THEMES, SCENE_THEMES,
} from '@/entities/room'; } from '@/entities/scene';
import { import {
GOAL_CHIPS, GOAL_CHIPS,
SOUND_PRESETS, SOUND_PRESETS,
@@ -62,16 +62,16 @@ const readStoredWorkspaceSelection = (): StoredWorkspaceSelection => {
} }
}; };
const resolveInitialRoomId = (roomIdFromQuery: string | null, storedSceneId?: string) => { const resolveInitialSceneId = (sceneIdFromQuery: string | null, storedSceneId?: string) => {
if (roomIdFromQuery && getRoomById(roomIdFromQuery)) { if (sceneIdFromQuery && getSceneById(sceneIdFromQuery)) {
return roomIdFromQuery; return sceneIdFromQuery;
} }
if (storedSceneId && getRoomById(storedSceneId)) { if (storedSceneId && getSceneById(storedSceneId)) {
return storedSceneId; return storedSceneId;
} }
return ROOM_THEMES[0].id; return SCENE_THEMES[0].id;
}; };
const resolveInitialSoundPreset = ( const resolveInitialSoundPreset = (
@@ -150,11 +150,11 @@ const resolveFocusTimeDisplayFromTimerLabel = (timerLabel: string) => {
export const SpaceWorkspaceWidget = () => { export const SpaceWorkspaceWidget = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const roomQuery = searchParams.get('room'); const sceneQuery = searchParams.get('scene') ?? searchParams.get('room');
const goalQuery = searchParams.get('goal')?.trim() ?? ''; const goalQuery = searchParams.get('goal')?.trim() ?? '';
const soundQuery = searchParams.get('sound'); const soundQuery = searchParams.get('sound');
const timerQuery = searchParams.get('timer'); const timerQuery = searchParams.get('timer');
const hasQueryOverrides = Boolean(roomQuery || goalQuery || soundQuery || timerQuery); const hasQueryOverrides = Boolean(sceneQuery || goalQuery || soundQuery || timerQuery);
const { const {
thoughts, thoughts,
thoughtCount, thoughtCount,
@@ -166,22 +166,22 @@ export const SpaceWorkspaceWidget = () => {
setThoughtCompleted, setThoughtCompleted,
} = useThoughtInbox(); } = useThoughtInbox();
const initialRoomId = resolveInitialRoomId(roomQuery, undefined); const initialSceneId = resolveInitialSceneId(sceneQuery, undefined);
const initialRoom = getRoomById(initialRoomId) ?? ROOM_THEMES[0]; const initialScene = getSceneById(initialSceneId) ?? SCENE_THEMES[0];
const initialGoal = goalQuery; const initialGoal = goalQuery;
const initialSoundPresetId = resolveInitialSoundPreset( const initialSoundPresetId = resolveInitialSoundPreset(
soundQuery, soundQuery,
undefined, undefined,
initialRoom.recommendedSoundPresetId, initialScene.recommendedSoundPresetId,
); );
const initialTimerLabel = resolveInitialTimerLabel( const initialTimerLabel = resolveInitialTimerLabel(
timerQuery, timerQuery,
undefined, undefined,
initialRoom.recommendedTimerPresetId, initialScene.recommendedTimerPresetId,
); );
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('setup'); const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('setup');
const [selectedRoomId, setSelectedRoomId] = useState(initialRoomId); const [selectedSceneId, setSelectedSceneId] = useState(initialSceneId);
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel); const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
const [goalInput, setGoalInput] = useState(initialGoal); const [goalInput, setGoalInput] = useState(initialGoal);
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null); const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
@@ -216,19 +216,19 @@ export const SpaceWorkspaceWidget = () => {
abandonSession, abandonSession,
} = useFocusSessionEngine(); } = useFocusSessionEngine();
const selectedRoom = useMemo(() => { const selectedScene = useMemo(() => {
return getRoomById(selectedRoomId) ?? ROOM_THEMES[0]; return getSceneById(selectedSceneId) ?? SCENE_THEMES[0];
}, [selectedRoomId]); }, [selectedSceneId]);
const setupRooms = useMemo(() => { const setupScenes = useMemo(() => {
const visibleRooms = ROOM_THEMES.slice(0, 6); const visibleScenes = SCENE_THEMES.slice(0, 6);
if (visibleRooms.some((room) => room.id === selectedRoom.id)) { if (visibleScenes.some((scene) => scene.id === selectedScene.id)) {
return visibleRooms; return visibleScenes;
} }
return [selectedRoom, ...visibleRooms].slice(0, 6); return [selectedScene, ...visibleScenes].slice(0, 6);
}, [selectedRoom]); }, [selectedScene]);
const canStart = goalInput.trim().length > 0; const canStart = goalInput.trim().length > 0;
const isFocusMode = workspaceMode === 'focus'; const isFocusMode = workspaceMode === 'focus';
@@ -237,17 +237,17 @@ export const SpaceWorkspaceWidget = () => {
const resolvedTimeDisplay = timeDisplay ?? resolveFocusTimeDisplayFromTimerLabel(selectedTimerLabel); const resolvedTimeDisplay = timeDisplay ?? resolveFocusTimeDisplayFromTimerLabel(selectedTimerLabel);
const applyRecommendedSelections = useCallback(( const applyRecommendedSelections = useCallback((
roomId: string, sceneId: string,
overrideState: SelectionOverride = selectionOverride, overrideState: SelectionOverride = selectionOverride,
) => { ) => {
const room = getRoomById(roomId); const scene = getSceneById(sceneId);
if (!room) { if (!scene) {
return; return;
} }
if (!overrideState.timer) { if (!overrideState.timer) {
const recommendedTimerLabel = resolveTimerLabelFromPresetId(room.recommendedTimerPresetId); const recommendedTimerLabel = resolveTimerLabelFromPresetId(scene.recommendedTimerPresetId);
if (recommendedTimerLabel) { if (recommendedTimerLabel) {
setSelectedTimerLabel(recommendedTimerLabel); setSelectedTimerLabel(recommendedTimerLabel);
@@ -256,9 +256,9 @@ export const SpaceWorkspaceWidget = () => {
if ( if (
!overrideState.sound && !overrideState.sound &&
SOUND_PRESETS.some((preset) => preset.id === room.recommendedSoundPresetId) SOUND_PRESETS.some((preset) => preset.id === scene.recommendedSoundPresetId)
) { ) {
setSelectedPresetId(room.recommendedSoundPresetId); setSelectedPresetId(scene.recommendedSoundPresetId);
} }
}, [selectionOverride, setSelectedPresetId]); }, [selectionOverride, setSelectedPresetId]);
@@ -268,8 +268,8 @@ export const SpaceWorkspaceWidget = () => {
sound: Boolean(storedSelection.override?.sound), sound: Boolean(storedSelection.override?.sound),
timer: Boolean(storedSelection.override?.timer), timer: Boolean(storedSelection.override?.timer),
}; };
const restoredRoomId = const restoredSceneId =
!roomQuery && storedSelection.sceneId && getRoomById(storedSelection.sceneId) !sceneQuery && storedSelection.sceneId && getSceneById(storedSelection.sceneId)
? storedSelection.sceneId ? storedSelection.sceneId
: null; : null;
const restoredTimerLabel = !timerQuery const restoredTimerLabel = !timerQuery
@@ -283,8 +283,8 @@ export const SpaceWorkspaceWidget = () => {
const rafId = window.requestAnimationFrame(() => { const rafId = window.requestAnimationFrame(() => {
setSelectionOverride(restoredSelectionOverride); setSelectionOverride(restoredSelectionOverride);
if (restoredRoomId) { if (restoredSceneId) {
setSelectedRoomId(restoredRoomId); setSelectedSceneId(restoredSceneId);
} }
if (restoredTimerLabel) { if (restoredTimerLabel) {
@@ -306,7 +306,7 @@ export const SpaceWorkspaceWidget = () => {
return () => { return () => {
window.cancelAnimationFrame(rafId); window.cancelAnimationFrame(rafId);
}; };
}, [goalQuery, hasQueryOverrides, roomQuery, setSelectedPresetId, soundQuery, timerQuery]); }, [goalQuery, hasQueryOverrides, sceneQuery, setSelectedPresetId, soundQuery, timerQuery]);
useEffect(() => { useEffect(() => {
if (!currentSession) { if (!currentSession) {
@@ -321,7 +321,7 @@ export const SpaceWorkspaceWidget = () => {
? currentSession.soundPresetId ? currentSession.soundPresetId
: selectedPresetId; : selectedPresetId;
const rafId = window.requestAnimationFrame(() => { const rafId = window.requestAnimationFrame(() => {
setSelectedRoomId(currentSession.roomId); setSelectedSceneId(currentSession.sceneId);
setSelectedTimerLabel(nextTimerLabel); setSelectedTimerLabel(nextTimerLabel);
setSelectedPresetId(nextSoundPresetId); setSelectedPresetId(nextSoundPresetId);
setGoalInput(currentSession.goal); setGoalInput(currentSession.goal);
@@ -336,9 +336,9 @@ export const SpaceWorkspaceWidget = () => {
}; };
}, [currentSession, selectedPresetId, selectedTimerLabel, setSelectedPresetId]); }, [currentSession, selectedPresetId, selectedTimerLabel, setSelectedPresetId]);
const handleSelectRoom = (roomId: string) => { const handleSelectScene = (sceneId: string) => {
setSelectedRoomId(roomId); setSelectedSceneId(sceneId);
applyRecommendedSelections(roomId); applyRecommendedSelections(sceneId);
}; };
const handleSelectTimer = (timerLabel: string, markOverride = false) => { const handleSelectTimer = (timerLabel: string, markOverride = false) => {
@@ -407,7 +407,7 @@ export const SpaceWorkspaceWidget = () => {
setWorkspaceMode('focus'); setWorkspaceMode('focus');
const startedSession = await startSession({ const startedSession = await startSession({
roomId: selectedRoomId, sceneId: selectedSceneId,
goal: trimmedGoal, goal: trimmedGoal,
timerPresetId, timerPresetId,
soundPresetId: selectedPresetId, soundPresetId: selectedPresetId,
@@ -552,21 +552,21 @@ export const SpaceWorkspaceWidget = () => {
window.localStorage.setItem( window.localStorage.setItem(
WORKSPACE_SELECTION_STORAGE_KEY, WORKSPACE_SELECTION_STORAGE_KEY,
JSON.stringify({ JSON.stringify({
sceneId: selectedRoomId, sceneId: selectedSceneId,
timerPresetId, timerPresetId,
soundPresetId: selectedPresetId, soundPresetId: selectedPresetId,
goal: normalizedGoal, goal: normalizedGoal,
override: selectionOverride, override: selectionOverride,
}), }),
); );
}, [goalInput, hasHydratedSelection, resumeGoal, selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt]); }, [goalInput, hasHydratedSelection, resumeGoal, selectedSceneId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt]);
return ( return (
<div className="relative h-dvh overflow-hidden text-white"> <div className="relative h-dvh overflow-hidden text-white">
<div <div
aria-hidden aria-hidden
className="absolute inset-0 bg-cover bg-center will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none" className="absolute inset-0 bg-cover bg-center will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none"
style={getRoomBackgroundStyle(selectedRoom)} style={getSceneBackgroundStyle(selectedScene)}
/> />
<div className="relative z-10 flex h-full flex-col"> <div className="relative z-10 flex h-full flex-col">
@@ -575,8 +575,8 @@ export const SpaceWorkspaceWidget = () => {
<SpaceSetupDrawerWidget <SpaceSetupDrawerWidget
open={!isFocusMode} open={!isFocusMode}
rooms={setupRooms} scenes={setupScenes}
selectedRoomId={selectedRoom.id} selectedSceneId={selectedScene.id}
selectedTimerLabel={selectedTimerLabel} selectedTimerLabel={selectedTimerLabel}
selectedSoundPresetId={selectedPresetId} selectedSoundPresetId={selectedPresetId}
goalInput={goalInput} goalInput={goalInput}
@@ -585,7 +585,7 @@ export const SpaceWorkspaceWidget = () => {
soundPresets={SOUND_PRESETS} soundPresets={SOUND_PRESETS}
timerPresets={TIMER_SELECTION_PRESETS} timerPresets={TIMER_SELECTION_PRESETS}
canStart={canStart} canStart={canStart}
onRoomSelect={handleSelectRoom} onSceneSelect={handleSelectScene}
onTimerSelect={(timerLabel) => handleSelectTimer(timerLabel, true)} onTimerSelect={(timerLabel) => handleSelectTimer(timerLabel, true)}
onSoundSelect={(presetId) => handleSelectSound(presetId, true)} onSoundSelect={(presetId) => handleSelectSound(presetId, true)}
onGoalChange={handleGoalChange} onGoalChange={handleGoalChange}
@@ -641,23 +641,23 @@ export const SpaceWorkspaceWidget = () => {
<SpaceToolsDockWidget <SpaceToolsDockWidget
isFocusMode={isFocusMode} isFocusMode={isFocusMode}
rooms={setupRooms} scenes={setupScenes}
selectedRoomId={selectedRoom.id} selectedSceneId={selectedScene.id}
selectedTimerLabel={selectedTimerLabel} selectedTimerLabel={selectedTimerLabel}
timerPresets={TIMER_SELECTION_PRESETS} timerPresets={TIMER_SELECTION_PRESETS}
thoughts={thoughts} thoughts={thoughts}
thoughtCount={thoughtCount} thoughtCount={thoughtCount}
selectedPresetId={selectedPresetId} selectedPresetId={selectedPresetId}
onRoomSelect={handleSelectRoom} onSceneSelect={handleSelectScene}
onTimerSelect={(timerLabel) => handleSelectTimer(timerLabel, true)} onTimerSelect={(timerLabel) => handleSelectTimer(timerLabel, true)}
onQuickSoundSelect={(presetId) => handleSelectSound(presetId, true)} onQuickSoundSelect={(presetId) => handleSelectSound(presetId, true)}
sceneRecommendedSoundLabel={selectedRoom.recommendedSound} sceneRecommendedSoundLabel={selectedScene.recommendedSound}
sceneRecommendedTimerLabel={resolveTimerLabelFromPresetId(selectedRoom.recommendedTimerPresetId) ?? selectedTimerLabel} sceneRecommendedTimerLabel={resolveTimerLabelFromPresetId(selectedScene.recommendedTimerPresetId) ?? selectedTimerLabel}
soundVolume={masterVolume} soundVolume={masterVolume}
onSetSoundVolume={setMasterVolume} onSetSoundVolume={setMasterVolume}
isSoundMuted={isMuted} isSoundMuted={isMuted}
onSetSoundMuted={setMuted} onSetSoundMuted={setMuted}
onCaptureThought={(note) => addThought(note, selectedRoom.name)} onCaptureThought={(note) => addThought(note, selectedScene.name)}
onDeleteThought={removeThought} onDeleteThought={removeThought}
onSetThoughtCompleted={setThoughtCompleted} onSetThoughtCompleted={setThoughtCompleted}
onRestoreThought={restoreThought} onRestoreThought={restoreThought}