feat: replace browser built-in alert/confirm/prompt pop-up with new DialogProvider

This commit is contained in:
Xiaohan-Tian
2026-04-19 23:54:26 -07:00
parent 0ac39068b2
commit 1828dbbc63
13 changed files with 392 additions and 70 deletions
+141
View File
@@ -0,0 +1,141 @@
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
backdrop-filter: blur(2px);
}
.dialog-modal {
background-color: #2d2d2d;
border: 1px solid #3a3a3a;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
width: 90%;
max-width: 440px;
overflow: hidden;
animation: dialogFadeIn 0.2s ease-out;
}
@keyframes dialogFadeIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background-color: #252525;
}
.dialog-title {
color: #e0e0e0;
font-size: 18px;
font-weight: 600;
margin: 0;
}
.dialog-close-btn {
background: transparent;
border: none;
color: #b0b0b0;
cursor: pointer;
padding: 8px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.2s ease;
}
.dialog-close-btn:hover {
background-color: #3a3a3a;
color: #e0e0e0;
}
.dialog-body {
padding: 24px 20px;
background-color: #2d2d2d;
}
.dialog-message {
color: #e0e0e0;
font-size: 14px;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.dialog-input {
width: 100%;
margin-top: 12px;
padding: 8px 10px;
background-color: #1e1e1e;
border: 1px solid #3a3a3a;
border-radius: 6px;
color: #e0e0e0;
font-size: 14px;
outline: none;
box-sizing: border-box;
transition: border-color 0.2s ease;
}
.dialog-input:focus {
border-color: #5a9fd4;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
background-color: #2d2d2d;
}
.dialog-btn {
padding: 8px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
.dialog-btn-primary {
background-color: #5a9fd4;
color: #fff;
}
.dialog-btn-primary:hover {
background-color: #4a8fc4;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(90, 159, 212, 0.3);
}
.dialog-btn-cancel {
background-color: #3a3a3a;
color: #b0b0b0;
}
.dialog-btn-cancel:hover {
background-color: #4a4a4a;
color: #e0e0e0;
}
+168
View File
@@ -0,0 +1,168 @@
import React, { useState, useCallback, useRef } from 'react';
import './DialogProvider.css';
import { FaTimes } from 'react-icons/fa';
export interface ConfirmOptions {
confirmLabel?: string;
cancelLabel?: string;
}
export interface PromptOptions {
confirmLabel?: string;
cancelLabel?: string;
placeholder?: string;
}
let _showAlertFn: ((message: string) => Promise<void>) | null = null;
let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise<boolean>) | null = null;
let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>) | null = null;
export function showAlert(message: string): Promise<void> {
if (!_showAlertFn) {
window.alert(message);
return Promise.resolve();
}
return _showAlertFn(message);
}
export function showConfirm(message: string, options?: ConfirmOptions): Promise<boolean> {
if (!_showConfirmFn) {
return Promise.resolve(window.confirm(message));
}
return _showConfirmFn(message, options);
}
export function showPrompt(message: string, defaultValue?: string, options?: PromptOptions): Promise<string | null> {
if (!_showPromptFn) {
return Promise.resolve(window.prompt(message, defaultValue) );
}
return _showPromptFn(message, defaultValue, options);
}
interface DialogInfo {
type: 'alert' | 'confirm' | 'prompt';
message: string;
options?: ConfirmOptions | PromptOptions;
defaultValue?: string;
}
const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [dialog, setDialog] = useState<DialogInfo | null>(null);
const [inputValue, setInputValue] = useState('');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolveRef = useRef<((value: any) => void) | null>(null);
const openAlert = useCallback((message: string): Promise<void> => {
return new Promise<void>((resolve) => {
resolveRef.current = resolve;
setDialog({ type: 'alert', message });
});
}, []);
const openConfirm = useCallback((message: string, options?: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve;
setDialog({ type: 'confirm', message, options });
});
}, []);
const openPrompt = useCallback((message: string, defaultValue?: string, options?: PromptOptions): Promise<string | null> => {
return new Promise<string | null>((resolve) => {
resolveRef.current = resolve;
setInputValue(defaultValue ?? '');
setDialog({ type: 'prompt', message, options, defaultValue });
});
}, []);
const close = useCallback((value: unknown) => {
setDialog(null);
setInputValue('');
if (resolveRef.current) {
resolveRef.current(value);
resolveRef.current = null;
}
}, []);
const registered = useRef(false);
if (!registered.current) {
registered.current = true;
_showAlertFn = openAlert;
_showConfirmFn = openConfirm;
_showPromptFn = openPrompt;
}
if (!dialog) {
return <>{children}</>;
}
const isAlert = dialog.type === 'alert';
const isPrompt = dialog.type === 'prompt';
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
const title = isAlert ? 'Notice' : isPrompt ? 'Input' : 'Confirm';
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
close(isAlert ? undefined : isPrompt ? null : false);
}
};
const handleCancel = () => close(isAlert ? undefined : isPrompt ? null : false);
const handleConfirm = () => close(isAlert ? undefined : isPrompt ? inputValue : true);
return (
<>
{children}
<div className="dialog-overlay" onClick={handleOverlayClick}>
<div className="dialog-modal">
<div className="dialog-header">
<h3 className="dialog-title">{title}</h3>
<button
className="dialog-close-btn"
onClick={handleCancel}
aria-label="Close dialog"
>
<FaTimes />
</button>
</div>
<div className="dialog-body">
<p className="dialog-message">{dialog.message}</p>
{isPrompt && (
<input
className="dialog-input"
type="text"
value={inputValue}
placeholder={promptOptions?.placeholder}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirm();
if (e.key === 'Escape') handleCancel();
}}
autoFocus
/>
)}
</div>
<div className="dialog-footer">
{!isAlert && (
<button
className="dialog-btn dialog-btn-cancel"
onClick={handleCancel}
>
{(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? 'Cancel'}
</button>
)}
<button
className="dialog-btn dialog-btn-primary"
onClick={handleConfirm}
autoFocus={!isPrompt}
>
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt ? 'OK' : 'Yes'))}
</button>
</div>
</div>
</div>
</>
);
};
export default DialogProvider;
+3 -2
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useState } from 'react';
import './FileImportModal.css';
import { FaTimes } from 'react-icons/fa';
import { showAlert } from './DialogProvider';
interface FileImportModalProps {
isVisible: boolean;
@@ -41,7 +42,7 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
@@ -56,7 +57,7 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
onFileImport(file);
onClose();
} else {
alert(`Invalid file type. Please select a file with one of these extensions: ${acceptedTypes.join(', ')}`);
await showAlert(`Invalid file type. Please select a file with one of these extensions: ${acceptedTypes.join(', ')}`);
}
}
}, [acceptedTypes, onFileImport, onClose]);
+8 -7
View File
@@ -3,6 +3,7 @@ import { FaTimes, FaSortUp, FaSortDown, FaCopy, FaTrash, FaUndo } from 'react-ic
import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStorage';
import { isValidProjectName } from '../../util/projectNameUtil';
import './OpenProjectModal.css';
import { showAlert, showConfirm, showPrompt } from './DialogProvider';
interface OpenProjectModalProps {
onClose: () => void;
@@ -115,12 +116,12 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
const handleDuplicate = async (e: React.MouseEvent, projectName: string) => {
e.stopPropagation();
const newName = window.prompt('Enter a name for the duplicated project:', projectName);
const newName = await showPrompt('Enter a name for the duplicated project:', projectName);
if (!newName || newName.trim() === '') return;
const trimmed = newName.trim();
if (!isValidProjectName(trimmed)) {
window.alert('Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.');
await showAlert('Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.');
return;
}
@@ -132,7 +133,7 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
onClose();
} catch (error) {
console.error('Error duplicating project:', error);
window.alert(`Failed to duplicate project: ${error}`);
await showAlert(`Failed to duplicate project: ${error}`);
}
};
@@ -144,7 +145,7 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
await fetchProjects(viewMode);
} catch (error) {
console.error('Error deleting project:', error);
window.alert(`Failed to delete project: ${error}`);
await showAlert(`Failed to delete project: ${error}`);
}
};
@@ -156,13 +157,13 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
await fetchProjects(viewMode);
} catch (error) {
console.error('Error restoring project:', error);
window.alert(`Failed to restore project: ${error}`);
await showAlert(`Failed to restore project: ${error}`);
}
};
const handlePermanentDelete = async (e: React.MouseEvent, projectName: string) => {
e.stopPropagation();
const confirmed = window.confirm(
const confirmed = await showConfirm(
`Are you sure you want to permanently delete "${projectName}"?\n\nThis operation cannot be undone.`
);
if (!confirmed) return;
@@ -177,7 +178,7 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
}
} catch (error) {
console.error('Error permanently deleting project:', error);
window.alert(`Failed to permanently delete project: ${error}`);
await showAlert(`Failed to permanently delete project: ${error}`);
}
};
+3 -1
View File
@@ -2,4 +2,6 @@ export { default as KGDropdown } from './KGDropdown';
export { default as Playhead } from './Playhead';
export { default as FileImportModal } from './FileImportModal';
export { default as LoadingOverlay } from './LoadingOverlay';
export { default as OpenProjectModal } from './OpenProjectModal';
export { default as OpenProjectModal } from './OpenProjectModal';
export { default as DialogProvider, showAlert, showConfirm, showPrompt } from './DialogProvider';
export type { ConfirmOptions, PromptOptions } from './DialogProvider';