refactor(feedback): 전역 토스트 제거 및 HUD 오버레이 피드백 도입

This commit is contained in:
2026-03-04 21:56:51 +09:00
parent 06dbee8d63
commit 836679753e
5 changed files with 30 additions and 128 deletions

View File

@@ -1,8 +1,7 @@
'use client';
import type { ReactNode } from 'react';
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { cn } from '@/shared/lib/cn';
import { createContext, useCallback, useContext, useMemo } from 'react';
interface ToastPayload {
title: string;
@@ -14,10 +13,6 @@ interface ToastPayload {
};
}
interface ToastItem extends ToastPayload {
id: number;
}
interface ToastContextValue {
pushToast: (payload: ToastPayload) => void;
}
@@ -25,58 +20,13 @@ interface ToastContextValue {
const ToastContext = createContext<ToastContextValue | null>(null);
export const ToastProvider = ({ children }: { children: ReactNode }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const removeToast = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const pushToast = useCallback((payload: ToastPayload) => {
const id = Date.now() + Math.floor(Math.random() * 10000);
const durationMs = payload.durationMs ?? 2400;
setToasts((current) => [...current, { id, ...payload }]);
window.setTimeout(() => {
removeToast(id);
}, durationMs);
}, [removeToast]);
const pushToast = useCallback((_payload: ToastPayload) => {}, []);
const value = useMemo(() => ({ pushToast }), [pushToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-[min(92vw,340px)] flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
'pointer-events-auto rounded-xl border border-white/15 bg-slate-950/92 px-4 py-3 text-sm text-white shadow-lg shadow-slate-950/60',
'animate-[toast-in_180ms_ease-out] motion-reduce:animate-none',
)}
>
<p className="font-medium">{toast.title}</p>
{toast.description ? (
<p className="mt-1 text-xs text-white/70">{toast.description}</p>
) : null}
{toast.action ? (
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() => {
toast.action?.onClick();
removeToast(toast.id);
}}
className="rounded-full border border-white/25 bg-white/[0.08] px-2.5 py-1 text-[11px] font-medium text-white/88 transition-colors hover:bg-white/[0.16]"
>
{toast.action.label}
</button>
</div>
) : null}
</div>
))}
</div>
</ToastContext.Provider>
);
};

View File

@@ -131,7 +131,6 @@ export const SpaceFocusHudWidget = ({
onClose={() => setSheetOpen(false)}
onRest={() => {
setSheetOpen(false);
onStatusMessage({ message: '좋아요. 5분 뒤에 다시 알려드릴게요.' });
if (restReminderTimerRef.current) {
window.clearTimeout(restReminderTimerRef.current);
@@ -145,7 +144,6 @@ export const SpaceFocusHudWidget = ({
onConfirm={(nextGoal) => {
onGoalUpdate(nextGoal);
setSheetOpen(false);
onStatusMessage({ message: '다음 한 조각으로 이어갑니다.' });
triggerFlash(1200);
}}
/>

View File

@@ -37,7 +37,7 @@ export const SpaceTimerHudWidget = ({
onGoalCompleteRequest,
onStatusAction,
}: SpaceTimerHudWidgetProps) => {
const { isBreatheMode, hintMessage, triggerRestart } = useRestart30s();
const { isBreatheMode, triggerRestart } = useRestart30s();
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
return (
@@ -55,7 +55,7 @@ export const SpaceTimerHudWidget = ({
/>
<section
className={cn(
'relative z-10 flex min-h-[4.65rem] items-center justify-between gap-3 rounded-2xl px-3.5 py-2 transition-colors',
'relative z-10 flex h-[4.85rem] items-center justify-between gap-3 overflow-hidden rounded-2xl px-3.5 py-2 transition-colors',
isImmersionMode
? 'border border-white/12 bg-black/22 backdrop-blur-md'
: 'border border-white/12 bg-black/24 backdrop-blur-md',
@@ -96,26 +96,6 @@ export const SpaceTimerHudWidget = ({
</button>
</div>
{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>
) : null}
</div>
<div className="flex items-center gap-2.5">
@@ -152,6 +132,30 @@ export const SpaceTimerHudWidget = ({
/>
</div>
</section>
<div
className="pointer-events-none absolute bottom-2 left-3.5 z-[12] max-w-[72%]"
role="status"
aria-live="polite"
aria-atomic="true"
>
<div
className={cn(
'inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/12 bg-black/24 px-2.5 py-1 text-[10px] text-white/72 backdrop-blur-sm transition-all duration-[220ms] ease-out motion-reduce:duration-0',
statusLine ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0',
)}
>
<span className="truncate">{statusLine?.message ?? ''}</span>
{statusLine?.actionLabel ? (
<button
type="button"
onClick={onStatusAction}
className="pointer-events-auto shrink-0 text-[10px] font-medium text-white/84 underline-offset-2 transition-colors hover:text-white hover:underline"
>
{statusLine.actionLabel}
</button>
) : null}
</div>
</div>
</div>
</div>
);

View File

@@ -39,7 +39,6 @@ interface SpaceToolsDockWidgetProps {
onDeleteThought: (thoughtId: string) => RecentThought | null;
onSetThoughtCompleted: (thoughtId: string, isCompleted: boolean) => RecentThought | null;
onRestoreThought: (thought: RecentThought) => void;
onRestoreThoughts: (thoughts: RecentThought[]) => void;
onClearInbox: () => RecentThought[];
onStatusMessage: (payload: HudStatusLinePayload) => void;
onExitRequested: () => void;
@@ -65,7 +64,6 @@ export const SpaceToolsDockWidget = ({
onDeleteThought,
onSetThoughtCompleted,
onRestoreThought,
onRestoreThoughts,
onClearInbox,
onStatusMessage,
onExitRequested,
@@ -194,26 +192,7 @@ export const SpaceToolsDockWidget = ({
};
const handleInboxComplete = (thought: RecentThought) => {
const previousThought = onSetThoughtCompleted(thought.id, !thought.isCompleted);
if (!previousThought) {
return;
}
const willBeCompleted = !thought.isCompleted;
onStatusMessage({
message: willBeCompleted ? '완료 처리됨' : '완료 해제됨',
durationMs: 4200,
priority: 'undo',
action: {
label: '실행취소',
onClick: () => {
onRestoreThought(previousThought);
onStatusMessage({ message: willBeCompleted ? '완료 처리를 취소했어요.' : '완료 해제를 취소했어요.' });
},
},
});
onSetThoughtCompleted(thought.id, !thought.isCompleted);
};
const handleInboxDelete = (thought: RecentThought) => {
@@ -238,25 +217,7 @@ export const SpaceToolsDockWidget = ({
};
const handleInboxClear = () => {
const snapshot = onClearInbox();
if (snapshot.length === 0) {
onStatusMessage({ message: '인박스가 비어 있어요.' });
return;
}
onStatusMessage({
message: '모두 비워짐',
durationMs: 4200,
priority: 'undo',
action: {
label: '실행취소',
onClick: () => {
onRestoreThoughts(snapshot);
onStatusMessage({ message: '인박스를 복구했어요.' });
},
},
});
onClearInbox();
};
const handlePlanPillClick = () => {
@@ -284,9 +245,6 @@ export const SpaceToolsDockWidget = ({
packId,
onTimerSelect,
onSelectPreset,
onApplied: (message) => {
onStatusMessage({ message, durationMs: 1200 });
},
});
const showVolumeFeedback = (nextVolume: number) => {

View File

@@ -17,7 +17,6 @@ import {
} 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';
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
@@ -55,7 +54,6 @@ const resolveInitialTimerLabel = (timerLabelFromQuery: string | null) => {
export const SpaceWorkspaceWidget = () => {
const searchParams = useSearchParams();
const { pushToast } = useToast();
const {
thoughts,
thoughtCount,
@@ -63,7 +61,6 @@ export const SpaceWorkspaceWidget = () => {
removeThought,
clearThoughts,
restoreThought,
restoreThoughts,
setThoughtCompleted,
} = useThoughtInbox();
@@ -124,14 +121,10 @@ export const SpaceWorkspaceWidget = () => {
}
setWorkspaceMode('focus');
pushStatusLine({
message: `목표: ${goalInput.trim()} 시작해요.`,
});
};
const handleExitRequested = () => {
setWorkspaceMode('setup');
pushToast({ title: '준비 모드로 돌아왔어요.' });
};
useEffect(() => {
@@ -212,7 +205,6 @@ export const SpaceWorkspaceWidget = () => {
onDeleteThought={removeThought}
onSetThoughtCompleted={setThoughtCompleted}
onRestoreThought={restoreThought}
onRestoreThoughts={restoreThoughts}
onClearInbox={clearThoughts}
onStatusMessage={pushStatusLine}
onExitRequested={handleExitRequested}