dashboard-nanobot/frontend/src/components/lucent/LucentPromptProvider.tsx

169 lines
5.3 KiB
TypeScript

import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react';
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from 'lucide-react';
import { useAppStore } from '../../store/appStore';
import { LucentIconButton } from './LucentIconButton';
import './lucent-prompt.css';
type PromptTone = 'info' | 'success' | 'warning' | 'error';
interface NotifyOptions {
title?: string;
tone?: PromptTone;
durationMs?: number;
}
interface ConfirmOptions {
title?: string;
message: string;
tone?: PromptTone;
confirmText?: string;
cancelText?: string;
}
interface ToastItem {
id: number;
title?: string;
message: string;
tone: PromptTone;
}
interface ConfirmState {
title?: string;
message: string;
tone: PromptTone;
confirmText: string;
cancelText: string;
resolve: (value: boolean) => void;
}
interface LucentPromptApi {
notify: (message: string, options?: NotifyOptions) => void;
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
const LucentPromptContext = createContext<LucentPromptApi | null>(null);
function ToneIcon({ tone }: { tone: PromptTone }) {
if (tone === 'success') return <CheckCircle2 size={16} />;
if (tone === 'warning') return <AlertTriangle size={16} />;
if (tone === 'error') return <AlertCircle size={16} />;
return <Info size={16} />;
}
export function LucentPromptProvider({ children }: { children: ReactNode }) {
const locale = useAppStore((s) => s.locale);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null);
const idRef = useRef(1);
const notify = useCallback((message: string, options?: NotifyOptions) => {
const text = String(message || '').trim();
if (!text) return;
const id = idRef.current++;
const tone = options?.tone || 'info';
setToasts((prev) => [...prev, { id, title: options?.title, message: text, tone }]);
const ttl = Math.max(1400, Math.min(options?.durationMs || 2600, 8000));
window.setTimeout(() => {
setToasts((prev) => prev.filter((item) => item.id !== id));
}, ttl);
}, []);
const confirm = useCallback(
(options: ConfirmOptions) =>
new Promise<boolean>((resolve) => {
const message = String(options?.message || '').trim();
if (!message) {
resolve(false);
return;
}
setConfirmState({
title: options?.title,
message,
tone: options?.tone || 'warning',
confirmText: options?.confirmText || (locale === 'zh' ? '确定' : 'Confirm'),
cancelText: options?.cancelText || (locale === 'zh' ? '取消' : 'Cancel'),
resolve,
});
}),
[locale],
);
const closeConfirm = useCallback(
(value: boolean) => {
setConfirmState((prev) => {
if (prev) prev.resolve(value);
return null;
});
},
[],
);
const value = useMemo<LucentPromptApi>(
() => ({
notify,
confirm,
}),
[notify, confirm],
);
return (
<LucentPromptContext.Provider value={value}>
{children}
<div className="lucent-toast-stack" aria-live="polite" aria-atomic="false">
{toasts.map((toast) => (
<div key={toast.id} className={`lucent-toast tone-${toast.tone}`}>
<span className="lucent-toast-icon">
<ToneIcon tone={toast.tone} />
</span>
<div className="lucent-toast-body">
{toast.title ? <div className="lucent-toast-title">{toast.title}</div> : null}
<div className="lucent-toast-message">{toast.message}</div>
</div>
</div>
))}
</div>
{confirmState ? (
<div className="lucent-confirm-mask" onClick={() => closeConfirm(false)}>
<div className={`lucent-confirm-card tone-${confirmState.tone}`} onClick={(e) => e.stopPropagation()}>
<div className="lucent-confirm-head">
<span className="lucent-confirm-icon">
<ToneIcon tone={confirmState.tone} />
</span>
<div className="lucent-confirm-title">
{confirmState.title || (locale === 'zh' ? '请确认操作' : 'Please Confirm')}
</div>
<LucentIconButton
className="lucent-confirm-close"
onClick={() => closeConfirm(false)}
aria-label={locale === 'zh' ? '关闭' : 'Close'}
tooltip={locale === 'zh' ? '关闭' : 'Close'}
>
<X size={14} />
</LucentIconButton>
</div>
<div className="lucent-confirm-message">{confirmState.message}</div>
<div className="lucent-confirm-actions">
<button className="btn btn-secondary" onClick={() => closeConfirm(false)}>
{confirmState.cancelText}
</button>
<button className="btn btn-primary" onClick={() => closeConfirm(true)}>
{confirmState.confirmText}
</button>
</div>
</div>
</div>
) : null}
</LucentPromptContext.Provider>
);
}
export function useLucentPrompt() {
const ctx = useContext(LucentPromptContext);
if (!ctx) {
throw new Error('useLucentPrompt must be used inside LucentPromptProvider');
}
return ctx;
}