Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions frontend/src/components/Dialog/DialogProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';

type DialogType = 'info' | 'success' | 'warning' | 'error';

interface AlertOptions {
title?: string;
type?: DialogType;
details?: string;
confirmLabel?: string;
}

interface ConfirmOptions {
title?: string;
danger?: boolean;
confirmLabel?: string;
cancelLabel?: string;
}

interface DialogContextValue {
alert: (message: string, options?: AlertOptions) => Promise<void>;
confirm: (message: string, options?: ConfirmOptions) => Promise<boolean>;
}

const DialogContext = createContext<DialogContextValue | null>(null);

type ModalState =
| { kind: 'alert'; message: string; options: AlertOptions; resolve: () => void }
| { kind: 'confirm'; message: string; options: ConfirmOptions; resolve: (ok: boolean) => void }
| null;

const TYPE_META: Record<DialogType, { color: string; icon: string }> = {
info: { color: 'var(--info)', icon: 'ℹ' },
success: { color: 'var(--success)', icon: '✓' },
warning: { color: 'var(--warning)', icon: '⚠' },
error: { color: 'var(--error)', icon: '✕' },
};

export function DialogProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ModalState>(null);

const alert = useCallback(
(message: string, options: AlertOptions = {}) =>
new Promise<void>((resolve) => setState({ kind: 'alert', message, options, resolve })),
[],
);

const confirm = useCallback(
(message: string, options: ConfirmOptions = {}) =>
new Promise<boolean>((resolve) => setState({ kind: 'confirm', message, options, resolve })),
[],
);

const close = useCallback((result?: boolean) => {
setState((s) => {
if (!s) return null;
if (s.kind === 'alert') s.resolve();
else s.resolve(!!result);
return null;
});
}, []);

return (
<DialogContext.Provider value={{ alert, confirm }}>
{children}
{state && <DialogModal state={state} onClose={close} />}
</DialogContext.Provider>
);
}

function DialogModal({ state, onClose }: { state: NonNullable<ModalState>; onClose: (result?: boolean) => void }) {
const btnRef = useRef<HTMLButtonElement>(null);
const [showDetails, setShowDetails] = useState(false);

useEffect(() => {
const timer = setTimeout(() => btnRef.current?.focus(), 50);
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(false);
if (e.key === 'Enter' && state.kind === 'alert') onClose();
};
window.addEventListener('keydown', onKey);
return () => { clearTimeout(timer); window.removeEventListener('keydown', onKey); };
}, [state, onClose]);

const isConfirm = state.kind === 'confirm';
const type: DialogType = isConfirm
? (state.options.danger ? 'error' : 'info')
: (state.options.type ?? 'info');
const meta = TYPE_META[type];
const title = state.options.title
?? (isConfirm ? '请确认' : type === 'error' ? '出错了' : type === 'success' ? '成功' : type === 'warning' ? '提示' : '提示');
const details = !isConfirm ? state.options.details : undefined;

return (
<div
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10000,
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose(false); }}
>
<div
role="dialog"
aria-modal="true"
style={{
background: 'var(--bg-primary)',
borderRadius: '12px',
padding: '24px',
width: '420px',
maxWidth: '90vw',
maxHeight: '80vh',
overflow: 'auto',
border: '1px solid var(--border-subtle)',
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<span
aria-hidden
style={{
width: '22px', height: '22px', borderRadius: '50%',
background: meta.color, color: '#fff',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: '13px', fontWeight: 700, flexShrink: 0,
}}
>{meta.icon}</span>
<h4 style={{ margin: 0, fontSize: '15px', fontWeight: 600 }}>{title}</h4>
</div>
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: details ? '12px' : '20px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{state.message}
</div>
{details && (
<div style={{ marginBottom: '20px' }}>
<button
type="button"
onClick={() => setShowDetails((v) => !v)}
style={{
background: 'none', border: 'none', padding: 0,
color: 'var(--text-tertiary)', fontSize: '12px',
cursor: 'pointer', textDecoration: 'underline',
}}
>
{showDetails ? '收起详细信息' : '查看详细信息'}
</button>
{showDetails && (
<pre style={{
marginTop: '8px',
padding: '10px 12px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-subtle)',
borderRadius: '6px',
fontSize: '11px',
color: 'var(--text-secondary)',
maxHeight: '240px',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
fontFamily: 'var(--font-mono)',
}}>{details}</pre>
)}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
{isConfirm && (
<button className="btn btn-secondary" onClick={() => onClose(false)}>
{state.options.cancelLabel ?? '取消'}
</button>
)}
<button
ref={btnRef}
className={isConfirm && state.options.danger ? 'btn btn-danger' : 'btn btn-primary'}
onClick={() => onClose(true)}
>
{isConfirm
? (state.options.confirmLabel ?? '确定')
: (state.options.confirmLabel ?? '确定')}
</button>
</div>
</div>
</div>
);
}

export function useDialog() {
const ctx = useContext(DialogContext);
if (!ctx) throw new Error('useDialog must be used within DialogProvider');
return ctx;
}
182 changes: 182 additions & 0 deletions frontend/src/components/Toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';

type ToastType = 'info' | 'success' | 'warning' | 'error';

interface ToastOptions {
duration?: number;
details?: string;
}

interface ToastItem {
id: number;
type: ToastType;
message: string;
details?: string;
duration: number;
}

interface ToastContextValue {
show: (type: ToastType, message: string, options?: ToastOptions) => void;
info: (message: string, options?: ToastOptions) => void;
success: (message: string, options?: ToastOptions) => void;
warning: (message: string, options?: ToastOptions) => void;
error: (message: string, options?: ToastOptions) => void;
}

const ToastContext = createContext<ToastContextValue | null>(null);

const TYPE_META: Record<ToastType, { color: string; icon: string }> = {
info: { color: 'var(--info)', icon: 'ℹ' },
success: { color: 'var(--success)', icon: '✓' },
warning: { color: 'var(--warning)', icon: '⚠' },
error: { color: 'var(--error)', icon: '✕' },
};

let idSeq = 0;

export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);

const remove = useCallback((id: number) => {
setItems((list) => list.filter((t) => t.id !== id));
}, []);

const show = useCallback((type: ToastType, message: string, options: ToastOptions = {}) => {
const id = ++idSeq;
const duration = options.duration ?? (type === 'error' ? 6000 : 3500);
setItems((list) => [...list, { id, type, message, details: options.details, duration }]);
}, []);

const value: ToastContextValue = {
show,
info: (m, o) => show('info', m, o),
success: (m, o) => show('success', m, o),
warning: (m, o) => show('warning', m, o),
error: (m, o) => show('error', m, o),
};

return (
<ToastContext.Provider value={value}>
{children}
<div
style={{
position: 'fixed',
top: '20px',
right: '20px',
zIndex: 10001,
display: 'flex',
flexDirection: 'column',
gap: '10px',
pointerEvents: 'none',
maxWidth: '380px',
}}
>
{items.map((t) => (
<ToastCard key={t.id} item={t} onClose={() => remove(t.id)} />
))}
</div>
</ToastContext.Provider>
);
}

function ToastCard({ item, onClose }: { item: ToastItem; onClose: () => void }) {
const [showDetails, setShowDetails] = useState(false);
const [leaving, setLeaving] = useState(false);
const timerRef = useRef<number | null>(null);
const meta = TYPE_META[item.type];

useEffect(() => {
timerRef.current = window.setTimeout(() => {
setLeaving(true);
window.setTimeout(onClose, 180);
}, item.duration);
return () => { if (timerRef.current) window.clearTimeout(timerRef.current); };
}, [item.duration, onClose]);

const pause = () => { if (timerRef.current) window.clearTimeout(timerRef.current); };

return (
<div
role="status"
onMouseEnter={pause}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore toast dismissal after hover pause

The toast timer is cleared on onMouseEnter, but there is no corresponding restart on mouse leave, so any toast the cursor touches can remain indefinitely instead of auto-dismissing. This causes notification buildup during normal use near the top-right corner and contradicts the expected transient behavior.

Useful? React with 👍 / 👎.

style={{
pointerEvents: 'auto',
background: 'var(--bg-elevated)',
border: '1px solid var(--border-subtle)',
borderLeft: `3px solid ${meta.color}`,
borderRadius: '8px',
padding: '12px 14px',
boxShadow: '0 8px 24px rgba(0,0,0,0.3)',
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
fontSize: '13px',
color: 'var(--text-primary)',
opacity: leaving ? 0 : 1,
transform: leaving ? 'translateX(20px)' : 'translateX(0)',
transition: 'opacity 180ms ease, transform 180ms ease',
minWidth: '240px',
}}
>
<span
aria-hidden
style={{
width: '18px', height: '18px', borderRadius: '50%',
background: meta.color, color: '#fff',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: '11px', fontWeight: 700, flexShrink: 0, marginTop: '1px',
}}
>{meta.icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ lineHeight: 1.5, wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}>{item.message}</div>
{item.details && (
<>
<button
type="button"
onClick={() => setShowDetails((v) => !v)}
style={{
background: 'none', border: 'none', padding: 0,
color: 'var(--text-tertiary)', fontSize: '11px',
cursor: 'pointer', textDecoration: 'underline', marginTop: '4px',
}}
>
{showDetails ? '收起详情' : '查看详情'}
</button>
{showDetails && (
<pre style={{
marginTop: '6px',
padding: '8px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-subtle)',
borderRadius: '4px',
fontSize: '11px',
color: 'var(--text-secondary)',
maxHeight: '160px',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
fontFamily: 'var(--font-mono)',
}}>{item.details}</pre>
)}
</>
)}
</div>
<button
type="button"
onClick={() => { setLeaving(true); window.setTimeout(onClose, 180); }}
aria-label="Close"
style={{
background: 'none', border: 'none', padding: 0,
color: 'var(--text-tertiary)', cursor: 'pointer',
fontSize: '14px', lineHeight: 1, flexShrink: 0,
}}
>✕</button>
</div>
);
}

export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
8 changes: 7 additions & 1 deletion frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import './i18n';
import './index.css';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
import { DialogProvider } from './components/Dialog/DialogProvider';
import { ToastProvider } from './components/Toast/ToastProvider';
import { loadSavedAccentColor } from './utils/theme';

// Apply saved theme color before first paint
Expand All @@ -22,7 +24,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<DialogProvider>
<ToastProvider>
<App />
</ToastProvider>
</DialogProvider>
</BrowserRouter>
</QueryClientProvider>
</ErrorBoundary>
Expand Down
Loading