fix(space-ui): /space 포커스 앵커 잘림과 스크롤 문제 수정
This commit is contained in:
@@ -1 +1,2 @@
|
||||
export type SpaceToolPanelId = 'sound' | 'notes' | 'inbox' | 'stats' | 'settings';
|
||||
export type SpaceAnchorPopoverId = 'sound' | 'notes';
|
||||
export type SpaceUtilityPanelId = 'settings' | 'inbox' | 'stats';
|
||||
|
||||
@@ -1,201 +1,369 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { RecentThought } from '@/entities/session';
|
||||
import type { SoundTrackKey } from '@/features/sound-preset';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { RoomTheme } from '@/entities/room';
|
||||
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
|
||||
import { ExitHoldButton } from '@/features/exit-hold';
|
||||
import { useToast } from '@/shared/ui';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
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 { NotesToolPanel } from './panels/NotesToolPanel';
|
||||
import { SettingsToolPanel } from './panels/SettingsToolPanel';
|
||||
import { SoundToolPanel } from './panels/SoundToolPanel';
|
||||
import { StatsToolPanel } from './panels/StatsToolPanel';
|
||||
|
||||
interface SpaceToolsDockWidgetProps {
|
||||
isFocusMode: boolean;
|
||||
rooms: RoomTheme[];
|
||||
selectedRoomId: string;
|
||||
selectedTimerLabel: string;
|
||||
timerPresets: TimerPreset[];
|
||||
selectedPresetId: string;
|
||||
thoughts: RecentThought[];
|
||||
thoughtCount: number;
|
||||
selectedPresetId: string;
|
||||
onRoomSelect: (roomId: string) => void;
|
||||
onTimerSelect: (timerLabel: 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;
|
||||
onClearInbox: () => void;
|
||||
onExitRequested: () => void;
|
||||
}
|
||||
|
||||
const TOOL_ITEMS: Array<{
|
||||
id: SpaceToolPanelId;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'sound', label: 'Sound' },
|
||||
{ id: 'notes', label: 'Notes' },
|
||||
{ id: 'inbox', label: 'Inbox' },
|
||||
{ id: 'stats', label: 'Stats' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
];
|
||||
const ANCHOR_ICON = {
|
||||
sound: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="h-4 w-4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
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> = {
|
||||
sound: '사운드',
|
||||
notes: '생각 던지기',
|
||||
const UTILITY_PANEL_TITLE: Record<SpaceUtilityPanelId, string> = {
|
||||
inbox: '인박스',
|
||||
stats: '집중 요약',
|
||||
settings: '설정',
|
||||
};
|
||||
|
||||
const DockIcon = ({ id }: { id: SpaceToolPanelId }) => {
|
||||
const commonProps = {
|
||||
viewBox: '0 0 24 24',
|
||||
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;
|
||||
const formatThoughtCount = (count: number) => {
|
||||
if (count < 1) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
if (count > 9) {
|
||||
return '9+';
|
||||
}
|
||||
|
||||
return String(count);
|
||||
};
|
||||
|
||||
export const SpaceToolsDockWidget = ({
|
||||
isFocusMode,
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
selectedTimerLabel,
|
||||
timerPresets,
|
||||
selectedPresetId,
|
||||
thoughts,
|
||||
thoughtCount,
|
||||
selectedPresetId,
|
||||
onRoomSelect,
|
||||
onTimerSelect,
|
||||
onSelectPreset,
|
||||
isMixerOpen,
|
||||
onToggleMixer,
|
||||
isMuted,
|
||||
onMuteChange,
|
||||
masterVolume,
|
||||
onMasterVolumeChange,
|
||||
trackKeys,
|
||||
trackLevels,
|
||||
onTrackLevelChange,
|
||||
onCaptureThought,
|
||||
onClearInbox,
|
||||
onExitRequested,
|
||||
}: SpaceToolsDockWidgetProps) => {
|
||||
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 (
|
||||
<>
|
||||
<div className="fixed right-3.5 top-1/2 z-30 -translate-y-1/2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-10 flex-col items-center gap-1.5 rounded-[18px] border py-1.5 backdrop-blur-lg transition-opacity',
|
||||
isFocusMode && activePanel === null
|
||||
? '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) => {
|
||||
const selected = activePanel === item.id;
|
||||
{openPopover ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="팝오버 닫기"
|
||||
onClick={() => setOpenPopover(null)}
|
||||
className="fixed inset-0 z-30"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={item.label}
|
||||
aria-label={item.label}
|
||||
onClick={() => setActivePanel(item.id)}
|
||||
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',
|
||||
selected
|
||||
? 'border-sky-200/44 bg-sky-200/14 text-white shadow-[0_0_0_1px_rgba(186,230,253,0.2)]'
|
||||
: 'border-white/12 bg-white/6 hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<DockIcon id={item.id} />
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'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 ? (isIdle ? 'opacity-40' : 'opacity-84') : 'opacity-92',
|
||||
)}
|
||||
>
|
||||
<ExitHoldButton
|
||||
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 (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectPreset(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 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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<SpaceSideSheet
|
||||
open={activePanel !== null}
|
||||
title={activePanel ? PANEL_TITLE_MAP[activePanel] : ''}
|
||||
onClose={() => setActivePanel(null)}
|
||||
open={utilityPanel !== null}
|
||||
title={utilityPanel ? UTILITY_PANEL_TITLE[utilityPanel] : ''}
|
||||
onClose={() => setUtilityPanel(null)}
|
||||
>
|
||||
{activePanel === 'sound' ? (
|
||||
<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' ? (
|
||||
{utilityPanel === 'inbox' ? (
|
||||
<InboxToolPanel
|
||||
thoughts={thoughts}
|
||||
onClear={() => {
|
||||
@@ -205,8 +373,23 @@ export const SpaceToolsDockWidget = ({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activePanel === 'stats' ? <StatsToolPanel /> : null}
|
||||
{activePanel === 'settings' ? <SettingsToolPanel /> : null}
|
||||
{utilityPanel === 'stats' ? <StatsToolPanel /> : 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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
'use client';
|
||||
|
||||
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 { 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 [defaultPresetId, setDefaultPresetId] = useState<
|
||||
(typeof DEFAULT_PRESET_OPTIONS)[number]['id']
|
||||
@@ -40,6 +58,58 @@ export const SettingsToolPanel = () => {
|
||||
</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">
|
||||
{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">
|
||||
<p className="text-sm font-medium text-white">기본 프리셋</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
|
||||
Reference in New Issue
Block a user