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 { NON_DEFAULT_LOCALES } from './i18n/config';
|
||||||
import { useProRoutes } from './lib/proRoutes';
|
import { useProRoutes } from './lib/proRoutes';
|
||||||
import { triggerSessionCheck } from './lib/proSession';
|
import { triggerSessionCheck } from './lib/proSession';
|
||||||
|
import { MessageDialogHost } from './components/ui/MessageDialogHost';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -164,6 +165,9 @@ function App() {
|
||||||
<Route path="/en/*" element={<EnPrefixRedirect />} />
|
<Route path="/en/*" element={<EnPrefixRedirect />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</LocaleSync>
|
</LocaleSync>
|
||||||
|
{/* Global alert() replacement — opened from anywhere (React or plain
|
||||||
|
.ts) via showMessageDialog() in store/useMessageDialogStore. */}
|
||||||
|
<MessageDialogHost />
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
import type { BoardKind } from '../../types/board';
|
import type { BoardKind } from '../../types/board';
|
||||||
import { boardDisplayName } from '../../types/board';
|
import { boardDisplayName } from '../../types/board';
|
||||||
import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject';
|
import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject';
|
||||||
|
import { showMessageDialog } from '../../store/useMessageDialogStore';
|
||||||
import './FileExplorer.css';
|
import './FileExplorer.css';
|
||||||
|
|
||||||
// SVG icons — same style as EditorToolbar (stroke-based, 16x16)
|
// SVG icons — same style as EditorToolbar (stroke-based, 16x16)
|
||||||
|
|
@ -284,7 +285,7 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 { useEditorStore } from '../store/useEditorStore';
|
||||||
import { useProjectStore } from '../store/useProjectStore';
|
import { useProjectStore } from '../store/useProjectStore';
|
||||||
import { useCompileLogsStore } from '../store/useCompileLogsStore';
|
import { useCompileLogsStore } from '../store/useCompileLogsStore';
|
||||||
|
import { showMessageDialog } from '../store/useMessageDialogStore';
|
||||||
import { switchLocale } from '../i18n/path';
|
import { switchLocale } from '../i18n/path';
|
||||||
import { LOCALES, type Locale } from '../i18n/config';
|
import { LOCALES, type Locale } from '../i18n/config';
|
||||||
|
|
||||||
|
|
@ -148,8 +149,9 @@ function pickAndImportVlx(): void {
|
||||||
try {
|
try {
|
||||||
await importVlxFile(file);
|
await importVlxFile(file);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// eslint-disable-next-line no-alert
|
showMessageDialog(`Failed to open .vlx: ${(err as Error).message}`, {
|
||||||
alert(`Failed to open .vlx: ${(err as Error).message}`);
|
kind: 'error',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.body.removeChild(input);
|
document.body.removeChild(input);
|
||||||
|
|
@ -181,7 +183,6 @@ function newProject(): void {
|
||||||
project.currentProject !== null;
|
project.currentProject !== null;
|
||||||
|
|
||||||
if (hasWork) {
|
if (hasWork) {
|
||||||
// eslint-disable-next-line no-alert
|
|
||||||
const ok = window.confirm(
|
const ok = window.confirm(
|
||||||
'Start a new project? Any unsaved changes will be lost.',
|
'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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const updater = (window as any).__TAURI__?.updater;
|
const updater = (window as any).__TAURI__?.updater;
|
||||||
if (!updater?.check) {
|
if (!updater?.check) {
|
||||||
// eslint-disable-next-line no-alert
|
showMessageDialog('Update plugin not available in this build.');
|
||||||
alert('Update plugin not available in this build.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const update = await updater.check();
|
const update = await updater.check();
|
||||||
if (update) {
|
if (update) {
|
||||||
await update.downloadAndInstall();
|
await update.downloadAndInstall();
|
||||||
} else {
|
} else {
|
||||||
// eslint-disable-next-line no-alert
|
showMessageDialog('Velxio Desktop is up to date.', { kind: 'success' });
|
||||||
alert('Velxio Desktop is up to date.');
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// eslint-disable-next-line no-alert
|
showMessageDialog(`Update check failed: ${(err as Error).message}`, {
|
||||||
alert(`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