From 27189977358d6e7c6c58de94b35fa736cac40953 Mon Sep 17 00:00:00 2001 From: corpi Date: Mon, 2 Mar 2026 12:49:47 +0900 Subject: [PATCH] =?UTF-8?q?refactor(space):=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20Setup?= =?UTF-8?q?=E2=86=92Focus=20=EC=A0=84=ED=99=98=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 맥락: - 허브를 경유하는 흐름 대신 /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 기반 초기값은 클라이언트 최초 렌더 기준으로만 반영됩니다. --- src/app/(app)/space/page.tsx | 6 +- src/features/inbox/index.ts | 1 + src/features/inbox/ui/InboxList.tsx | 38 +++ src/features/session-goal/index.ts | 1 + .../session-goal/ui/SessionGoalField.tsx | 57 ++++ src/features/space-select/index.ts | 1 + .../space-select/ui/SpaceSelectCarousel.tsx | 51 ++++ src/widgets/space-focus-hud/index.ts | 1 + .../ui/SpaceFocusHudWidget.tsx | 14 + src/widgets/space-setup-drawer/index.ts | 1 + .../ui/SpaceSetupDrawerWidget.tsx | 123 ++++++++ src/widgets/space-sheet-shell/index.ts | 1 + .../space-sheet-shell/ui/SpaceSideSheet.tsx | 85 ++++++ .../space-shell/ui/SpaceSkeletonWidget.tsx | 138 +-------- src/widgets/space-tools-dock/index.ts | 1 + src/widgets/space-tools-dock/model/types.ts | 1 + .../ui/SpaceToolsDockWidget.tsx | 284 ++++++++---------- .../ui/panels/InboxToolPanel.tsx | 25 ++ .../ui/panels/NotesToolPanel.tsx | 16 + .../ui/panels/SettingsToolPanel.tsx | 65 ++++ .../ui/panels/SoundToolPanel.tsx | 48 +++ .../ui/panels/StatsToolPanel.tsx | 26 ++ src/widgets/space-workspace/index.ts | 1 + .../ui/SpaceWorkspaceWidget.tsx | 216 +++++++++++++ 24 files changed, 898 insertions(+), 303 deletions(-) create mode 100644 src/features/inbox/index.ts create mode 100644 src/features/inbox/ui/InboxList.tsx create mode 100644 src/features/session-goal/index.ts create mode 100644 src/features/session-goal/ui/SessionGoalField.tsx create mode 100644 src/features/space-select/index.ts create mode 100644 src/features/space-select/ui/SpaceSelectCarousel.tsx create mode 100644 src/widgets/space-focus-hud/index.ts create mode 100644 src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx create mode 100644 src/widgets/space-setup-drawer/index.ts create mode 100644 src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx create mode 100644 src/widgets/space-sheet-shell/index.ts create mode 100644 src/widgets/space-sheet-shell/ui/SpaceSideSheet.tsx create mode 100644 src/widgets/space-tools-dock/model/types.ts create mode 100644 src/widgets/space-tools-dock/ui/panels/InboxToolPanel.tsx create mode 100644 src/widgets/space-tools-dock/ui/panels/NotesToolPanel.tsx create mode 100644 src/widgets/space-tools-dock/ui/panels/SettingsToolPanel.tsx create mode 100644 src/widgets/space-tools-dock/ui/panels/SoundToolPanel.tsx create mode 100644 src/widgets/space-tools-dock/ui/panels/StatsToolPanel.tsx create mode 100644 src/widgets/space-workspace/index.ts create mode 100644 src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx diff --git a/src/app/(app)/space/page.tsx b/src/app/(app)/space/page.tsx index 29ff25e..99b13ed 100644 --- a/src/app/(app)/space/page.tsx +++ b/src/app/(app)/space/page.tsx @@ -1,14 +1,14 @@ import { Suspense } from 'react'; -import { SpaceSkeletonWidget } from '@/widgets/space-shell'; +import { SpaceWorkspaceWidget } from '@/widgets/space-workspace'; export default function SpacePage() { return ( +
} > - + ); } diff --git a/src/features/inbox/index.ts b/src/features/inbox/index.ts new file mode 100644 index 0000000..ee4ef44 --- /dev/null +++ b/src/features/inbox/index.ts @@ -0,0 +1 @@ +export * from './ui/InboxList'; diff --git a/src/features/inbox/ui/InboxList.tsx b/src/features/inbox/ui/InboxList.tsx new file mode 100644 index 0000000..e5c67cd --- /dev/null +++ b/src/features/inbox/ui/InboxList.tsx @@ -0,0 +1,38 @@ +import type { RecentThought } from '@/entities/session'; +import { cn } from '@/shared/lib/cn'; + +interface InboxListProps { + thoughts: RecentThought[]; + className?: string; +} + +export const InboxList = ({ thoughts, className }: InboxListProps) => { + if (thoughts.length === 0) { + return ( +

+ 지금은 비어 있어요. 집중 중 떠오른 생각을 여기로 주차할 수 있어요. +

+ ); + } + + return ( +
    + {thoughts.slice(0, 10).map((thought) => ( +
  • +

    {thought.text}

    +

    + {thought.roomName} · {thought.capturedAt} +

    +
  • + ))} +
+ ); +}; diff --git a/src/features/session-goal/index.ts b/src/features/session-goal/index.ts new file mode 100644 index 0000000..63d3a6d --- /dev/null +++ b/src/features/session-goal/index.ts @@ -0,0 +1 @@ +export * from './ui/SessionGoalField'; diff --git a/src/features/session-goal/ui/SessionGoalField.tsx b/src/features/session-goal/ui/SessionGoalField.tsx new file mode 100644 index 0000000..953f1f7 --- /dev/null +++ b/src/features/session-goal/ui/SessionGoalField.tsx @@ -0,0 +1,57 @@ +import type { GoalChip } from '@/entities/session'; +import { cn } from '@/shared/lib/cn'; + +interface SessionGoalFieldProps { + goalInput: string; + selectedGoalId: string | null; + goalChips: GoalChip[]; + onGoalChange: (value: string) => void; + onGoalChipSelect: (chip: GoalChip) => void; +} + +export const SessionGoalField = ({ + goalInput, + selectedGoalId, + goalChips, + onGoalChange, + onGoalChipSelect, +}: SessionGoalFieldProps) => { + return ( +
+
+ + onGoalChange(event.target.value)} + placeholder="예: 계약서 1페이지 정리" + className="h-11 w-full rounded-xl border border-white/18 bg-slate-950/48 px-3 text-sm text-white placeholder:text-white/42 focus:border-sky-200/58 focus:outline-none" + /> +
+ +
+ {goalChips.map((chip) => { + const selected = chip.id === selectedGoalId; + + return ( + + ); + })} +
+
+ ); +}; diff --git a/src/features/space-select/index.ts b/src/features/space-select/index.ts new file mode 100644 index 0000000..4dd528e --- /dev/null +++ b/src/features/space-select/index.ts @@ -0,0 +1 @@ +export * from './ui/SpaceSelectCarousel'; diff --git a/src/features/space-select/ui/SpaceSelectCarousel.tsx b/src/features/space-select/ui/SpaceSelectCarousel.tsx new file mode 100644 index 0000000..094c4ce --- /dev/null +++ b/src/features/space-select/ui/SpaceSelectCarousel.tsx @@ -0,0 +1,51 @@ +import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room'; +import { cn } from '@/shared/lib/cn'; + +interface SpaceSelectCarouselProps { + rooms: RoomTheme[]; + selectedRoomId: string; + onSelect: (roomId: string) => void; +} + +export const SpaceSelectCarousel = ({ + rooms, + selectedRoomId, + onSelect, +}: SpaceSelectCarouselProps) => { + return ( +
+
+ {rooms.map((room) => { + const selected = room.id === selectedRoomId; + + return ( + + ); + })} +
+
+ ); +}; diff --git a/src/widgets/space-focus-hud/index.ts b/src/widgets/space-focus-hud/index.ts new file mode 100644 index 0000000..ae7cb7b --- /dev/null +++ b/src/widgets/space-focus-hud/index.ts @@ -0,0 +1 @@ +export * from './ui/SpaceFocusHudWidget'; diff --git a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx new file mode 100644 index 0000000..10d7eb6 --- /dev/null +++ b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx @@ -0,0 +1,14 @@ +import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud'; + +interface SpaceFocusHudWidgetProps { + goal: string; + visible: boolean; +} + +export const SpaceFocusHudWidget = ({ goal, visible }: SpaceFocusHudWidgetProps) => { + if (!visible) { + return null; + } + + return ; +}; diff --git a/src/widgets/space-setup-drawer/index.ts b/src/widgets/space-setup-drawer/index.ts new file mode 100644 index 0000000..d9c66a3 --- /dev/null +++ b/src/widgets/space-setup-drawer/index.ts @@ -0,0 +1 @@ +export * from './ui/SpaceSetupDrawerWidget'; diff --git a/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx b/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx new file mode 100644 index 0000000..eeb0d67 --- /dev/null +++ b/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx @@ -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 ( + + 시작하기 + + )} + > +
+
+
+

Space

+

오늘 머물 공간을 하나 고르세요.

+
+ +
+ +
+
+

Goal

+

스킵 없이 한 줄 목표를 남겨주세요.

+
+ +
+ +
+
+

Sound

+

선택 항목이에요. 필요 없으면 그대로 시작해도 됩니다.

+
+ +
+ {soundPresets.map((preset) => { + const selected = preset.id === selectedSoundPresetId; + + return ( + + ); + })} +
+
+
+
+ ); +}; diff --git a/src/widgets/space-sheet-shell/index.ts b/src/widgets/space-sheet-shell/index.ts new file mode 100644 index 0000000..8a4fb12 --- /dev/null +++ b/src/widgets/space-sheet-shell/index.ts @@ -0,0 +1 @@ +export * from './ui/SpaceSideSheet'; diff --git a/src/widgets/space-sheet-shell/ui/SpaceSideSheet.tsx b/src/widgets/space-sheet-shell/ui/SpaceSideSheet.tsx new file mode 100644 index 0000000..c690e30 --- /dev/null +++ b/src/widgets/space-sheet-shell/ui/SpaceSideSheet.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useEffect, type ReactNode } from 'react'; +import { cn } from '@/shared/lib/cn'; + +interface SpaceSideSheetProps { + open: boolean; + title: string; + subtitle?: string; + onClose: () => void; + children: ReactNode; + footer?: ReactNode; + widthClassName?: string; +} + +export const SpaceSideSheet = ({ + open, + title, + subtitle, + onClose, + children, + footer, + widthClassName, +}: SpaceSideSheetProps) => { + useEffect(() => { + if (!open) { + return; + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + + return () => { + document.removeEventListener('keydown', handleEscape); + }; + }, [open, onClose]); + + if (!open) { + return null; + } + + return ( + <> + + + +
{children}
+ + {footer ?
{footer}
: null} +
+ + + ); +}; diff --git a/src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx b/src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx index b8529e1..948f82b 100644 --- a/src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx +++ b/src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx @@ -1,139 +1,5 @@ -'use client'; - -import { useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { useSearchParams } from 'next/navigation'; -import { getRoomBackgroundStyle, getRoomById, ROOM_THEMES } from '@/entities/room'; -import { SOUND_PRESETS, useThoughtInbox, type RecentThought } from '@/entities/session'; -import { useImmersionMode } from '@/features/immersion-mode'; -import { cn } from '@/shared/lib/cn'; -import { SpaceChromeWidget } from '@/widgets/space-chrome'; -import { SpaceExitSummarySheet } from '@/widgets/space-exit-summary'; -import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud'; -import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock'; +import { SpaceWorkspaceWidget } from '@/widgets/space-workspace'; export const SpaceSkeletonWidget = () => { - const router = useRouter(); - const searchParams = useSearchParams(); - const { addThought } = useThoughtInbox(); - - const roomId = searchParams.get('room') ?? ROOM_THEMES[0].id; - const goal = searchParams.get('goal') ?? '오늘은 한 조각만 집중해요'; - const timerLabel = searchParams.get('timer') ?? '25/5'; - const soundFromQuery = searchParams.get('sound'); - - const room = useMemo(() => getRoomById(roomId) ?? ROOM_THEMES[0], [roomId]); - const { isImmersionMode, toggleImmersionMode, exitImmersionMode } = useImmersionMode(); - const [isExitSummaryOpen, setExitSummaryOpen] = useState(false); - const [sessionThoughts, setSessionThoughts] = useState([]); - const initialSoundPresetId = - SOUND_PRESETS.find((preset) => preset.id === soundFromQuery)?.id ?? - SOUND_PRESETS[0].id; - - const handleExitRequested = () => { - setExitSummaryOpen(true); - if (isImmersionMode) { - exitImmersionMode(); - } - }; - - const handleThoughtCaptured = (note: string) => { - addThought(note, room.name); - setSessionThoughts((current) => [ - { - id: `session-thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, - text: note, - roomName: room.name, - capturedAt: '방금 전', - }, - ...current, - ]); - }; - - return ( -
-
-
-
-
-
- -
- - -
-
- - - - - - setExitSummaryOpen(false)} - onMoveToHub={() => router.push('/app')} - /> -
- ); + return ; }; diff --git a/src/widgets/space-tools-dock/index.ts b/src/widgets/space-tools-dock/index.ts index 8df5d40..491f34b 100644 --- a/src/widgets/space-tools-dock/index.ts +++ b/src/widgets/space-tools-dock/index.ts @@ -1 +1,2 @@ +export * from './model/types'; export * from './ui/SpaceToolsDockWidget'; diff --git a/src/widgets/space-tools-dock/model/types.ts b/src/widgets/space-tools-dock/model/types.ts new file mode 100644 index 0000000..2c9c78f --- /dev/null +++ b/src/widgets/space-tools-dock/model/types.ts @@ -0,0 +1 @@ +export type SpaceToolPanelId = 'sound' | 'notes' | 'inbox' | 'stats' | 'settings'; diff --git a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx index 297c3b3..16d8ffd 100644 --- a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx +++ b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx @@ -1,205 +1,161 @@ 'use client'; import { useState } from 'react'; -import type { RoomPresence } from '@/entities/room'; -import { CHECK_IN_PHRASES, REACTION_OPTIONS } from '@/entities/session'; -import { useCheckIn } from '@/features/check-in'; -import { useSoundPresetSelection } from '@/features/sound-preset'; +import type { RecentThought } from '@/entities/session'; +import type { SoundTrackKey } from '@/features/sound-preset'; import { useToast } from '@/shared/ui'; import { cn } from '@/shared/lib/cn'; -import { NotesSheetWidget } from '@/widgets/notes-sheet'; -import { QuickSheetWidget } from '@/widgets/quick-sheet'; -import { RoomSheetWidget } from '@/widgets/room-sheet'; -import { SoundSheetWidget } from '@/widgets/sound-sheet'; -import { useSpaceToolsDock } from '../model/useSpaceToolsDock'; +import { SpaceSideSheet } from '@/widgets/space-sheet-shell'; +import type { SpaceToolPanelId } from '../model/types'; +import { InboxToolPanel } from './panels/InboxToolPanel'; +import { NotesToolPanel } from './panels/NotesToolPanel'; +import { SettingsToolPanel } from './panels/SettingsToolPanel'; +import { SoundToolPanel } from './panels/SoundToolPanel'; +import { StatsToolPanel } from './panels/StatsToolPanel'; interface SpaceToolsDockWidgetProps { - roomName: string; - activeMembers: number; - presence: RoomPresence; - initialSoundPresetId?: string; - isImmersionMode: boolean; - onToggleImmersionMode: () => void; - onThoughtCaptured?: (note: string) => void; + isFocusMode: boolean; + thoughts: RecentThought[]; + thoughtCount: number; + selectedPresetId: string; + onSelectPreset: (presetId: string) => void; + isMixerOpen: boolean; + onToggleMixer: () => void; + isMuted: boolean; + onMuteChange: (next: boolean) => void; + masterVolume: number; + onMasterVolumeChange: (next: number) => void; + trackKeys: readonly SoundTrackKey[]; + trackLevels: Record; + onTrackLevelChange: (track: SoundTrackKey, level: number) => void; + onCaptureThought: (note: string) => void; + onClearInbox: () => void; } -const TOOL_BUTTONS: Array<{ - id: 'sound' | 'room' | 'notes' | 'quick'; +const TOOL_ITEMS: Array<{ + id: SpaceToolPanelId; icon: string; label: string; }> = [ { id: 'sound', icon: '🎧', label: 'Sound' }, - { id: 'room', icon: '👥', label: 'Room' }, { id: 'notes', icon: '📝', label: 'Notes' }, - { id: 'quick', icon: '⚙️', label: 'Quick' }, + { id: 'inbox', icon: '📨', label: 'Inbox' }, + { id: 'stats', icon: '📊', label: 'Stats' }, + { id: 'settings', icon: '⚙', label: 'Settings' }, ]; +const PANEL_TITLE_MAP: Record = { + sound: '사운드', + notes: '생각 던지기', + inbox: '인박스', + stats: '집중 요약', + settings: '설정', +}; + export const SpaceToolsDockWidget = ({ - roomName, - activeMembers, - presence, - initialSoundPresetId, - isImmersionMode, - onToggleImmersionMode, - onThoughtCaptured, + isFocusMode, + thoughts, + thoughtCount, + selectedPresetId, + onSelectPreset, + isMixerOpen, + onToggleMixer, + isMuted, + onMuteChange, + masterVolume, + onMasterVolumeChange, + trackKeys, + trackLevels, + onTrackLevelChange, + onCaptureThought, + onClearInbox, }: SpaceToolsDockWidgetProps) => { const { pushToast } = useToast(); - const { lastCheckIn, recordCheckIn } = useCheckIn(); - const { activePanel, closePanel, togglePanel } = useSpaceToolsDock(); - const [isRailExpanded, setRailExpanded] = useState(false); - const { - selectedPresetId, - setSelectedPresetId, - isMixerOpen, - setMixerOpen, - isMuted, - setMuted, - masterVolume, - setMasterVolume, - trackLevels, - setTrackLevel, - trackKeys, - } = useSoundPresetSelection(initialSoundPresetId); - - const isDockExpanded = !isImmersionMode || isRailExpanded || activePanel !== null; - - const handleClosePanel = () => { - closePanel(); - if (isImmersionMode) { - setRailExpanded(false); - } - }; - - const handleCheckIn = (message: string) => { - recordCheckIn(message); - pushToast({ title: `체크인: ${message}` }); - }; - - const handleReaction = (emoji: string) => { - pushToast({ title: `리액션: ${emoji}` }); - }; - - const handleToolSelect = (panel: (typeof TOOL_BUTTONS)[number]['id']) => { - if (isImmersionMode) { - setRailExpanded(true); - } - togglePanel(panel); - }; + const [activePanel, setActivePanel] = useState(null); return ( <> - {activePanel ? ( - - )) - ) : ( - - )} + ); + })}
- {activePanel === 'sound' ? ( - setMixerOpen((current) => !current)} - isMuted={isMuted} - onMuteChange={setMuted} - masterVolume={masterVolume} - onMasterVolumeChange={setMasterVolume} - trackKeys={trackKeys} - trackLevels={trackLevels} - onTrackLevelChange={setTrackLevel} - /> - ) : null} + setActivePanel(null)} + > + {activePanel === 'sound' ? ( + + ) : null} - {activePanel === 'room' ? ( - handleReaction(reaction.emoji)} - /> - ) : null} + {activePanel === 'notes' ? ( + { + onCaptureThought(note); + pushToast({ title: '인박스에 주차했어요 (더미)' }); + }} + /> + ) : null} - {activePanel === 'notes' ? ( - { - pushToast({ title: `노트 추가: ${note}` }); - onThoughtCaptured?.(note); - }} - onNoteRemoved={() => pushToast({ title: '노트를 정리했어요' })} - /> - ) : null} + {activePanel === 'inbox' ? ( + { + onClearInbox(); + pushToast({ title: '인박스를 비웠어요 (더미)' }); + }} + /> + ) : null} - {activePanel === 'quick' ? ( - - ) : null} + {activePanel === 'stats' ? : null} + {activePanel === 'settings' ? : null} + ); }; diff --git a/src/widgets/space-tools-dock/ui/panels/InboxToolPanel.tsx b/src/widgets/space-tools-dock/ui/panels/InboxToolPanel.tsx new file mode 100644 index 0000000..9a448eb --- /dev/null +++ b/src/widgets/space-tools-dock/ui/panels/InboxToolPanel.tsx @@ -0,0 +1,25 @@ +import type { RecentThought } from '@/entities/session'; +import { InboxList } from '@/features/inbox'; + +interface InboxToolPanelProps { + thoughts: RecentThought[]; + onClear: () => void; +} + +export const InboxToolPanel = ({ thoughts, onClear }: InboxToolPanelProps) => { + return ( +
+
+

나중에 모아보는 읽기 전용 인박스

+ +
+ +
+ ); +}; diff --git a/src/widgets/space-tools-dock/ui/panels/NotesToolPanel.tsx b/src/widgets/space-tools-dock/ui/panels/NotesToolPanel.tsx new file mode 100644 index 0000000..c2417f7 --- /dev/null +++ b/src/widgets/space-tools-dock/ui/panels/NotesToolPanel.tsx @@ -0,0 +1,16 @@ +import { DistractionDumpNotesContent } from '@/features/distraction-dump'; + +interface NotesToolPanelProps { + onCaptureThought: (note: string) => void; +} + +export const NotesToolPanel = ({ onCaptureThought }: NotesToolPanelProps) => { + return ( +
+

+ 집중 중 떠오른 생각을 잠깐 주차하세요. 저장 동작은 더미 토스트로만 처리됩니다. +

+ +
+ ); +}; diff --git a/src/widgets/space-tools-dock/ui/panels/SettingsToolPanel.tsx b/src/widgets/space-tools-dock/ui/panels/SettingsToolPanel.tsx new file mode 100644 index 0000000..58767b5 --- /dev/null +++ b/src/widgets/space-tools-dock/ui/panels/SettingsToolPanel.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState } from 'react'; +import { DEFAULT_PRESET_OPTIONS } from '@/shared/config/settingsOptions'; +import { cn } from '@/shared/lib/cn'; + +export const SettingsToolPanel = () => { + const [reduceMotion, setReduceMotion] = useState(false); + const [defaultPresetId, setDefaultPresetId] = useState< + (typeof DEFAULT_PRESET_OPTIONS)[number]['id'] + >(DEFAULT_PRESET_OPTIONS[0].id); + + return ( +
+
+
+
+

Reduce Motion

+

화면 전환을 조금 더 차분하게 표시합니다.

+
+ +
+
+ +
+

기본 프리셋

+
+ {DEFAULT_PRESET_OPTIONS.map((preset) => ( + + ))} +
+
+
+ ); +}; diff --git a/src/widgets/space-tools-dock/ui/panels/SoundToolPanel.tsx b/src/widgets/space-tools-dock/ui/panels/SoundToolPanel.tsx new file mode 100644 index 0000000..6058faa --- /dev/null +++ b/src/widgets/space-tools-dock/ui/panels/SoundToolPanel.tsx @@ -0,0 +1,48 @@ +import { SoundPresetControls, type SoundTrackKey } from '@/features/sound-preset'; + +interface SoundToolPanelProps { + selectedPresetId: string; + onSelectPreset: (presetId: string) => void; + isMixerOpen: boolean; + onToggleMixer: () => void; + isMuted: boolean; + onMuteChange: (next: boolean) => void; + masterVolume: number; + onMasterVolumeChange: (next: number) => void; + trackKeys: readonly SoundTrackKey[]; + trackLevels: Record; + onTrackLevelChange: (track: SoundTrackKey, level: number) => void; +} + +export const SoundToolPanel = ({ + selectedPresetId, + onSelectPreset, + isMixerOpen, + onToggleMixer, + isMuted, + onMuteChange, + masterVolume, + onMasterVolumeChange, + trackKeys, + trackLevels, + onTrackLevelChange, +}: SoundToolPanelProps) => { + return ( +
+

오디오 재생은 연결하지 않은 UI 목업입니다.

+ +
+ ); +}; diff --git a/src/widgets/space-tools-dock/ui/panels/StatsToolPanel.tsx b/src/widgets/space-tools-dock/ui/panels/StatsToolPanel.tsx new file mode 100644 index 0000000..29c5177 --- /dev/null +++ b/src/widgets/space-tools-dock/ui/panels/StatsToolPanel.tsx @@ -0,0 +1,26 @@ +import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session'; + +export const StatsToolPanel = () => { + const previewStats = [TODAY_STATS[0], TODAY_STATS[1], WEEKLY_STATS[0], WEEKLY_STATS[2]]; + + return ( +
+

오늘 흐름과 최근 7일 리듬을 가볍게 확인하세요.

+ +
+ {previewStats.map((stat) => ( +
+

{stat.label}

+

{stat.value}

+

{stat.delta}

+
+ ))} +
+ +
+
+

그래프 플레이스홀더

+
+
+ ); +}; diff --git a/src/widgets/space-workspace/index.ts b/src/widgets/space-workspace/index.ts new file mode 100644 index 0000000..30ed239 --- /dev/null +++ b/src/widgets/space-workspace/index.ts @@ -0,0 +1 @@ +export * from './ui/SpaceWorkspaceWidget'; diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx new file mode 100644 index 0000000..9437c0c --- /dev/null +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -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('setup'); + const [isSetupDrawerOpen, setSetupDrawerOpen] = useState(true); + const [selectedRoomId, setSelectedRoomId] = useState(initialRoomId); + const [goalInput, setGoalInput] = useState(initialGoal); + const [selectedGoalId, setSelectedGoalId] = useState(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 ( +
+
+
+
+
+ +
+
+
+

VibeRoom

+

+ {selectedRoom.name} · {selectedRoom.vibeLabel} +

+
+ + {!isSetupDrawerOpen ? ( + + ) : null} +
+ +
+ {isFocusMode ? null : ( +
+
+

Workspace

+

+ 공간을 고르고 시작하세요 +

+

+ 오른쪽 Setup에서 목표를 입력하면 바로 몰입 화면으로 전환됩니다. +

+
+
+ )} +
+
+ + setSetupDrawerOpen(false)} + onRoomSelect={setSelectedRoomId} + onGoalChange={handleGoalChange} + onGoalChipSelect={handleGoalChipSelect} + onSoundSelect={setSelectedPresetId} + onStart={handleStart} + /> + + + + 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} + /> +
+ ); +};