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:
David Montero Crespo 2026-07-17 06:06:28 +02:00
parent 17e9e5a8c2
commit 9826737831
5 changed files with 184 additions and 10 deletions

View File

@ -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>
); );
} }

View File

@ -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' });
} }
}, []); }, []);

View File

@ -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,
);
};

View File

@ -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',
});
} }
} }

View File

@ -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);
}