169 lines
5.3 KiB
TypeScript
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;
|
|
}
|