feat(ui): global message dialog to replace window.alert()
useMessageDialogStore + <MessageDialogHost /> (mounted once in App.tsx)
give a themed in-app dialog callable from anywhere — React components
and plain .ts modules alike via showMessageDialog(msg, {kind}). Swaps
the native alert() calls in FileExplorer (import errors) and the
desktop menu (.vlx open errors, updater status) for it; the pro overlay
can reuse the same store.
This commit is contained in:
parent
17e9e5a8c2
commit
9826737831
|
|
@ -34,6 +34,7 @@ import { LocaleSync } from './i18n/LocaleSync';
|
|||
import { NON_DEFAULT_LOCALES } from './i18n/config';
|
||||
import { useProRoutes } from './lib/proRoutes';
|
||||
import { triggerSessionCheck } from './lib/proSession';
|
||||
import { MessageDialogHost } from './components/ui/MessageDialogHost';
|
||||
import './App.css';
|
||||
|
||||
/**
|
||||
|
|
@ -164,6 +165,9 @@ function App() {
|
|||
<Route path="/en/*" element={<EnPrefixRedirect />} />
|
||||
</Routes>
|
||||
</LocaleSync>
|
||||
{/* Global alert() replacement — opened from anywhere (React or plain
|
||||
.ts) via showMessageDialog() in store/useMessageDialogStore. */}
|
||||
<MessageDialogHost />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import type { BoardKind } from '../../types/board';
|
||||
import { boardDisplayName } from '../../types/board';
|
||||
import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject';
|
||||
import { showMessageDialog } from '../../store/useMessageDialogStore';
|
||||
import './FileExplorer.css';
|
||||
|
||||
// SVG icons — same style as EditorToolbar (stroke-based, 16x16)
|
||||
|
|
@ -284,7 +285,7 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
window.alert((err as Error).message);
|
||||
showMessageDialog((err as Error).message, { kind: 'error' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* MessageDialogHost — renders the global message dialog driven by
|
||||
* useMessageDialogStore (the replacement for window.alert()).
|
||||
*
|
||||
* Mounted once in App.tsx so it is available on every page, in web and
|
||||
* desktop builds, and to the pro overlay. Styling follows the dark modal
|
||||
* convention used by FlashModal / ShareModal.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useMessageDialogStore, type MessageDialogKind } from '../../store/useMessageDialogStore';
|
||||
|
||||
const ACCENTS: Record<MessageDialogKind, { bg: string; fg: string; icon: string }> = {
|
||||
info: { bg: '#16283a', fg: '#7cc4ff', icon: 'ℹ' },
|
||||
success: { bg: '#143824', fg: '#7ee87e', icon: '✓' },
|
||||
error: { bg: '#3a1a1a', fg: '#ff8585', icon: '⚠' },
|
||||
};
|
||||
|
||||
export const MessageDialogHost = () => {
|
||||
const { open, kind, title, message, close } = useMessageDialogStore();
|
||||
const okRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Focus OK so Enter dismisses, matching the native alert() flow.
|
||||
okRef.current?.focus();
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, close]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const accent = ACCENTS[kind];
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={close}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
zIndex: 9700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 440,
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: 'calc(100vh - 64px)',
|
||||
background: '#1a1d24',
|
||||
color: '#e6e6e9',
|
||||
border: '1px solid #2c2c33',
|
||||
borderRadius: 8,
|
||||
padding: 20,
|
||||
boxShadow: '0 12px 36px rgba(0,0,0,0.7)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 14,
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif',
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<h2 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>{title}</h2>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
alignItems: 'flex-start',
|
||||
padding: 12,
|
||||
background: accent.bg,
|
||||
color: accent.fg,
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden style={{ fontSize: 15, lineHeight: '19px' }}>
|
||||
{accent.icon}
|
||||
</span>
|
||||
<span style={{ whiteSpace: 'pre-wrap', overflowY: 'auto' }}>{message}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
ref={okRef}
|
||||
type="button"
|
||||
onClick={close}
|
||||
style={{
|
||||
padding: '7px 20px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
background: 'linear-gradient(135deg, #007acc 0%, #005ea1 100%)',
|
||||
border: '1px solid #005ea1',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
|
@ -29,6 +29,7 @@ import { useSimulatorStore } from '../store/useSimulatorStore';
|
|||
import { useEditorStore } from '../store/useEditorStore';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
import { useCompileLogsStore } from '../store/useCompileLogsStore';
|
||||
import { showMessageDialog } from '../store/useMessageDialogStore';
|
||||
import { switchLocale } from '../i18n/path';
|
||||
import { LOCALES, type Locale } from '../i18n/config';
|
||||
|
||||
|
|
@ -148,8 +149,9 @@ function pickAndImportVlx(): void {
|
|||
try {
|
||||
await importVlxFile(file);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Failed to open .vlx: ${(err as Error).message}`);
|
||||
showMessageDialog(`Failed to open .vlx: ${(err as Error).message}`, {
|
||||
kind: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
document.body.removeChild(input);
|
||||
|
|
@ -181,7 +183,6 @@ function newProject(): void {
|
|||
project.currentProject !== null;
|
||||
|
||||
if (hasWork) {
|
||||
// eslint-disable-next-line no-alert
|
||||
const ok = window.confirm(
|
||||
'Start a new project? Any unsaved changes will be lost.',
|
||||
);
|
||||
|
|
@ -230,19 +231,18 @@ async function checkForUpdates(): Promise<void> {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const updater = (window as any).__TAURI__?.updater;
|
||||
if (!updater?.check) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert('Update plugin not available in this build.');
|
||||
showMessageDialog('Update plugin not available in this build.');
|
||||
return;
|
||||
}
|
||||
const update = await updater.check();
|
||||
if (update) {
|
||||
await update.downloadAndInstall();
|
||||
} else {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert('Velxio Desktop is up to date.');
|
||||
showMessageDialog('Velxio Desktop is up to date.', { kind: 'success' });
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Update check failed: ${(err as Error).message}`);
|
||||
showMessageDialog(`Update check failed: ${(err as Error).message}`, {
|
||||
kind: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Global message dialog store — the in-app replacement for window.alert().
|
||||
*
|
||||
* Any code can open the dialog:
|
||||
* - React components: `showMessageDialog('...', { kind: 'error' })`
|
||||
* - Plain .ts modules (desktop menu handlers, services): same call —
|
||||
* zustand stores work outside React via getState().
|
||||
*
|
||||
* The dialog itself is rendered by <MessageDialogHost />, mounted once in
|
||||
* App.tsx. The pro overlay reuses this store via `@velxio/store/...`.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type MessageDialogKind = 'info' | 'success' | 'error';
|
||||
|
||||
export interface MessageDialogOptions {
|
||||
kind?: MessageDialogKind;
|
||||
/** Optional header line. Callers pass an already-translated string. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface MessageDialogState {
|
||||
open: boolean;
|
||||
kind: MessageDialogKind;
|
||||
title: string | null;
|
||||
message: string;
|
||||
show: (message: string, opts?: MessageDialogOptions) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useMessageDialogStore = create<MessageDialogState>((set) => ({
|
||||
open: false,
|
||||
kind: 'info',
|
||||
title: null,
|
||||
message: '',
|
||||
show: (message, opts) =>
|
||||
set({
|
||||
open: true,
|
||||
message,
|
||||
kind: opts?.kind ?? 'info',
|
||||
title: opts?.title ?? null,
|
||||
}),
|
||||
close: () => set({ open: false }),
|
||||
}));
|
||||
|
||||
/** Imperative helper so non-React callers don't need to know zustand. */
|
||||
export function showMessageDialog(message: string, opts?: MessageDialogOptions): void {
|
||||
useMessageDialogStore.getState().show(message, opts);
|
||||
}
|
||||
Loading…
Reference in New Issue