refactor(space): 단일 워크스페이스 Setup→Focus 전환 구조 도입

맥락:
- 허브를 경유하는 흐름 대신 /space 한 화면에서 설정과 몰입을 이어서 처리할 필요가 있었습니다.
- View 로직 분리와 파일 분할 기준을 지키면서 도크/시트 패턴을 통합해야 했습니다.

변경사항:
- /space를 Setup(기본)과 Focus(시작 후) 2상태로 운영하는 space-workspace 위젯을 추가했습니다.
- Setup Drawer를 추가해 Space 선택, Goal(필수), Sound(선택) 섹션과 하단 고정 CTA를 구성했습니다.
- Goal 입력이 비어있으면 시작하기 버튼이 비활성화되도록 UI 검증을 반영했습니다.
- Focus 상태에서 하단 HUD만 유지하고 우측 Tools Dock(🎧/📝/📨/📊/⚙) + 우측 시트 패턴을 적용했습니다.
- Notes(쓰기)와 Inbox(읽기) 패널을 분리하고 더미 토스트 동작을 연결했습니다.
- FSD 분리를 위해 features(space-select/session-goal/inbox)와 widgets(space-workspace/space-setup-drawer/space-focus-hud/space-sheet-shell)를 추가했습니다.
- 기존 space-shell은 신규 워크스페이스로 연결되는 얇은 래퍼로 정리했습니다.

검증:
- npx tsc --noEmit
- npm run build

세션-상태: /space 단일 워크스페이스에서 Setup→Focus 전환이 동작합니다.
세션-다음: 진입 경로를 /space로 통일하고 레거시 /app 라우트를 정리합니다.
세션-리스크: useSearchParams 기반 초기값은 클라이언트 최초 렌더 기준으로만 반영됩니다.
This commit is contained in:
2026-03-02 12:49:47 +09:00
parent a2bebb3485
commit 2718997735
24 changed files with 898 additions and 303 deletions

View File

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

View File

@@ -0,0 +1,216 @@
'use client';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import {
getRoomBackgroundStyle,
getRoomById,
ROOM_THEMES,
} from '@/entities/room';
import {
GOAL_CHIPS,
SOUND_PRESETS,
useThoughtInbox,
type GoalChip,
} from '@/entities/session';
import { useSoundPresetSelection } from '@/features/sound-preset';
import { cn } from '@/shared/lib/cn';
import { useToast } from '@/shared/ui';
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
type WorkspaceMode = 'setup' | 'focus';
const resolveInitialRoomId = (roomIdFromQuery: string | null) => {
if (roomIdFromQuery && getRoomById(roomIdFromQuery)) {
return roomIdFromQuery;
}
return ROOM_THEMES[0].id;
};
const resolveInitialSoundPreset = (presetIdFromQuery: string | null) => {
if (presetIdFromQuery && SOUND_PRESETS.some((preset) => preset.id === presetIdFromQuery)) {
return presetIdFromQuery;
}
return SOUND_PRESETS[0].id;
};
export const SpaceWorkspaceWidget = () => {
const searchParams = useSearchParams();
const { pushToast } = useToast();
const { thoughts, thoughtCount, addThought, clearThoughts } = useThoughtInbox();
const initialRoomId = resolveInitialRoomId(searchParams.get('room'));
const initialGoal = searchParams.get('goal')?.trim() ?? '';
const initialSoundPresetId = resolveInitialSoundPreset(searchParams.get('sound'));
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('setup');
const [isSetupDrawerOpen, setSetupDrawerOpen] = useState(true);
const [selectedRoomId, setSelectedRoomId] = useState(initialRoomId);
const [goalInput, setGoalInput] = useState(initialGoal);
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
const {
selectedPresetId,
setSelectedPresetId,
isMixerOpen,
setMixerOpen,
isMuted,
setMuted,
masterVolume,
setMasterVolume,
trackLevels,
setTrackLevel,
trackKeys,
} = useSoundPresetSelection(initialSoundPresetId);
const selectedRoom = useMemo(() => {
return getRoomById(selectedRoomId) ?? ROOM_THEMES[0];
}, [selectedRoomId]);
const canStart = goalInput.trim().length > 0;
const isFocusMode = workspaceMode === 'focus';
const handleGoalChipSelect = (chip: GoalChip) => {
setSelectedGoalId(chip.id);
setGoalInput(chip.label);
};
const handleGoalChange = (value: string) => {
setGoalInput(value);
if (value.trim().length === 0) {
setSelectedGoalId(null);
}
};
const handleStart = () => {
if (!canStart) {
return;
}
setWorkspaceMode('focus');
setSetupDrawerOpen(false);
pushToast({
title: '집중을 시작했어요 (더미)',
description: `${selectedRoom.name} · ${goalInput.trim()}`,
});
};
const handleOpenSetup = () => {
setWorkspaceMode('setup');
setSetupDrawerOpen(true);
};
return (
<div className="relative min-h-screen overflow-hidden text-white">
<div aria-hidden className="absolute inset-0" style={getRoomBackgroundStyle(selectedRoom)} />
<div
aria-hidden
className={cn(
'absolute inset-0 transition-colors',
isFocusMode ? 'bg-slate-900/44' : 'bg-slate-900/36',
)}
/>
<div
aria-hidden
className={cn(
'absolute inset-0 transition-opacity',
isFocusMode ? 'opacity-58' : 'opacity-68',
)}
style={{
background:
'radial-gradient(108% 86% at 18% 0%, rgba(148,163,184,0.32) 0%, rgba(2,6,23,0) 46%), radial-gradient(96% 80% at 88% 12%, rgba(125,211,252,0.2) 0%, rgba(2,6,23,0) 54%), linear-gradient(180deg, rgba(2,6,23,0.18) 0%, rgba(2,6,23,0.62) 100%)',
}}
/>
<div
aria-hidden
className={cn('absolute inset-0', isFocusMode ? 'opacity-[0.16]' : 'opacity-[0.12]')}
style={{
backgroundImage:
"url('/textures/grain.png'), repeating-linear-gradient(0deg, rgba(255,255,255,0.016) 0 1px, transparent 1px 2px)",
}}
/>
<div className="relative z-10 flex min-h-screen flex-col pr-[4.25rem]">
<header className="flex items-start justify-between px-4 pt-4 sm:px-6">
<div className="rounded-2xl border border-white/18 bg-slate-950/44 px-3.5 py-2 backdrop-blur-xl">
<p className="text-sm font-semibold tracking-tight text-white/92">VibeRoom</p>
<p className="text-[11px] text-white/62">
{selectedRoom.name} · {selectedRoom.vibeLabel}
</p>
</div>
{!isSetupDrawerOpen ? (
<button
type="button"
onClick={handleOpenSetup}
className="rounded-full border border-white/20 bg-slate-950/44 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-slate-950/62 hover:text-white"
>
Setup
</button>
) : null}
</header>
<main className="relative flex-1">
{isFocusMode ? null : (
<div className="pointer-events-none absolute inset-x-4 top-1/2 -translate-y-1/2 sm:inset-x-6">
<div className="max-w-xl rounded-3xl border border-white/14 bg-slate-950/38 px-6 py-5 backdrop-blur-sm">
<p className="text-xs uppercase tracking-[0.18em] text-white/56">Workspace</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
</h1>
<p className="mt-2 text-sm text-white/68">
Setup에서 .
</p>
</div>
</div>
)}
</main>
</div>
<SpaceSetupDrawerWidget
open={isSetupDrawerOpen}
rooms={ROOM_THEMES}
selectedRoomId={selectedRoom.id}
goalInput={goalInput}
selectedGoalId={selectedGoalId}
selectedSoundPresetId={selectedPresetId}
goalChips={GOAL_CHIPS}
soundPresets={SOUND_PRESETS}
canStart={canStart}
onClose={() => setSetupDrawerOpen(false)}
onRoomSelect={setSelectedRoomId}
onGoalChange={handleGoalChange}
onGoalChipSelect={handleGoalChipSelect}
onSoundSelect={setSelectedPresetId}
onStart={handleStart}
/>
<SpaceFocusHudWidget goal={goalInput.trim()} visible={isFocusMode} />
<SpaceToolsDockWidget
isFocusMode={isFocusMode}
thoughts={thoughts}
thoughtCount={thoughtCount}
selectedPresetId={selectedPresetId}
onSelectPreset={setSelectedPresetId}
isMixerOpen={isMixerOpen}
onToggleMixer={() => setMixerOpen((current) => !current)}
isMuted={isMuted}
onMuteChange={setMuted}
masterVolume={masterVolume}
onMasterVolumeChange={setMasterVolume}
trackKeys={trackKeys}
trackLevels={trackLevels}
onTrackLevelChange={setTrackLevel}
onCaptureThought={(note) => addThought(note, selectedRoom.name)}
onClearInbox={clearThoughts}
/>
</div>
);
};