fix(space): Quick Controls 사운드 복원과 HUD 피드백 정합성 수정

This commit is contained in:
2026-03-05 16:24:53 +09:00
parent b1bafd5e9a
commit f3f0518588
5 changed files with 109 additions and 43 deletions

View File

@@ -1,10 +1,15 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import { defineConfig, globalIgnores } from "eslint/config"; import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals"; import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript"; import nextTs from "eslint-config-next/typescript";
let storybookConfig = [];
try {
// Optional dependency: lint should still run when Storybook plugin is not installed.
const storybook = await import("eslint-plugin-storybook");
storybookConfig = storybook.default?.configs?.["flat/recommended"] ?? [];
} catch {}
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
...nextTs, ...nextTs,
@@ -16,7 +21,7 @@ const eslintConfig = defineConfig([
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",
]), ]),
...storybook.configs["flat/recommended"] ...storybookConfig
]); ]);
export default eslintConfig; export default eslintConfig;

View File

@@ -1,32 +1,17 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useToast } from '@/shared/ui';
import {
RECOVERY_30S_BUTTON_LABEL,
RECOVERY_30S_TOAST_MESSAGE,
} from './copy';
const MODE_DURATION_MS = 2000; const MODE_DURATION_MS = 2000;
const HINT_DURATION_MS = 1800;
export const useRestart30s = () => { export const useRestart30s = () => {
const { pushToast } = useToast();
const [isBreatheMode, setBreatheMode] = useState(false); const [isBreatheMode, setBreatheMode] = useState(false);
const [hintMessage, setHintMessage] = useState<string | null>(null);
const resetTimerRef = useRef<number | null>(null); const resetTimerRef = useRef<number | null>(null);
const hintTimerRef = useRef<number | null>(null);
const clearTimers = () => { const clearTimers = () => {
if (resetTimerRef.current !== null) { if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current); window.clearTimeout(resetTimerRef.current);
resetTimerRef.current = null; resetTimerRef.current = null;
} }
if (hintTimerRef.current !== null) {
window.clearTimeout(hintTimerRef.current);
hintTimerRef.current = null;
}
}; };
useEffect(() => { useEffect(() => {
@@ -38,16 +23,6 @@ export const useRestart30s = () => {
const triggerRestart = () => { const triggerRestart = () => {
clearTimers(); clearTimers();
setBreatheMode(true); setBreatheMode(true);
setHintMessage(RECOVERY_30S_TOAST_MESSAGE);
pushToast({
title: RECOVERY_30S_BUTTON_LABEL,
description: RECOVERY_30S_TOAST_MESSAGE,
});
hintTimerRef.current = window.setTimeout(() => {
setHintMessage(null);
}, HINT_DURATION_MS);
resetTimerRef.current = window.setTimeout(() => { resetTimerRef.current = window.setTimeout(() => {
setBreatheMode(false); setBreatheMode(false);
@@ -56,7 +31,6 @@ export const useRestart30s = () => {
return { return {
isBreatheMode, isBreatheMode,
hintMessage,
triggerRestart, triggerRestart,
}; };
}; };

View File

@@ -4,10 +4,11 @@ import { useMemo } from 'react';
import type { PlanTier } from '@/entities/plan'; import type { PlanTier } from '@/entities/plan';
import { import {
PRO_LOCKED_ROOM_IDS, PRO_LOCKED_ROOM_IDS,
PRO_LOCKED_SOUND_IDS,
PRO_LOCKED_TIMER_LABELS, PRO_LOCKED_TIMER_LABELS,
} from '@/entities/plan'; } from '@/entities/plan';
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room'; import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room';
import type { TimerPreset } from '@/entities/session'; import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
import { useReducedMotion } from '@/shared/lib/useReducedMotion'; import { useReducedMotion } from '@/shared/lib/useReducedMotion';
import { Toggle } from '@/shared/ui'; import { Toggle } from '@/shared/ui';
@@ -17,6 +18,7 @@ interface ControlCenterSheetWidgetProps {
rooms: RoomTheme[]; rooms: RoomTheme[];
selectedRoomId: string; selectedRoomId: string;
selectedTimerLabel: string; selectedTimerLabel: string;
selectedSoundPresetId: string;
sceneRecommendedSoundLabel: string; sceneRecommendedSoundLabel: string;
sceneRecommendedTimerLabel: string; sceneRecommendedTimerLabel: string;
timerPresets: TimerPreset[]; timerPresets: TimerPreset[];
@@ -24,6 +26,7 @@ interface ControlCenterSheetWidgetProps {
onAutoHideControlsChange: (next: boolean) => void; onAutoHideControlsChange: (next: boolean) => void;
onSelectRoom: (roomId: string) => void; onSelectRoom: (roomId: string) => void;
onSelectTimer: (timerLabel: string) => void; onSelectTimer: (timerLabel: string) => void;
onSelectSound: (presetId: string) => void;
onLockedClick: (source: string) => void; onLockedClick: (source: string) => void;
onResetToRecommended: () => void; onResetToRecommended: () => void;
} }
@@ -50,6 +53,7 @@ export const ControlCenterSheetWidget = ({
rooms, rooms,
selectedRoomId, selectedRoomId,
selectedTimerLabel, selectedTimerLabel,
selectedSoundPresetId,
sceneRecommendedSoundLabel, sceneRecommendedSoundLabel,
sceneRecommendedTimerLabel, sceneRecommendedTimerLabel,
timerPresets, timerPresets,
@@ -57,6 +61,7 @@ export const ControlCenterSheetWidget = ({
onAutoHideControlsChange, onAutoHideControlsChange,
onSelectRoom, onSelectRoom,
onSelectTimer, onSelectTimer,
onSelectSound,
onLockedClick, onLockedClick,
onResetToRecommended, onResetToRecommended,
}: ControlCenterSheetWidgetProps) => { }: ControlCenterSheetWidgetProps) => {
@@ -155,6 +160,44 @@ export const ControlCenterSheetWidget = ({
</div> </div>
</section> </section>
<section className="space-y-2.5 rounded-2xl border border-white/12 bg-black/22 p-3.5 backdrop-blur-md">
<SectionTitle
title="Sound"
description={SOUND_PRESETS.find((preset) => preset.id === selectedSoundPresetId)?.label ?? '기본'}
/>
<div className="grid grid-cols-3 gap-2">
{SOUND_PRESETS.slice(0, 6).map((preset) => {
const selected = preset.id === selectedSoundPresetId;
const locked = !isPro && PRO_LOCKED_SOUND_IDS.includes(preset.id);
return (
<button
key={preset.id}
type="button"
onClick={() => {
if (locked) {
onLockedClick(`사운드: ${preset.label}`);
return;
}
onSelectSound(preset.id);
}}
className={cn(
'relative rounded-xl border px-3 py-2 text-[11px]',
colorMotionClass,
selected
? 'border-sky-200/42 bg-sky-200/16 text-white'
: 'border-white/18 bg-white/[0.04] text-white/74 hover:bg-white/[0.1]',
)}
>
{preset.label}
{locked ? <span className="ml-1 text-[9px] text-white/66">LOCK PRO</span> : null}
</button>
);
})}
</div>
</section>
<div className="space-y-1.5 rounded-xl border border-white/12 bg-white/[0.03] px-3 py-2.5"> <div className="space-y-1.5 rounded-xl border border-white/12 bg-white/[0.03] px-3 py-2.5">
<p className="text-[11px] text-white/58">: {sceneRecommendedSoundLabel} · {sceneRecommendedTimerLabel}</p> <p className="text-[11px] text-white/58">: {sceneRecommendedSoundLabel} · {sceneRecommendedTimerLabel}</p>
<button <button

View File

@@ -7,7 +7,6 @@ import { ExitHoldButton } from '@/features/exit-hold';
import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet'; import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet';
import { PlanPill } from '@/features/plan-pill'; import { PlanPill } from '@/features/plan-pill';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine'; import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import { useToast } from '@/shared/ui';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
import { ControlCenterSheetWidget } from '@/widgets/control-center-sheet'; import { ControlCenterSheetWidget } from '@/widgets/control-center-sheet';
import { SpaceSideSheet } from '@/widgets/space-sheet-shell'; import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
@@ -40,6 +39,7 @@ interface SpaceToolsDockWidgetProps {
onDeleteThought: (thoughtId: string) => RecentThought | null; onDeleteThought: (thoughtId: string) => RecentThought | null;
onSetThoughtCompleted: (thoughtId: string, isCompleted: boolean) => RecentThought | null; onSetThoughtCompleted: (thoughtId: string, isCompleted: boolean) => RecentThought | null;
onRestoreThought: (thought: RecentThought) => void; onRestoreThought: (thought: RecentThought) => void;
onRestoreThoughts: (thoughts: RecentThought[]) => void;
onClearInbox: () => RecentThought[]; onClearInbox: () => RecentThought[];
onResetToSceneRecommended: () => void; onResetToSceneRecommended: () => void;
onStatusMessage: (payload: HudStatusLinePayload) => void; onStatusMessage: (payload: HudStatusLinePayload) => void;
@@ -68,12 +68,12 @@ export const SpaceToolsDockWidget = ({
onDeleteThought, onDeleteThought,
onSetThoughtCompleted, onSetThoughtCompleted,
onRestoreThought, onRestoreThought,
onRestoreThoughts,
onClearInbox, onClearInbox,
onResetToSceneRecommended, onResetToSceneRecommended,
onStatusMessage, onStatusMessage,
onExitRequested, onExitRequested,
}: SpaceToolsDockWidgetProps) => { }: SpaceToolsDockWidgetProps) => {
const { pushToast } = useToast();
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null); const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null); const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
const [autoHideControls, setAutoHideControls] = useState(true); const [autoHideControls, setAutoHideControls] = useState(true);
@@ -121,8 +121,23 @@ export const SpaceToolsDockWidget = ({
}, [openPopover]); }, [openPopover]);
useEffect(() => { useEffect(() => {
if (!isFocusMode || openPopover || utilityPanel) { if (isFocusMode) {
return;
}
const rafId = window.requestAnimationFrame(() => {
setOpenPopover(null);
setUtilityPanel(null);
setIdle(false); setIdle(false);
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [isFocusMode]);
useEffect(() => {
if (!isFocusMode || openPopover || utilityPanel) {
return; return;
} }
@@ -197,6 +212,7 @@ export const SpaceToolsDockWidget = ({
}, [autoHideControls, utilityPanel]); }, [autoHideControls, utilityPanel]);
const openUtilityPanel = (panel: SpaceUtilityPanelId) => { const openUtilityPanel = (panel: SpaceUtilityPanelId) => {
setIdle(false);
setOpenPopover(null); setOpenPopover(null);
setUtilityPanel(panel); setUtilityPanel(panel);
}; };
@@ -260,7 +276,25 @@ export const SpaceToolsDockWidget = ({
}; };
const handleInboxClear = () => { const handleInboxClear = () => {
onClearInbox(); const snapshot = onClearInbox();
if (snapshot.length === 0) {
onStatusMessage({ message: '비울 항목이 없어요.' });
return;
}
onStatusMessage({
message: '모두 비워짐',
durationMs: 4200,
priority: 'undo',
action: {
label: '실행취소',
onClick: () => {
onRestoreThoughts(snapshot);
onStatusMessage({ message: '복원했어요.' });
},
},
});
}; };
const handlePlanPillClick = () => { const handlePlanPillClick = () => {
@@ -273,13 +307,13 @@ export const SpaceToolsDockWidget = ({
}; };
const handleLockedClick = (source: string) => { const handleLockedClick = (source: string) => {
pushToast({ title: `${source}은(는) PRO 기능이에요.` }); onStatusMessage({ message: `${source}은(는) PRO 기능이에요.` });
openUtilityPanel('paywall'); openUtilityPanel('paywall');
}; };
const handleStartPro = () => { const handleStartPro = () => {
setPlan('pro'); setPlan('pro');
pushToast({ title: '결제(더미)' }); onStatusMessage({ message: '결제(더미)' });
openUtilityPanel('control-center'); openUtilityPanel('control-center');
}; };
@@ -321,7 +355,7 @@ export const SpaceToolsDockWidget = ({
return ( return (
<> <>
{openPopover ? ( {isFocusMode && openPopover ? (
<button <button
type="button" type="button"
aria-label="팝오버 닫기" aria-label="팝오버 닫기"
@@ -364,7 +398,10 @@ export const SpaceToolsDockWidget = ({
/> />
<button <button
type="button" type="button"
onClick={() => setOpenPopover((current) => (current === 'notes' ? null : 'notes'))} onClick={() => {
setIdle(false);
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" 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 aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
@@ -396,7 +433,10 @@ export const SpaceToolsDockWidget = ({
/> />
<button <button
type="button" type="button"
onClick={() => setOpenPopover((current) => (current === 'sound' ? null : 'sound'))} onClick={() => {
setIdle(false);
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" 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 aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span>
@@ -431,7 +471,7 @@ export const SpaceToolsDockWidget = ({
) : null} ) : null}
<SpaceSideSheet <SpaceSideSheet
open={utilityPanel !== null} open={isFocusMode && utilityPanel !== null}
title={utilityPanel ? UTILITY_PANEL_TITLE[utilityPanel] : ''} title={utilityPanel ? UTILITY_PANEL_TITLE[utilityPanel] : ''}
subtitle={utilityPanel === 'control-center' ? '배경 · 타이머 · 사운드를 그 자리에서 바꿔요.' : undefined} subtitle={utilityPanel === 'control-center' ? '배경 · 타이머 · 사운드를 그 자리에서 바꿔요.' : undefined}
headerAction={ headerAction={
@@ -449,6 +489,7 @@ export const SpaceToolsDockWidget = ({
rooms={rooms} rooms={rooms}
selectedRoomId={selectedRoomId} selectedRoomId={selectedRoomId}
selectedTimerLabel={selectedTimerLabel} selectedTimerLabel={selectedTimerLabel}
selectedSoundPresetId={selectedPresetId}
sceneRecommendedSoundLabel={sceneRecommendedSoundLabel} sceneRecommendedSoundLabel={sceneRecommendedSoundLabel}
sceneRecommendedTimerLabel={sceneRecommendedTimerLabel} sceneRecommendedTimerLabel={sceneRecommendedTimerLabel}
timerPresets={timerPresets} timerPresets={timerPresets}
@@ -460,6 +501,7 @@ export const SpaceToolsDockWidget = ({
onSelectTimer={(label) => { onSelectTimer={(label) => {
onTimerSelect(label); onTimerSelect(label);
}} }}
onSelectSound={onQuickSoundSelect}
onLockedClick={handleLockedClick} onLockedClick={handleLockedClick}
onResetToRecommended={onResetToSceneRecommended} onResetToRecommended={onResetToSceneRecommended}
/> />
@@ -484,8 +526,8 @@ export const SpaceToolsDockWidget = ({
{utilityPanel === 'manage-plan' ? ( {utilityPanel === 'manage-plan' ? (
<ManagePlanSheetContent <ManagePlanSheetContent
onClose={() => setUtilityPanel(null)} onClose={() => setUtilityPanel(null)}
onManage={() => pushToast({ title: '구독 관리(더미)' })} onManage={() => onStatusMessage({ message: '구독 관리(더미)' })}
onRestore={() => pushToast({ title: '구매 복원(더미)' })} onRestore={() => onStatusMessage({ message: '구매 복원(더미)' })}
/> />
) : null} ) : null}
</SpaceSideSheet> </SpaceSideSheet>

View File

@@ -149,6 +149,7 @@ export const SpaceWorkspaceWidget = () => {
removeThought, removeThought,
clearThoughts, clearThoughts,
restoreThought, restoreThought,
restoreThoughts,
setThoughtCompleted, setThoughtCompleted,
} = useThoughtInbox(); } = useThoughtInbox();
@@ -409,6 +410,7 @@ export const SpaceWorkspaceWidget = () => {
onDeleteThought={removeThought} onDeleteThought={removeThought}
onSetThoughtCompleted={setThoughtCompleted} onSetThoughtCompleted={setThoughtCompleted}
onRestoreThought={restoreThought} onRestoreThought={restoreThought}
onRestoreThoughts={restoreThoughts}
onClearInbox={clearThoughts} onClearInbox={clearThoughts}
onStatusMessage={pushStatusLine} onStatusMessage={pushStatusLine}
onExitRequested={handleExitRequested} onExitRequested={handleExitRequested}