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:
123
src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx
Normal file
123
src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { RoomTheme } from '@/entities/room';
|
||||
import type { GoalChip, SoundPreset } from '@/entities/session';
|
||||
import { SpaceSelectCarousel } from '@/features/space-select';
|
||||
import { SessionGoalField } from '@/features/session-goal';
|
||||
import { Button } from '@/shared/ui';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
|
||||
|
||||
interface SpaceSetupDrawerWidgetProps {
|
||||
open: boolean;
|
||||
rooms: RoomTheme[];
|
||||
selectedRoomId: string;
|
||||
goalInput: string;
|
||||
selectedGoalId: string | null;
|
||||
selectedSoundPresetId: string;
|
||||
goalChips: GoalChip[];
|
||||
soundPresets: SoundPreset[];
|
||||
canStart: boolean;
|
||||
onClose: () => void;
|
||||
onRoomSelect: (roomId: string) => void;
|
||||
onGoalChange: (value: string) => void;
|
||||
onGoalChipSelect: (chip: GoalChip) => void;
|
||||
onSoundSelect: (soundPresetId: string) => void;
|
||||
onStart: () => void;
|
||||
}
|
||||
|
||||
export const SpaceSetupDrawerWidget = ({
|
||||
open,
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
goalInput,
|
||||
selectedGoalId,
|
||||
selectedSoundPresetId,
|
||||
goalChips,
|
||||
soundPresets,
|
||||
canStart,
|
||||
onClose,
|
||||
onRoomSelect,
|
||||
onGoalChange,
|
||||
onGoalChipSelect,
|
||||
onSoundSelect,
|
||||
onStart,
|
||||
}: SpaceSetupDrawerWidgetProps) => {
|
||||
return (
|
||||
<SpaceSideSheet
|
||||
open={open}
|
||||
title="Setup"
|
||||
subtitle="공간 선택 → 목표(필수) → 사운드(선택)"
|
||||
onClose={onClose}
|
||||
widthClassName="w-[min(360px,94vw)]"
|
||||
footer={(
|
||||
<Button
|
||||
type="button"
|
||||
size="full"
|
||||
onClick={onStart}
|
||||
disabled={!canStart}
|
||||
className={cn(
|
||||
'h-11 rounded-xl !bg-sky-300/85 !text-slate-900 shadow-[0_10px_24px_rgba(125,211,252,0.26)] hover:!bg-sky-300 disabled:!bg-white/12 disabled:!text-white/42',
|
||||
)}
|
||||
>
|
||||
시작하기
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-[0.16em] text-white/55">Space</p>
|
||||
<p className="text-xs text-white/62">오늘 머물 공간을 하나 고르세요.</p>
|
||||
</div>
|
||||
<SpaceSelectCarousel
|
||||
rooms={rooms}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelect={onRoomSelect}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-[0.16em] text-white/55">Goal</p>
|
||||
<p className="text-xs text-white/62">스킵 없이 한 줄 목표를 남겨주세요.</p>
|
||||
</div>
|
||||
<SessionGoalField
|
||||
goalInput={goalInput}
|
||||
selectedGoalId={selectedGoalId}
|
||||
goalChips={goalChips}
|
||||
onGoalChange={onGoalChange}
|
||||
onGoalChipSelect={onGoalChipSelect}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] uppercase tracking-[0.16em] text-white/55">Sound</p>
|
||||
<p className="text-xs text-white/62">선택 항목이에요. 필요 없으면 그대로 시작해도 됩니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{soundPresets.map((preset) => {
|
||||
const selected = preset.id === selectedSoundPresetId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => onSoundSelect(preset.id)}
|
||||
className={cn(
|
||||
'rounded-xl border px-3 py-2 text-left text-xs transition-colors',
|
||||
selected
|
||||
? 'border-sky-200/64 bg-sky-200/20 text-sky-50'
|
||||
: 'border-white/18 bg-white/8 text-white/78 hover:bg-white/14',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SpaceSideSheet>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user