diff --git a/src/shared/lib/useHudStatusLine.ts b/src/shared/lib/useHudStatusLine.ts new file mode 100644 index 0000000..5bcca67 --- /dev/null +++ b/src/shared/lib/useHudStatusLine.ts @@ -0,0 +1,103 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; + +export type HudStatusLinePriority = 'normal' | 'undo'; + +export interface HudStatusLineAction { + label: string; + onClick: () => void; +} + +export interface HudStatusLinePayload { + message: string; + durationMs?: number; + action?: HudStatusLineAction; + priority?: HudStatusLinePriority; +} + +export interface HudStatusLineItem extends HudStatusLinePayload { + id: number; + priority: HudStatusLinePriority; +} + +const MAX_PENDING_MESSAGES = 2; +const MAX_TOTAL_MESSAGES = MAX_PENDING_MESSAGES + 1; +const DEFAULT_DURATION_MS = 2000; +const DEFAULT_UNDO_DURATION_MS = 4200; + +export const useHudStatusLine = (enabled = true) => { + const [queue, setQueue] = useState([]); + + useEffect(() => { + if (enabled) { + return; + } + + setQueue([]); + }, [enabled]); + + useEffect(() => { + if (!enabled || queue.length === 0) { + return; + } + + const active = queue[0]; + const durationMs = + active.durationMs ?? (active.action ? DEFAULT_UNDO_DURATION_MS : DEFAULT_DURATION_MS); + const timerId = window.setTimeout(() => { + setQueue((current) => current.slice(1)); + }, durationMs); + + return () => { + window.clearTimeout(timerId); + }; + }, [enabled, queue]); + + const pushStatusLine = useCallback((payload: HudStatusLinePayload) => { + const nextItem: HudStatusLineItem = { + id: Date.now() + Math.floor(Math.random() * 10000), + ...payload, + priority: payload.priority ?? (payload.action ? 'undo' : 'normal'), + }; + + setQueue((current) => { + if (current.length === 0) { + return [nextItem]; + } + + const [active, ...pending] = current; + const nextQueue = + nextItem.priority === 'undo' + ? [active, nextItem, ...pending] + : [active, ...pending, nextItem]; + + return nextQueue.slice(0, MAX_TOTAL_MESSAGES); + }); + }, []); + + const dismissActiveStatus = useCallback(() => { + setQueue((current) => current.slice(1)); + }, []); + + const runActiveAction = useCallback(() => { + const active = queue[0]; + + if (!active?.action) { + dismissActiveStatus(); + return; + } + + active.action.onClick(); + dismissActiveStatus(); + }, [dismissActiveStatus, queue]); + + return useMemo(() => { + return { + activeStatus: queue[0] ?? null, + pushStatusLine, + runActiveAction, + dismissActiveStatus, + }; + }, [dismissActiveStatus, pushStatusLine, queue, runActiveAction]); +}; diff --git a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx index 1cf7c27..804699d 100644 --- a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx +++ b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import type { HudStatusLineItem, HudStatusLinePayload } from '@/shared/lib/useHudStatusLine'; import { useReducedMotion } from '@/shared/lib/useReducedMotion'; -import { useToast } from '@/shared/ui'; import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud'; import { GoalCompleteSheet } from './GoalCompleteSheet'; import { GoalFlashOverlay } from './GoalFlashOverlay'; @@ -10,6 +10,9 @@ interface SpaceFocusHudWidgetProps { timerLabel: string; visible: boolean; onGoalUpdate: (nextGoal: string) => void; + statusLine: HudStatusLineItem | null; + onStatusAction: () => void; + onStatusMessage: (payload: HudStatusLinePayload) => void; } export const SpaceFocusHudWidget = ({ @@ -17,8 +20,10 @@ export const SpaceFocusHudWidget = ({ timerLabel, visible, onGoalUpdate, + statusLine, + onStatusAction, + onStatusMessage, }: SpaceFocusHudWidgetProps) => { - const { pushToast } = useToast(); const reducedMotion = useReducedMotion(); const [flashVisible, setFlashVisible] = useState(false); const [sheetOpen, setSheetOpen] = useState(false); @@ -95,9 +100,18 @@ export const SpaceFocusHudWidget = ({ { if (reducedMotion) { playbackStateRef.current = state; @@ -117,21 +131,21 @@ export const SpaceFocusHudWidget = ({ onClose={() => setSheetOpen(false)} onRest={() => { setSheetOpen(false); - pushToast({ title: '좋아요. 5분 뒤에 다시 알려드릴게요.' }); + onStatusMessage({ message: '좋아요. 5분 뒤에 다시 알려드릴게요.' }); if (restReminderTimerRef.current) { window.clearTimeout(restReminderTimerRef.current); } restReminderTimerRef.current = window.setTimeout(() => { - pushToast({ title: '5분이 지났어요. 다음 한 조각으로 돌아와요.' }); + onStatusMessage({ message: '5분이 지났어요. 다음 한 조각으로 돌아와요.' }); restReminderTimerRef.current = null; }, 5 * 60 * 1000); }} onConfirm={(nextGoal) => { onGoalUpdate(nextGoal); setSheetOpen(false); - pushToast({ title: '다음 한 조각으로 이어갑니다.' }); + onStatusMessage({ message: '다음 한 조각으로 이어갑니다.' }); triggerFlash(1200); }} /> diff --git a/src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx b/src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx index 4da6cdb..547e6f3 100644 --- a/src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx +++ b/src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx @@ -12,8 +12,13 @@ interface SpaceTimerHudWidgetProps { goal: string; className?: string; isImmersionMode?: boolean; + statusLine?: { + message: string; + actionLabel?: string; + } | null; onPlaybackStateChange?: (state: 'running' | 'paused') => void; onGoalCompleteRequest?: () => void; + onStatusAction?: () => void; } const HUD_ACTIONS = [ @@ -27,8 +32,10 @@ export const SpaceTimerHudWidget = ({ goal, className, isImmersionMode = false, + statusLine = null, onPlaybackStateChange, onGoalCompleteRequest, + onStatusAction, }: SpaceTimerHudWidgetProps) => { const { isBreatheMode, hintMessage, triggerRestart } = useRestart30s(); const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.'; @@ -89,7 +96,22 @@ export const SpaceTimerHudWidget = ({ 완료 - {hintMessage ? ( + {statusLine ? ( +
+

+ {statusLine.message} +

+ {statusLine.actionLabel ? ( + + ) : null} +
+ ) : hintMessage ? (

{hintMessage}

diff --git a/src/widgets/space-tools-dock/model/applyQuickPack.ts b/src/widgets/space-tools-dock/model/applyQuickPack.ts index a7a2805..d1412ef 100644 --- a/src/widgets/space-tools-dock/model/applyQuickPack.ts +++ b/src/widgets/space-tools-dock/model/applyQuickPack.ts @@ -2,30 +2,30 @@ interface ApplyQuickPackParams { packId: 'balanced' | 'deep-work' | 'gentle'; onTimerSelect: (timerLabel: string) => void; onSelectPreset: (presetId: string) => void; - pushToast: (payload: { title: string; description?: string }) => void; + onApplied?: (message: string) => void; } export const applyQuickPack = ({ packId, onTimerSelect, onSelectPreset, - pushToast, + onApplied, }: ApplyQuickPackParams) => { if (packId === 'balanced') { onTimerSelect('25/5'); onSelectPreset('rain-focus'); - pushToast({ title: 'Balanced 팩을 적용했어요.' }); + onApplied?.('Balanced 팩을 적용했어요.'); return; } if (packId === 'deep-work') { onTimerSelect('50/10'); onSelectPreset('deep-white'); - pushToast({ title: 'Deep Work 팩을 적용했어요.' }); + onApplied?.('Deep Work 팩을 적용했어요.'); return; } onTimerSelect('25/5'); onSelectPreset('silent'); - pushToast({ title: 'Gentle 팩을 적용했어요.' }); + onApplied?.('Gentle 팩을 적용했어요.'); }; diff --git a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx index c6112b3..cb9d517 100644 --- a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx +++ b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx @@ -6,6 +6,7 @@ import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/ import { ExitHoldButton } from '@/features/exit-hold'; import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet'; import { PlanPill } from '@/features/plan-pill'; +import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine'; import { useToast } from '@/shared/ui'; import { cn } from '@/shared/lib/cn'; import { ControlCenterSheetWidget } from '@/widgets/control-center-sheet'; @@ -40,6 +41,7 @@ interface SpaceToolsDockWidgetProps { onRestoreThought: (thought: RecentThought) => void; onRestoreThoughts: (thoughts: RecentThought[]) => void; onClearInbox: () => RecentThought[]; + onStatusMessage: (payload: HudStatusLinePayload) => void; onExitRequested: () => void; } @@ -65,6 +67,7 @@ export const SpaceToolsDockWidget = ({ onRestoreThought, onRestoreThoughts, onClearInbox, + onStatusMessage, onExitRequested, }: SpaceToolsDockWidgetProps) => { const { pushToast } = useToast(); @@ -171,9 +174,10 @@ export const SpaceToolsDockWidget = ({ } setNoteDraft(''); - pushToast({ - title: '인박스에 저장됨', + onStatusMessage({ + message: '인박스에 저장됨', durationMs: 4200, + priority: 'undo', action: { label: '실행취소', onClick: () => { @@ -183,7 +187,7 @@ export const SpaceToolsDockWidget = ({ return; } - pushToast({ title: '저장 취소됨' }); + onStatusMessage({ message: '저장 취소됨' }); }, }, }); @@ -198,14 +202,15 @@ export const SpaceToolsDockWidget = ({ const willBeCompleted = !thought.isCompleted; - pushToast({ - title: willBeCompleted ? '완료 처리됨' : '완료 해제됨', + onStatusMessage({ + message: willBeCompleted ? '완료 처리됨' : '완료 해제됨', durationMs: 4200, + priority: 'undo', action: { label: '실행취소', onClick: () => { onRestoreThought(previousThought); - pushToast({ title: willBeCompleted ? '완료 처리를 취소했어요.' : '완료 해제를 취소했어요.' }); + onStatusMessage({ message: willBeCompleted ? '완료 처리를 취소했어요.' : '완료 해제를 취소했어요.' }); }, }, }); @@ -218,14 +223,15 @@ export const SpaceToolsDockWidget = ({ return; } - pushToast({ - title: '삭제됨', + onStatusMessage({ + message: '삭제됨', durationMs: 4200, + priority: 'undo', action: { label: '실행취소', onClick: () => { onRestoreThought(removedThought); - pushToast({ title: '삭제를 취소했어요.' }); + onStatusMessage({ message: '삭제를 취소했어요.' }); }, }, }); @@ -235,18 +241,19 @@ export const SpaceToolsDockWidget = ({ const snapshot = onClearInbox(); if (snapshot.length === 0) { - pushToast({ title: '인박스가 비어 있어요.' }); + onStatusMessage({ message: '인박스가 비어 있어요.' }); return; } - pushToast({ - title: '모두 비워짐', + onStatusMessage({ + message: '모두 비워짐', durationMs: 4200, + priority: 'undo', action: { label: '실행취소', onClick: () => { onRestoreThoughts(snapshot); - pushToast({ title: '인박스를 복구했어요.' }); + onStatusMessage({ message: '인박스를 복구했어요.' }); }, }, }); @@ -273,7 +280,14 @@ export const SpaceToolsDockWidget = ({ }; const handleApplyPack = (packId: 'balanced' | 'deep-work' | 'gentle') => - applyQuickPack({ packId, onTimerSelect, onSelectPreset, pushToast }); + applyQuickPack({ + packId, + onTimerSelect, + onSelectPreset, + onApplied: (message) => { + onStatusMessage({ message, durationMs: 1200 }); + }, + }); const showVolumeFeedback = (nextVolume: number) => { setVolumeFeedback(`${nextVolume}%`); @@ -413,7 +427,6 @@ export const SpaceToolsDockWidget = ({ onVolumeKeyDown={handleVolumeKeyDown} onSelectPreset={(presetId) => { onSelectPreset(presetId); - pushToast({ title: '사운드 변경(더미)' }); setOpenPopover(null); }} /> @@ -447,15 +460,12 @@ export const SpaceToolsDockWidget = ({ soundPresets={SOUND_PRESETS} onSelectRoom={(roomId) => { onRoomSelect(roomId); - pushToast({ title: '공간을 바꿨어요.' }); }} onSelectTimer={(label) => { onTimerSelect(label); - pushToast({ title: `타이머를 ${label}로 바꿨어요.` }); }} onSelectSound={(presetId) => { onSelectPreset(presetId); - pushToast({ title: '사운드를 바꿨어요.' }); }} onApplyPack={handleApplyPack} onLockedClick={handleLockedClick} diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index ccc9bfc..ef8e0eb 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -16,6 +16,7 @@ import { type TimerPreset, } from '@/entities/session'; import { useSoundPresetSelection } from '@/features/sound-preset'; +import { useHudStatusLine } from '@/shared/lib/useHudStatusLine'; import { useToast } from '@/shared/ui'; import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud'; import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer'; @@ -102,6 +103,7 @@ export const SpaceWorkspaceWidget = () => { const canStart = goalInput.trim().length > 0; const isFocusMode = workspaceMode === 'focus'; + const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode); const handleGoalChipSelect = (chip: GoalChip) => { setSelectedGoalId(chip.id); @@ -122,9 +124,8 @@ export const SpaceWorkspaceWidget = () => { } setWorkspaceMode('focus'); - - pushToast({ - title: `목표: ${goalInput.trim()} 시작해요.`, + pushStatusLine({ + message: `목표: ${goalInput.trim()} 시작해요.`, }); }; @@ -182,6 +183,9 @@ export const SpaceWorkspaceWidget = () => { goal={goalInput.trim()} timerLabel={selectedTimerLabel} visible={isFocusMode} + statusLine={activeStatus} + onStatusAction={runActiveAction} + onStatusMessage={pushStatusLine} onGoalUpdate={(nextGoal) => { setGoalInput(nextGoal); setSelectedGoalId(null); @@ -210,6 +214,7 @@ export const SpaceWorkspaceWidget = () => { onRestoreThought={restoreThought} onRestoreThoughts={restoreThoughts} onClearInbox={clearThoughts} + onStatusMessage={pushStatusLine} onExitRequested={handleExitRequested} />