fix(space-ui): /space 포커스 앵커 잘림과 스크롤 문제 수정

This commit is contained in:
2026-03-03 17:50:49 +09:00
parent ef9cc63cc5
commit be16153bef
8 changed files with 735 additions and 306 deletions

View File

@@ -52,10 +52,10 @@ body {
@keyframes space-stage-pan { @keyframes space-stage-pan {
0% { 0% {
transform: scale(1.02) translate3d(-0.4%, -0.25%, 0); transform: translate3d(-0.55%, -0.35%, 0);
} }
100% { 100% {
transform: scale(1.07) translate3d(0.55%, 0.35%, 0); transform: translate3d(0.55%, 0.35%, 0);
} }
} }
@@ -69,3 +69,14 @@ body {
opacity: 1; opacity: 1;
} }
} }
@keyframes popover-rise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -2,13 +2,25 @@ import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
interface SpaceFocusHudWidgetProps { interface SpaceFocusHudWidgetProps {
goal: string; goal: string;
timerLabel: string;
visible: boolean; visible: boolean;
} }
export const SpaceFocusHudWidget = ({ goal, visible }: SpaceFocusHudWidgetProps) => { export const SpaceFocusHudWidget = ({
goal,
timerLabel,
visible,
}: SpaceFocusHudWidgetProps) => {
if (!visible) { if (!visible) {
return null; return null;
} }
return <SpaceTimerHudWidget timerLabel="25/5" goal={goal} isImmersionMode className="pr-[4.2rem]" />; return (
<SpaceTimerHudWidget
timerLabel={timerLabel}
goal={goal}
isImmersionMode
className="pr-[4.2rem]"
/>
);
}; };

View File

@@ -1,51 +1,131 @@
'use client'; 'use client';
import type { FormEvent } from 'react'; import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { RoomTheme } from '@/entities/room'; import type { RoomTheme } from '@/entities/room';
import type { GoalChip, SoundPreset } from '@/entities/session'; import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session';
import { SpaceSelectCarousel } from '@/features/space-select'; import { SpaceSelectCarousel } from '@/features/space-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';
import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
type RitualPopover = 'space' | 'timer' | 'sound';
interface SpaceSetupDrawerWidgetProps { interface SpaceSetupDrawerWidgetProps {
open: boolean; open: boolean;
dismissible?: boolean;
rooms: RoomTheme[]; rooms: RoomTheme[];
selectedRoomId: string; selectedRoomId: string;
selectedTimerLabel: string;
selectedSoundPresetId: string;
goalInput: string; goalInput: string;
selectedGoalId: string | null; selectedGoalId: string | null;
selectedSoundPresetId: string;
goalChips: GoalChip[]; goalChips: GoalChip[];
soundPresets: SoundPreset[]; soundPresets: SoundPreset[];
timerPresets: TimerPreset[];
canStart: boolean; canStart: boolean;
onClose: () => void;
onRoomSelect: (roomId: string) => void; onRoomSelect: (roomId: string) => void;
onTimerSelect: (timerLabel: string) => void;
onSoundSelect: (soundPresetId: string) => void;
onGoalChange: (value: string) => void; onGoalChange: (value: string) => void;
onGoalChipSelect: (chip: GoalChip) => void; onGoalChipSelect: (chip: GoalChip) => void;
onSoundSelect: (soundPresetId: string) => void;
onStart: () => void; onStart: () => void;
} }
interface SummaryChipProps {
label: string;
value: string;
open: boolean;
onClick: () => void;
}
const SummaryChip = ({ label, value, open, onClick }: SummaryChipProps) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] transition-colors',
open
? 'border-sky-200/42 bg-sky-200/14 text-white/92'
: 'border-white/14 bg-white/[0.04] text-white/80 hover:bg-white/[0.09]',
)}
>
<span className="text-white/62">{label}</span>
<span className="max-w-[124px] truncate text-white/92">{value}</span>
<span aria-hidden className="text-white/56"></span>
</button>
);
};
export const SpaceSetupDrawerWidget = ({ export const SpaceSetupDrawerWidget = ({
open, open,
dismissible = true,
rooms, rooms,
selectedRoomId, selectedRoomId,
selectedTimerLabel,
selectedSoundPresetId,
goalInput, goalInput,
selectedGoalId, selectedGoalId,
selectedSoundPresetId,
goalChips, goalChips,
soundPresets, soundPresets,
timerPresets,
canStart, canStart,
onClose,
onRoomSelect, onRoomSelect,
onTimerSelect,
onSoundSelect,
onGoalChange, onGoalChange,
onGoalChipSelect, onGoalChipSelect,
onSoundSelect,
onStart, onStart,
}: SpaceSetupDrawerWidgetProps) => { }: SpaceSetupDrawerWidgetProps) => {
const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const selectedRoom = useMemo(() => {
return rooms.find((room) => room.id === selectedRoomId) ?? rooms[0];
}, [rooms, selectedRoomId]);
const selectedSoundLabel = useMemo(() => {
return (
soundPresets.find((preset) => preset.id === selectedSoundPresetId)?.label ??
soundPresets[0]?.label ??
'기본'
);
}, [selectedSoundPresetId, soundPresets]);
useEffect(() => {
if (!openPopover) {
return;
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpenPopover(null);
}
};
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (!panelRef.current?.contains(target)) {
setOpenPopover(null);
}
};
document.addEventListener('keydown', handleEscape);
document.addEventListener('mousedown', handlePointerDown);
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('mousedown', handlePointerDown);
};
}, [openPopover]);
if (!open) {
return null;
}
const togglePopover = (popover: RitualPopover) => {
setOpenPopover((current) => (current === popover ? null : popover));
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => { const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -57,70 +137,73 @@ export const SpaceSetupDrawerWidget = ({
}; };
return ( return (
<SpaceSideSheet <section
open={open} className="fixed left-1/2 top-1/2 z-40 w-[min(428px,92vw)] -translate-x-1/2 -translate-y-1/2"
title="오늘은 한 조각만." aria-label="집중 시작 패널"
subtitle="공간을 고르고, 한 줄 목표를 적으면 시작돼요."
onClose={onClose}
dismissible={dismissible}
widthClassName="w-[min(360px,94vw)]"
footer={(
<div className="space-y-1.5">
{!canStart ? (
<p className="text-[10px] text-white/56"> .</p>
) : null}
<Button
type="submit"
form="space-setup-ritual-form"
size="full"
disabled={!canStart}
className={cn(
'h-10 rounded-xl !bg-sky-300/84 !text-slate-900 shadow-[0_6px_14px_rgba(125,211,252,0.2)] hover:!bg-sky-300 disabled:!bg-white/10 disabled:!text-white/42',
)}
> >
<div
</Button> ref={panelRef}
</div> className="rounded-3xl border border-white/14 bg-[linear-gradient(160deg,rgba(15,23,42,0.68)_0%,rgba(8,13,27,0.56)_100%)] p-4 text-white shadow-[0_22px_52px_rgba(2,6,23,0.38)] backdrop-blur-2xl sm:p-5"
)}
> >
<form id="space-setup-ritual-form" className="space-y-4" onSubmit={handleSubmit}> <header className="mb-3 space-y-1">
<section className="space-y-2"> <p className="text-[10px] uppercase tracking-[0.18em] text-white/48">Ritual</p>
<p className="text-[12px] font-medium text-white/84"></p> <h1 className="text-[1.45rem] font-semibold leading-tight text-white"> .</h1>
<SpaceSelectCarousel <p className="text-xs text-white/60"> Focus .</p>
rooms={rooms} </header>
selectedRoomId={selectedRoomId}
onSelect={onRoomSelect}
/>
</section>
<section className="space-y-2 border-t border-white/8 pt-3">
<p className="text-[12px] font-medium text-white/84"> ()</p>
<SessionGoalField
autoFocus={open}
goalInput={goalInput}
selectedGoalId={selectedGoalId}
goalChips={goalChips}
onGoalChange={onGoalChange}
onGoalChipSelect={onGoalChipSelect}
/>
</section>
<section className="space-y-2 border-t border-white/8 pt-3">
<p className="text-[12px] font-medium text-white/84">()</p>
<div className="relative mb-3">
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{soundPresets.slice(0, 6).map((preset) => { <SummaryChip
const selected = preset.id === selectedSoundPresetId; label="공간"
value={selectedRoom?.name ?? '기본 공간'}
open={openPopover === 'space'}
onClick={() => togglePopover('space')}
/>
<SummaryChip
label="타이머"
value={selectedTimerLabel}
open={openPopover === 'timer'}
onClick={() => togglePopover('timer')}
/>
<SummaryChip
label="사운드"
value={selectedSoundLabel}
open={openPopover === 'sound'}
onClick={() => togglePopover('sound')}
/>
</div>
{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">
<SpaceSelectCarousel
rooms={rooms.slice(0, 4)}
selectedRoomId={selectedRoomId}
onSelect={(roomId) => {
onRoomSelect(roomId);
setOpenPopover(null);
}}
/>
</div>
) : null}
{openPopover === 'timer' ? (
<div className="absolute left-0 top-[calc(100%+0.5rem)] z-20 rounded-2xl border border-white/14 bg-slate-950/80 p-3 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="flex flex-wrap gap-1.5">
{timerPresets.slice(0, 3).map((preset) => {
const selected = preset.label === selectedTimerLabel;
return ( return (
<button <button
key={preset.id} key={preset.id}
type="button" type="button"
onClick={() => onSoundSelect(preset.id)} onClick={() => {
onTimerSelect(preset.label);
setOpenPopover(null);
}}
className={cn( className={cn(
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors', 'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
selected selected
? 'border-sky-200/30 bg-sky-200/12 text-white/90' ? 'border-sky-200/34 bg-sky-200/14 text-white/90'
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8', : 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
)} )}
> >
@@ -129,8 +212,65 @@ export const SpaceSetupDrawerWidget = ({
); );
})} })}
</div> </div>
</section> </div>
) : null}
{openPopover === 'sound' ? (
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-20 w-[min(300px,88vw)] rounded-2xl border border-white/14 bg-slate-950/80 p-3 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="flex flex-wrap gap-1.5">
{soundPresets.slice(0, 6).map((preset) => {
const selected = preset.id === selectedSoundPresetId;
return (
<button
key={preset.id}
type="button"
onClick={() => {
onSoundSelect(preset.id);
setOpenPopover(null);
}}
className={cn(
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
selected
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
)}
>
{preset.label}
</button>
);
})}
</div>
</div>
) : null}
</div>
<form id="space-setup-ritual-form" className="space-y-3" onSubmit={handleSubmit}>
<SessionGoalField
autoFocus={open}
goalInput={goalInput}
selectedGoalId={selectedGoalId}
goalChips={goalChips}
onGoalChange={onGoalChange}
onGoalChipSelect={onGoalChipSelect}
/>
<div className="space-y-1.5 pt-1">
{!canStart ? <p className="text-[10px] text-white/56"> .</p> : null}
<Button
type="submit"
form="space-setup-ritual-form"
size="full"
disabled={!canStart}
className={cn(
'h-10 rounded-xl !bg-sky-300/84 !text-slate-900 shadow-[0_8px_16px_rgba(125,211,252,0.24)] hover:!bg-sky-300 disabled:!bg-white/10 disabled:!text-white/42',
)}
>
</Button>
</div>
</form> </form>
</SpaceSideSheet> </div>
</section>
); );
}; };

View File

@@ -36,13 +36,17 @@ export const SpaceTimerHudWidget = ({
)} )}
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 0.35rem)' }} style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 0.35rem)' }}
> >
<div className="mx-auto w-full max-w-xl pointer-events-auto"> <div className="relative mx-auto w-full max-w-xl pointer-events-auto">
<div
aria-hidden
className="pointer-events-none absolute left-1/2 top-1/2 z-0 h-28 w-[min(760px,96vw)] -translate-x-1/2 -translate-y-1/2 bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.2)_0%,rgba(2,6,23,0.12)_45%,rgba(2,6,23,0)_78%)]"
/>
<section <section
className={cn( className={cn(
'flex h-16 items-center justify-between gap-3 rounded-2xl px-3.5 transition-colors', 'relative z-10 flex h-16 items-center justify-between gap-3 rounded-2xl px-3.5 transition-colors',
isImmersionMode isImmersionMode
? 'border border-white/6 bg-black/16 backdrop-blur-md' ? 'border border-white/12 bg-black/22 backdrop-blur-md'
: 'border border-white/10 bg-black/25 backdrop-blur-sm', : 'border border-white/12 bg-black/24 backdrop-blur-md',
)} )}
> >
<div className="min-w-0"> <div className="min-w-0">
@@ -50,29 +54,29 @@ export const SpaceTimerHudWidget = ({
<span <span
className={cn( className={cn(
'text-[11px] font-semibold uppercase tracking-[0.16em]', 'text-[11px] font-semibold uppercase tracking-[0.16em]',
isImmersionMode ? 'text-white/45' : 'text-white/62', isImmersionMode ? 'text-white/90' : 'text-white/88',
)} )}
> >
{isBreatheMode ? RECOVERY_30S_MODE_LABEL : 'Focus'} {isBreatheMode ? RECOVERY_30S_MODE_LABEL : 'Focus'}
</span> </span>
<span <span
className={cn( className={cn(
'text-2xl font-semibold tracking-tight', 'text-[1.7rem] font-semibold tracking-tight sm:text-[1.78rem]',
isImmersionMode ? 'text-white/72' : 'text-white', isImmersionMode ? 'text-white/90' : 'text-white/92',
)} )}
> >
25:00 25:00
</span> </span>
<span className={cn('text-[11px]', isImmersionMode ? 'text-white/42' : 'text-white/62')}> <span className={cn('text-[11px]', isImmersionMode ? 'text-white/65' : 'text-white/65')}>
{timerLabel} {timerLabel}
</span> </span>
</div> </div>
{hintMessage ? ( {hintMessage ? (
<p className={cn('truncate text-[11px]', isImmersionMode ? 'text-white/54' : 'text-white/64')}> <p className={cn('truncate text-[11px]', isImmersionMode ? 'text-white/50' : 'text-white/50')}>
{hintMessage} {hintMessage}
</p> </p>
) : ( ) : (
<p className={cn('truncate text-[11px]', isImmersionMode ? 'text-white/44' : 'text-white/58')}> <p className={cn('truncate text-[11px]', isImmersionMode ? 'text-white/65' : 'text-white/65')}>
: {goal} : {goal}
</p> </p>
)} )}
@@ -88,8 +92,8 @@ export const SpaceTimerHudWidget = ({
className={cn( className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-full border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80', 'inline-flex h-8 w-8 items-center justify-center rounded-full border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80',
isImmersionMode isImmersionMode
? 'border-white/10 bg-white/5 text-white/64 hover:bg-white/10' ? 'border-white/14 bg-black/26 text-white/82 hover:bg-black/34'
: 'border-white/15 bg-white/8 text-white/82 hover:bg-white/14', : 'border-white/14 bg-black/26 text-white/84 hover:bg-black/34',
)} )}
> >
<span aria-hidden>{action.icon}</span> <span aria-hidden>{action.icon}</span>
@@ -97,7 +101,10 @@ export const SpaceTimerHudWidget = ({
</button> </button>
))} ))}
</div> </div>
<Restart30sAction onTrigger={triggerRestart} /> <Restart30sAction
onTrigger={triggerRestart}
className={cn(isImmersionMode ? 'text-white/72 hover:text-white/92' : 'text-white/74 hover:text-white/92')}
/>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -1 +1,2 @@
export type SpaceToolPanelId = 'sound' | 'notes' | 'inbox' | 'stats' | 'settings'; export type SpaceAnchorPopoverId = 'sound' | 'notes';
export type SpaceUtilityPanelId = 'settings' | 'inbox' | 'stats';

View File

@@ -1,201 +1,369 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { RecentThought } from '@/entities/session'; import type { RoomTheme } from '@/entities/room';
import type { SoundTrackKey } from '@/features/sound-preset'; import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
import { ExitHoldButton } from '@/features/exit-hold';
import { useToast } from '@/shared/ui'; import { useToast } from '@/shared/ui';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
import { SpaceSideSheet } from '@/widgets/space-sheet-shell'; import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
import type { SpaceToolPanelId } from '../model/types'; import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from '../model/types';
import { InboxToolPanel } from './panels/InboxToolPanel'; import { InboxToolPanel } from './panels/InboxToolPanel';
import { NotesToolPanel } from './panels/NotesToolPanel';
import { SettingsToolPanel } from './panels/SettingsToolPanel'; import { SettingsToolPanel } from './panels/SettingsToolPanel';
import { SoundToolPanel } from './panels/SoundToolPanel';
import { StatsToolPanel } from './panels/StatsToolPanel'; import { StatsToolPanel } from './panels/StatsToolPanel';
interface SpaceToolsDockWidgetProps { interface SpaceToolsDockWidgetProps {
isFocusMode: boolean; isFocusMode: boolean;
rooms: RoomTheme[];
selectedRoomId: string;
selectedTimerLabel: string;
timerPresets: TimerPreset[];
selectedPresetId: string;
thoughts: RecentThought[]; thoughts: RecentThought[];
thoughtCount: number; thoughtCount: number;
selectedPresetId: string; onRoomSelect: (roomId: string) => void;
onTimerSelect: (timerLabel: string) => void;
onSelectPreset: (presetId: string) => void; 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<SoundTrackKey, number>;
onTrackLevelChange: (track: SoundTrackKey, level: number) => void;
onCaptureThought: (note: string) => void; onCaptureThought: (note: string) => void;
onClearInbox: () => void; onClearInbox: () => void;
onExitRequested: () => void;
} }
const TOOL_ITEMS: Array<{ const ANCHOR_ICON = {
id: SpaceToolPanelId; sound: (
label: string; <svg
}> = [ viewBox="0 0 24 24"
{ id: 'sound', label: 'Sound' }, fill="none"
{ id: 'notes', label: 'Notes' }, className="h-4 w-4"
{ id: 'inbox', label: 'Inbox' }, stroke="currentColor"
{ id: 'stats', label: 'Stats' }, strokeWidth="1.8"
{ id: 'settings', label: 'Settings' }, strokeLinecap="round"
]; strokeLinejoin="round"
>
<path d="M4 13V11a2 2 0 0 1 2-2h2l3-3h2v12h-2l-3-3H6a2 2 0 0 1-2-2Z" />
<path d="M16 9a4 4 0 0 1 0 6" />
</svg>
),
notes: (
<svg
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 4h9l3 3v13H6z" />
<path d="M15 4v4h4" />
<path d="M9 12h6M9 16h4" />
</svg>
),
};
const PANEL_TITLE_MAP: Record<SpaceToolPanelId, string> = { const UTILITY_PANEL_TITLE: Record<SpaceUtilityPanelId, string> = {
sound: '사운드',
notes: '생각 던지기',
inbox: '인박스', inbox: '인박스',
stats: '집중 요약', stats: '집중 요약',
settings: '설정', settings: '설정',
}; };
const DockIcon = ({ id }: { id: SpaceToolPanelId }) => { const formatThoughtCount = (count: number) => {
const commonProps = { if (count < 1) {
viewBox: '0 0 24 24', return '0';
fill: 'none',
className: 'h-4 w-4',
stroke: 'currentColor',
strokeWidth: 1.8,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
};
switch (id) {
case 'sound':
return (
<svg {...commonProps}>
<path d="M4 13V11a2 2 0 0 1 2-2h2l3-3h2v12h-2l-3-3H6a2 2 0 0 1-2-2Z" />
<path d="M16 9a4 4 0 0 1 0 6" />
<path d="M18 7a7 7 0 0 1 0 10" />
</svg>
);
case 'notes':
return (
<svg {...commonProps}>
<rect x="5" y="4" width="14" height="16" rx="2.5" />
<path d="M9 9h6M9 13h6M9 17h4" />
</svg>
);
case 'inbox':
return (
<svg {...commonProps}>
<path d="M4 7.5A2.5 2.5 0 0 1 6.5 5h11A2.5 2.5 0 0 1 20 7.5v9A2.5 2.5 0 0 1 17.5 19h-11A2.5 2.5 0 0 1 4 16.5v-9Z" />
<path d="M4 12h4l1.5 2h5L16 12h4" />
</svg>
);
case 'stats':
return (
<svg {...commonProps}>
<path d="M5 18V10M12 18V7M19 18v-5" />
<path d="M4 18h16" />
</svg>
);
case 'settings':
return (
<svg {...commonProps}>
<path d="M12 8.5A3.5 3.5 0 1 1 8.5 12 3.5 3.5 0 0 1 12 8.5Z" />
<path d="M12 3.5v2M12 18.5v2M20.5 12h-2M5.5 12h-2M18 6l-1.4 1.4M7.4 16.6 6 18M18 18l-1.4-1.4M7.4 7.4 6 6" />
</svg>
);
default:
return null;
} }
if (count > 9) {
return '9+';
}
return String(count);
}; };
export const SpaceToolsDockWidget = ({ export const SpaceToolsDockWidget = ({
isFocusMode, isFocusMode,
rooms,
selectedRoomId,
selectedTimerLabel,
timerPresets,
selectedPresetId,
thoughts, thoughts,
thoughtCount, thoughtCount,
selectedPresetId, onRoomSelect,
onTimerSelect,
onSelectPreset, onSelectPreset,
isMixerOpen,
onToggleMixer,
isMuted,
onMuteChange,
masterVolume,
onMasterVolumeChange,
trackKeys,
trackLevels,
onTrackLevelChange,
onCaptureThought, onCaptureThought,
onClearInbox, onClearInbox,
onExitRequested,
}: SpaceToolsDockWidgetProps) => { }: SpaceToolsDockWidgetProps) => {
const { pushToast } = useToast(); const { pushToast } = useToast();
const [activePanel, setActivePanel] = useState<SpaceToolPanelId | null>(null); const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
const [noteDraft, setNoteDraft] = useState('');
const [isIdle, setIdle] = useState(false);
const selectedSoundLabel = useMemo(() => {
return (
SOUND_PRESETS.find((preset) => preset.id === selectedPresetId)?.label ?? SOUND_PRESETS[0]?.label ?? '기본'
);
}, [selectedPresetId]);
useEffect(() => {
if (!openPopover) {
return;
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpenPopover(null);
}
};
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [openPopover]);
useEffect(() => {
if (!isFocusMode || openPopover || utilityPanel) {
setIdle(false);
return;
}
let timerId: number | null = null;
const armIdleTimer = () => {
if (timerId) {
window.clearTimeout(timerId);
}
timerId = window.setTimeout(() => {
setIdle(true);
}, 3500);
};
const wake = () => {
setIdle(false);
armIdleTimer();
};
armIdleTimer();
window.addEventListener('pointermove', wake);
window.addEventListener('keydown', wake);
window.addEventListener('pointerdown', wake);
return () => {
if (timerId) {
window.clearTimeout(timerId);
}
window.removeEventListener('pointermove', wake);
window.removeEventListener('keydown', wake);
window.removeEventListener('pointerdown', wake);
};
}, [isFocusMode, openPopover, utilityPanel]);
const openUtilityPanel = (panel: SpaceUtilityPanelId) => {
setOpenPopover(null);
setUtilityPanel(panel);
};
const handleNoteSubmit = () => {
const trimmedNote = noteDraft.trim();
if (!trimmedNote) {
return;
}
onCaptureThought(trimmedNote);
setNoteDraft('');
pushToast({ title: '메모를 잠깐 주차했어요.' });
};
return ( return (
<> <>
<div className="fixed right-3.5 top-1/2 z-30 -translate-y-1/2"> {openPopover ? (
<button
type="button"
aria-label="팝오버 닫기"
onClick={() => setOpenPopover(null)}
className="fixed inset-0 z-30"
/>
) : null}
<div <div
className={cn( className={cn(
'flex w-10 flex-col items-center gap-1.5 rounded-[18px] border py-1.5 backdrop-blur-lg transition-opacity', 'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-[calc(env(safe-area-inset-top,0px)+0.75rem)]',
isFocusMode && activePanel === null isFocusMode ? (isIdle ? 'opacity-40' : 'opacity-84') : 'opacity-92',
? 'border-white/12 bg-slate-950/18 opacity-32 hover:opacity-86'
: 'border-white/14 bg-slate-950/28 opacity-86',
)} )}
> >
{TOOL_ITEMS.map((item) => { <ExitHoldButton
const selected = activePanel === item.id; variant={isFocusMode ? 'ring' : 'bar'}
onConfirm={onExitRequested}
/>
</div>
{isFocusMode ? (
<>
<div
className={cn(
'fixed z-30 transition-opacity left-[calc(env(safe-area-inset-left,0px)+0.75rem)] bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] sm:bottom-[calc(env(safe-area-inset-bottom,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div
aria-hidden
className="pointer-events-none absolute -inset-x-5 -inset-y-4 -z-10 rounded-[999px] bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.18)_0%,rgba(2,6,23,0.11)_48%,rgba(2,6,23,0)_78%)]"
/>
<button
type="button"
onClick={() => setOpenPopover((current) => (current === 'notes' ? null : 'notes'))}
className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100"
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
<span>Notes {formatThoughtCount(thoughtCount)}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'notes' ? (
<div
className="mb-2 w-[min(320px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', left: 0 }}
>
<p className="text-[11px] text-white/56"> </p>
<div className="mt-2 flex gap-1.5">
<input
value={noteDraft}
onChange={(event) => setNoteDraft(event.target.value)}
placeholder="한 줄 메모"
className="h-8 min-w-0 flex-1 rounded-lg border border-white/14 bg-white/[0.04] px-2.5 text-xs text-white placeholder:text-white/38 focus:border-sky-200/42 focus:outline-none"
/>
<button
type="button"
onClick={handleNoteSubmit}
className="h-8 rounded-lg border border-sky-200/34 bg-sky-200/14 px-2.5 text-xs text-white/88"
>
</button>
</div>
<ul className="mt-2 space-y-1.5">
{thoughts.slice(0, 3).map((thought) => (
<li
key={thought.id}
className="truncate rounded-lg border border-white/10 bg-white/[0.03] px-2 py-1.5 text-[11px] text-white/74"
>
{thought.text}
</li>
))}
{thoughts.length === 0 ? (
<li className="rounded-lg border border-white/10 bg-white/[0.03] px-2 py-1.5 text-[11px] text-white/56">
.
</li>
) : null}
</ul>
<div className="mt-2 flex items-center justify-end gap-3">
<button
type="button"
onClick={() => openUtilityPanel('inbox')}
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
>
</button>
<button
type="button"
onClick={() => openUtilityPanel('stats')}
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
>
</button>
<button
type="button"
onClick={() => openUtilityPanel('settings')}
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
>
</button>
</div>
</div>
) : null}
</div>
</div>
<div
className={cn(
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] sm:bottom-[calc(env(safe-area-inset-bottom,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div
aria-hidden
className="pointer-events-none absolute -inset-x-5 -inset-y-4 -z-10 rounded-[999px] bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.18)_0%,rgba(2,6,23,0.11)_48%,rgba(2,6,23,0)_78%)]"
/>
<button
type="button"
onClick={() => setOpenPopover((current) => (current === 'sound' ? null : 'sound'))}
className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100"
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span>
<span className="max-w-[132px] truncate">{selectedSoundLabel}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'sound' ? (
<div
className="mb-2 w-[min(300px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', right: 0 }}
>
<p className="text-[11px] text-white/56"> </p>
<div className="mt-2 flex flex-wrap gap-1.5">
{SOUND_PRESETS.slice(0, 6).map((preset) => {
const selected = preset.id === selectedPresetId;
return ( return (
<button <button
key={item.id} key={preset.id}
type="button" type="button"
title={item.label} onClick={() => {
aria-label={item.label} onSelectPreset(preset.id);
onClick={() => setActivePanel(item.id)} setOpenPopover(null);
}}
className={cn( className={cn(
'relative inline-flex h-8 w-8 items-center justify-center rounded-[10px] border text-white/80 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70', 'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
selected selected
? 'border-sky-200/44 bg-sky-200/14 text-white shadow-[0_0_0_1px_rgba(186,230,253,0.2)]' ? 'border-sky-200/34 bg-sky-200/14 text-white/90'
: 'border-white/12 bg-white/6 hover:bg-white/10', : 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
)} )}
> >
<DockIcon id={item.id} /> {preset.label}
{item.id === 'inbox' && thoughtCount > 0 ? (
<span className="absolute -right-1 -top-1 inline-flex min-w-[0.95rem] items-center justify-center rounded-full bg-sky-200/26 px-1 py-0.5 text-[8px] font-semibold text-sky-50">
{thoughtCount > 99 ? '99+' : `${thoughtCount}`}
</span>
) : null}
</button> </button>
); );
})} })}
</div> </div>
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() => openUtilityPanel('settings')}
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
>
</button>
</div> </div>
</div>
) : null}
</div>
</div>
</>
) : null}
<SpaceSideSheet <SpaceSideSheet
open={activePanel !== null} open={utilityPanel !== null}
title={activePanel ? PANEL_TITLE_MAP[activePanel] : ''} title={utilityPanel ? UTILITY_PANEL_TITLE[utilityPanel] : ''}
onClose={() => setActivePanel(null)} onClose={() => setUtilityPanel(null)}
> >
{activePanel === 'sound' ? ( {utilityPanel === 'inbox' ? (
<SoundToolPanel
selectedPresetId={selectedPresetId}
onSelectPreset={onSelectPreset}
isMixerOpen={isMixerOpen}
onToggleMixer={onToggleMixer}
isMuted={isMuted}
onMuteChange={onMuteChange}
masterVolume={masterVolume}
onMasterVolumeChange={onMasterVolumeChange}
trackKeys={trackKeys}
trackLevels={trackLevels}
onTrackLevelChange={onTrackLevelChange}
/>
) : null}
{activePanel === 'notes' ? (
<NotesToolPanel
onCaptureThought={(note) => {
onCaptureThought(note);
pushToast({ title: '인박스에 주차했어요 (더미)' });
}}
/>
) : null}
{activePanel === 'inbox' ? (
<InboxToolPanel <InboxToolPanel
thoughts={thoughts} thoughts={thoughts}
onClear={() => { onClear={() => {
@@ -205,8 +373,23 @@ export const SpaceToolsDockWidget = ({
/> />
) : null} ) : null}
{activePanel === 'stats' ? <StatsToolPanel /> : null} {utilityPanel === 'stats' ? <StatsToolPanel /> : null}
{activePanel === 'settings' ? <SettingsToolPanel /> : null} {utilityPanel === 'settings' ? (
<SettingsToolPanel
rooms={rooms}
selectedRoomId={selectedRoomId}
selectedTimerLabel={selectedTimerLabel}
timerPresets={timerPresets}
onSelectRoom={(roomId) => {
onRoomSelect(roomId);
pushToast({ title: '공간을 바꿨어요.' });
}}
onSelectTimer={(label) => {
onTimerSelect(label);
pushToast({ title: `타이머를 ${label}로 바꿨어요.` });
}}
/>
) : null}
</SpaceSideSheet> </SpaceSideSheet>
</> </>
); );

View File

@@ -1,10 +1,28 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import type { RoomTheme } from '@/entities/room';
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';
export const SettingsToolPanel = () => { interface SettingsToolPanelProps {
rooms: RoomTheme[];
selectedRoomId: string;
selectedTimerLabel: string;
timerPresets: TimerPreset[];
onSelectRoom: (roomId: string) => void;
onSelectTimer: (timerLabel: string) => void;
}
export const SettingsToolPanel = ({
rooms,
selectedRoomId,
selectedTimerLabel,
timerPresets,
onSelectRoom,
onSelectTimer,
}: SettingsToolPanelProps) => {
const [reduceMotion, setReduceMotion] = useState(false); const [reduceMotion, setReduceMotion] = useState(false);
const [defaultPresetId, setDefaultPresetId] = useState< const [defaultPresetId, setDefaultPresetId] = useState<
(typeof DEFAULT_PRESET_OPTIONS)[number]['id'] (typeof DEFAULT_PRESET_OPTIONS)[number]['id']
@@ -40,6 +58,58 @@ export const SettingsToolPanel = () => {
</div> </div>
</section> </section>
<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="mt-1 text-xs text-white/58"> .</p>
<div className="mt-2 flex flex-wrap gap-2">
{rooms.slice(0, 4).map((room) => {
const selected = room.id === selectedRoomId;
return (
<button
key={room.id}
type="button"
onClick={() => onSelectRoom(room.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition-colors',
selected
? 'border-sky-200/44 bg-sky-200/20 text-sky-100'
: 'border-white/18 bg-white/8 text-white/80 hover:bg-white/14',
)}
>
{room.name}
</button>
);
})}
</div>
</section>
<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="mt-1 text-xs text-white/58"> .</p>
<div className="mt-2 flex flex-wrap gap-2">
{timerPresets.slice(0, 3).map((preset) => {
const selected = preset.label === selectedTimerLabel;
return (
<button
key={preset.id}
type="button"
onClick={() => onSelectTimer(preset.label)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition-colors',
selected
? 'border-sky-200/44 bg-sky-200/20 text-sky-100'
: 'border-white/18 bg-white/8 text-white/80 hover:bg-white/14',
)}
>
{preset.label}
</button>
);
})}
</div>
</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>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { import {
getRoomBackgroundStyle, getRoomBackgroundStyle,
@@ -10,8 +10,10 @@ import {
import { import {
GOAL_CHIPS, GOAL_CHIPS,
SOUND_PRESETS, SOUND_PRESETS,
TIMER_PRESETS,
useThoughtInbox, useThoughtInbox,
type GoalChip, type GoalChip,
type TimerPreset,
} from '@/entities/session'; } from '@/entities/session';
import { useSoundPresetSelection } from '@/features/sound-preset'; import { useSoundPresetSelection } from '@/features/sound-preset';
import { useToast } from '@/shared/ui'; import { useToast } from '@/shared/ui';
@@ -37,6 +39,19 @@ const resolveInitialSoundPreset = (presetIdFromQuery: string | null) => {
return SOUND_PRESETS[0].id; return SOUND_PRESETS[0].id;
}; };
const TIMER_SELECTION_PRESETS = TIMER_PRESETS.filter(
(preset): preset is TimerPreset & { focusMinutes: number; breakMinutes: number } =>
typeof preset.focusMinutes === 'number' && typeof preset.breakMinutes === 'number',
).slice(0, 3);
const resolveInitialTimerLabel = (timerLabelFromQuery: string | null) => {
if (timerLabelFromQuery && TIMER_SELECTION_PRESETS.some((preset) => preset.label === timerLabelFromQuery)) {
return timerLabelFromQuery;
}
return TIMER_SELECTION_PRESETS[0]?.label ?? '25/5';
};
export const SpaceWorkspaceWidget = () => { export const SpaceWorkspaceWidget = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { pushToast } = useToast(); const { pushToast } = useToast();
@@ -45,33 +60,31 @@ export const SpaceWorkspaceWidget = () => {
const initialRoomId = resolveInitialRoomId(searchParams.get('room')); const initialRoomId = resolveInitialRoomId(searchParams.get('room'));
const initialGoal = searchParams.get('goal')?.trim() ?? ''; const initialGoal = searchParams.get('goal')?.trim() ?? '';
const initialSoundPresetId = resolveInitialSoundPreset(searchParams.get('sound')); const initialSoundPresetId = resolveInitialSoundPreset(searchParams.get('sound'));
const initialTimerLabel = resolveInitialTimerLabel(searchParams.get('timer'));
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('setup'); const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('setup');
const [isSetupDrawerOpen, setSetupDrawerOpen] = useState(true);
const [selectedRoomId, setSelectedRoomId] = useState(initialRoomId); const [selectedRoomId, setSelectedRoomId] = useState(initialRoomId);
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);
const { const {
selectedPresetId, selectedPresetId,
setSelectedPresetId, setSelectedPresetId,
isMixerOpen,
setMixerOpen,
isMuted,
setMuted,
masterVolume,
setMasterVolume,
trackLevels,
setTrackLevel,
trackKeys,
} = useSoundPresetSelection(initialSoundPresetId); } = useSoundPresetSelection(initialSoundPresetId);
const selectedRoom = useMemo(() => { const selectedRoom = useMemo(() => {
return getRoomById(selectedRoomId) ?? ROOM_THEMES[0]; return getRoomById(selectedRoomId) ?? ROOM_THEMES[0];
}, [selectedRoomId]); }, [selectedRoomId]);
const setupRooms = useMemo(() => { const setupRooms = useMemo(() => {
const restRooms = ROOM_THEMES.filter((room) => room.id !== selectedRoom.id); const visibleRooms = ROOM_THEMES.slice(0, 6);
return [selectedRoom, ...restRooms].slice(0, 4);
if (visibleRooms.some((room) => room.id === selectedRoom.id)) {
return visibleRooms;
}
return [selectedRoom, ...visibleRooms].slice(0, 6);
}, [selectedRoom]); }, [selectedRoom]);
const canStart = goalInput.trim().length > 0; const canStart = goalInput.trim().length > 0;
@@ -96,19 +109,32 @@ export const SpaceWorkspaceWidget = () => {
} }
setWorkspaceMode('focus'); setWorkspaceMode('focus');
setSetupDrawerOpen(false);
pushToast({ pushToast({
title: `목표: ${goalInput.trim()} 시작해요.`, title: `목표: ${goalInput.trim()} 시작해요.`,
}); });
}; };
const handleOpenSetup = () => { const handleExitRequested = () => {
setSetupDrawerOpen(true); setWorkspaceMode('setup');
pushToast({ title: '준비 모드로 돌아왔어요.' });
}; };
useEffect(() => {
const previousBodyOverflow = document.body.style.overflow;
const previousHtmlOverflow = document.documentElement.style.overflow;
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousBodyOverflow;
document.documentElement.style.overflow = previousHtmlOverflow;
};
}, []);
return ( return (
<div className="relative min-h-screen 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"
@@ -135,72 +161,51 @@ export const SpaceWorkspaceWidget = () => {
}} }}
/> />
<div className="relative z-10 flex min-h-screen flex-col pr-[4.25rem]"> <div className="relative z-10 flex h-full flex-col">
<header className="flex items-center justify-between px-4 pt-3.5 sm:px-6">
{!isFocusMode ? (
<div className="rounded-full border border-white/16 bg-slate-950/32 px-3 py-1.5 backdrop-blur-xl">
<p className="text-xs font-semibold tracking-tight text-white/88">VibeRoom</p>
</div>
) : (
<div aria-hidden className="h-7" />
)}
{isFocusMode && !isSetupDrawerOpen ? (
<button
type="button"
onClick={handleOpenSetup}
className="rounded-full border border-white/16 bg-slate-950/24 px-2.5 py-1 text-[11px] text-white/58 transition-colors hover:bg-slate-950/40 hover:text-white/84"
>
Setup
</button>
) : null}
</header>
<main className="relative flex-1" /> <main className="relative flex-1" />
</div> </div>
<SpaceSetupDrawerWidget <SpaceSetupDrawerWidget
open={isSetupDrawerOpen} open={!isFocusMode}
dismissible={isFocusMode}
rooms={setupRooms} rooms={setupRooms}
selectedRoomId={selectedRoom.id} selectedRoomId={selectedRoom.id}
selectedTimerLabel={selectedTimerLabel}
selectedSoundPresetId={selectedPresetId}
goalInput={goalInput} goalInput={goalInput}
selectedGoalId={selectedGoalId} selectedGoalId={selectedGoalId}
selectedSoundPresetId={selectedPresetId}
goalChips={GOAL_CHIPS} goalChips={GOAL_CHIPS}
soundPresets={SOUND_PRESETS} soundPresets={SOUND_PRESETS}
timerPresets={TIMER_SELECTION_PRESETS}
canStart={canStart} canStart={canStart}
onClose={() => {
if (isFocusMode) {
setSetupDrawerOpen(false);
}
}}
onRoomSelect={setSelectedRoomId} onRoomSelect={setSelectedRoomId}
onTimerSelect={setSelectedTimerLabel}
onSoundSelect={setSelectedPresetId}
onGoalChange={handleGoalChange} onGoalChange={handleGoalChange}
onGoalChipSelect={handleGoalChipSelect} onGoalChipSelect={handleGoalChipSelect}
onSoundSelect={setSelectedPresetId}
onStart={handleStart} onStart={handleStart}
/> />
<SpaceFocusHudWidget goal={goalInput.trim()} visible={isFocusMode} /> <SpaceFocusHudWidget
goal={goalInput.trim()}
timerLabel={selectedTimerLabel}
visible={isFocusMode}
/>
<SpaceToolsDockWidget <SpaceToolsDockWidget
isFocusMode={isFocusMode} isFocusMode={isFocusMode}
rooms={setupRooms}
selectedRoomId={selectedRoom.id}
selectedTimerLabel={selectedTimerLabel}
timerPresets={TIMER_SELECTION_PRESETS}
thoughts={thoughts} thoughts={thoughts}
thoughtCount={thoughtCount} thoughtCount={thoughtCount}
selectedPresetId={selectedPresetId} selectedPresetId={selectedPresetId}
onRoomSelect={setSelectedRoomId}
onTimerSelect={setSelectedTimerLabel}
onSelectPreset={setSelectedPresetId} 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)} onCaptureThought={(note) => addThought(note, selectedRoom.name)}
onClearInbox={clearThoughts} onClearInbox={clearThoughts}
onExitRequested={handleExitRequested}
/> />
</div> </div>
); );