refactor(toast): HUD 상태 라인 피드백 통합 및 우선순위 큐 적용
This commit is contained in:
103
src/shared/lib/useHudStatusLine.ts
Normal file
103
src/shared/lib/useHudStatusLine.ts
Normal file
@@ -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<HudStatusLineItem[]>([]);
|
||||
|
||||
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]);
|
||||
};
|
||||
@@ -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 = ({
|
||||
<SpaceTimerHudWidget
|
||||
timerLabel={timerLabel}
|
||||
goal={goal}
|
||||
statusLine={
|
||||
statusLine
|
||||
? {
|
||||
message: statusLine.message,
|
||||
actionLabel: statusLine.action?.label,
|
||||
}
|
||||
: null
|
||||
}
|
||||
isImmersionMode
|
||||
className="pr-[4.2rem]"
|
||||
onGoalCompleteRequest={handleOpenCompleteSheet}
|
||||
onStatusAction={onStatusAction}
|
||||
onPlaybackStateChange={(state) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 = ({
|
||||
완료
|
||||
</button>
|
||||
</div>
|
||||
{hintMessage ? (
|
||||
{statusLine ? (
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2">
|
||||
<p className={cn('min-w-0 truncate text-[11px]', isImmersionMode ? 'text-white/68' : 'text-white/66')}>
|
||||
{statusLine.message}
|
||||
</p>
|
||||
{statusLine.actionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStatusAction}
|
||||
className="shrink-0 text-[11px] font-medium text-white/76 underline-offset-2 transition-colors hover:text-white/92 hover:underline"
|
||||
>
|
||||
{statusLine.actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : hintMessage ? (
|
||||
<p className={cn('mt-1 truncate text-[10px]', isImmersionMode ? 'text-white/52' : 'text-white/52')}>
|
||||
{hintMessage}
|
||||
</p>
|
||||
|
||||
@@ -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 팩을 적용했어요.');
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user