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:
1
src/widgets/space-workspace/index.ts
Normal file
1
src/widgets/space-workspace/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/SpaceWorkspaceWidget';
|
||||
216
src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx
Normal file
216
src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user