refactor(feedback): 전역 토스트 제거 및 HUD 오버레이 피드백 도입
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useMemo } from 'react';
|
||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
|
|
||||||
interface ToastPayload {
|
interface ToastPayload {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -14,10 +13,6 @@ interface ToastPayload {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastItem extends ToastPayload {
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToastContextValue {
|
interface ToastContextValue {
|
||||||
pushToast: (payload: ToastPayload) => void;
|
pushToast: (payload: ToastPayload) => void;
|
||||||
}
|
}
|
||||||
@@ -25,58 +20,13 @@ interface ToastContextValue {
|
|||||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
export const ToastProvider = ({ children }: { children: ReactNode }) => {
|
export const ToastProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
const pushToast = useCallback((_payload: ToastPayload) => {}, []);
|
||||||
|
|
||||||
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 value = useMemo(() => ({ pushToast }), [pushToast]);
|
const value = useMemo(() => ({ pushToast }), [pushToast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={value}>
|
<ToastContext.Provider value={value}>
|
||||||
{children}
|
{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>
|
</ToastContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ export const SpaceFocusHudWidget = ({
|
|||||||
onClose={() => setSheetOpen(false)}
|
onClose={() => setSheetOpen(false)}
|
||||||
onRest={() => {
|
onRest={() => {
|
||||||
setSheetOpen(false);
|
setSheetOpen(false);
|
||||||
onStatusMessage({ message: '좋아요. 5분 뒤에 다시 알려드릴게요.' });
|
|
||||||
|
|
||||||
if (restReminderTimerRef.current) {
|
if (restReminderTimerRef.current) {
|
||||||
window.clearTimeout(restReminderTimerRef.current);
|
window.clearTimeout(restReminderTimerRef.current);
|
||||||
@@ -145,7 +144,6 @@ export const SpaceFocusHudWidget = ({
|
|||||||
onConfirm={(nextGoal) => {
|
onConfirm={(nextGoal) => {
|
||||||
onGoalUpdate(nextGoal);
|
onGoalUpdate(nextGoal);
|
||||||
setSheetOpen(false);
|
setSheetOpen(false);
|
||||||
onStatusMessage({ message: '다음 한 조각으로 이어갑니다.' });
|
|
||||||
triggerFlash(1200);
|
triggerFlash(1200);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const SpaceTimerHudWidget = ({
|
|||||||
onGoalCompleteRequest,
|
onGoalCompleteRequest,
|
||||||
onStatusAction,
|
onStatusAction,
|
||||||
}: SpaceTimerHudWidgetProps) => {
|
}: SpaceTimerHudWidgetProps) => {
|
||||||
const { isBreatheMode, hintMessage, triggerRestart } = useRestart30s();
|
const { isBreatheMode, triggerRestart } = useRestart30s();
|
||||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
|
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,7 +55,7 @@ export const SpaceTimerHudWidget = ({
|
|||||||
/>
|
/>
|
||||||
<section
|
<section
|
||||||
className={cn(
|
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
|
isImmersionMode
|
||||||
? 'border border-white/12 bg-black/22 backdrop-blur-md'
|
? 'border border-white/12 bg-black/22 backdrop-blur-md'
|
||||||
: 'border border-white/12 bg-black/24 backdrop-blur-md',
|
: 'border border-white/12 bg-black/24 backdrop-blur-md',
|
||||||
@@ -96,26 +96,6 @@ export const SpaceTimerHudWidget = ({
|
|||||||
완료
|
완료
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -152,6 +132,30 @@ export const SpaceTimerHudWidget = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ 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[];
|
||||||
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
||||||
onExitRequested: () => void;
|
onExitRequested: () => void;
|
||||||
@@ -65,7 +64,6 @@ export const SpaceToolsDockWidget = ({
|
|||||||
onDeleteThought,
|
onDeleteThought,
|
||||||
onSetThoughtCompleted,
|
onSetThoughtCompleted,
|
||||||
onRestoreThought,
|
onRestoreThought,
|
||||||
onRestoreThoughts,
|
|
||||||
onClearInbox,
|
onClearInbox,
|
||||||
onStatusMessage,
|
onStatusMessage,
|
||||||
onExitRequested,
|
onExitRequested,
|
||||||
@@ -194,26 +192,7 @@ export const SpaceToolsDockWidget = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInboxComplete = (thought: RecentThought) => {
|
const handleInboxComplete = (thought: RecentThought) => {
|
||||||
const previousThought = onSetThoughtCompleted(thought.id, !thought.isCompleted);
|
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 ? '완료 처리를 취소했어요.' : '완료 해제를 취소했어요.' });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInboxDelete = (thought: RecentThought) => {
|
const handleInboxDelete = (thought: RecentThought) => {
|
||||||
@@ -238,25 +217,7 @@ export const SpaceToolsDockWidget = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInboxClear = () => {
|
const handleInboxClear = () => {
|
||||||
const snapshot = onClearInbox();
|
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 = () => {
|
||||||
@@ -284,9 +245,6 @@ export const SpaceToolsDockWidget = ({
|
|||||||
packId,
|
packId,
|
||||||
onTimerSelect,
|
onTimerSelect,
|
||||||
onSelectPreset,
|
onSelectPreset,
|
||||||
onApplied: (message) => {
|
|
||||||
onStatusMessage({ message, durationMs: 1200 });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const showVolumeFeedback = (nextVolume: number) => {
|
const showVolumeFeedback = (nextVolume: number) => {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
} from '@/entities/session';
|
} from '@/entities/session';
|
||||||
import { useSoundPresetSelection } from '@/features/sound-preset';
|
import { useSoundPresetSelection } from '@/features/sound-preset';
|
||||||
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
|
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
|
||||||
import { useToast } from '@/shared/ui';
|
|
||||||
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
|
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
|
||||||
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
|
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
|
||||||
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
|
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
|
||||||
@@ -55,7 +54,6 @@ const resolveInitialTimerLabel = (timerLabelFromQuery: string | null) => {
|
|||||||
|
|
||||||
export const SpaceWorkspaceWidget = () => {
|
export const SpaceWorkspaceWidget = () => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { pushToast } = useToast();
|
|
||||||
const {
|
const {
|
||||||
thoughts,
|
thoughts,
|
||||||
thoughtCount,
|
thoughtCount,
|
||||||
@@ -63,7 +61,6 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
removeThought,
|
removeThought,
|
||||||
clearThoughts,
|
clearThoughts,
|
||||||
restoreThought,
|
restoreThought,
|
||||||
restoreThoughts,
|
|
||||||
setThoughtCompleted,
|
setThoughtCompleted,
|
||||||
} = useThoughtInbox();
|
} = useThoughtInbox();
|
||||||
|
|
||||||
@@ -124,14 +121,10 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setWorkspaceMode('focus');
|
setWorkspaceMode('focus');
|
||||||
pushStatusLine({
|
|
||||||
message: `목표: ${goalInput.trim()} 시작해요.`,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExitRequested = () => {
|
const handleExitRequested = () => {
|
||||||
setWorkspaceMode('setup');
|
setWorkspaceMode('setup');
|
||||||
pushToast({ title: '준비 모드로 돌아왔어요.' });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -212,7 +205,6 @@ 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}
|
||||||
|
|||||||
Reference in New Issue
Block a user