diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 32f0e3d4..ef8c262c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + {/* Global alert() replacement — opened from anywhere (React or plain + .ts) via showMessageDialog() in store/useMessageDialogStore. */} + ); } diff --git a/frontend/src/components/editor/FileExplorer.tsx b/frontend/src/components/editor/FileExplorer.tsx index 8ee4af9c..c6029068 100644 --- a/frontend/src/components/editor/FileExplorer.tsx +++ b/frontend/src/components/editor/FileExplorer.tsx @@ -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 = ({ onSaveClick, onNewCl } } } catch (err) { - window.alert((err as Error).message); + showMessageDialog((err as Error).message, { kind: 'error' }); } }, []); diff --git a/frontend/src/components/ui/MessageDialogHost.tsx b/frontend/src/components/ui/MessageDialogHost.tsx new file mode 100644 index 00000000..e8e34a1c --- /dev/null +++ b/frontend/src/components/ui/MessageDialogHost.tsx @@ -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 = { + 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(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( +
+
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 && ( +

{title}

+ )} + +
+ + {accent.icon} + + {message} +
+ +
+ +
+
+
, + document.body, + ); +}; diff --git a/frontend/src/desktop/menu.ts b/frontend/src/desktop/menu.ts index 4916e73e..97355647 100644 --- a/frontend/src/desktop/menu.ts +++ b/frontend/src/desktop/menu.ts @@ -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 { // 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', + }); } } diff --git a/frontend/src/store/useMessageDialogStore.ts b/frontend/src/store/useMessageDialogStore.ts new file mode 100644 index 00000000..22db7762 --- /dev/null +++ b/frontend/src/store/useMessageDialogStore.ts @@ -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 , 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((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); +}