refactor(feedback): 전역 토스트 제거 및 HUD 오버레이 피드백 도입
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user