diff --git a/frontend/src/components/editor/FileExplorer.tsx b/frontend/src/components/editor/FileExplorer.tsx index c6029068..834782a4 100644 --- a/frontend/src/components/editor/FileExplorer.tsx +++ b/frontend/src/components/editor/FileExplorer.tsx @@ -11,7 +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 { showMessageDialog, showConfirmDialog } from '../../store/useMessageDialogStore'; import './FileExplorer.css'; // SVG icons — same style as EditorToolbar (stroke-based, 16x16) @@ -237,6 +237,7 @@ interface FileExplorerProps { } export const FileExplorer: React.FC = ({ onSaveClick, onNewClick }) => { + const { t } = useTranslation(); // Hidden we trigger via ref when the user clicks // the Open project button. Accepts both .vlx (Velxio native) and .zip // (Wokwi bundle); the dispatcher in utils/importProject.ts decides which @@ -252,14 +253,17 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl e.target.value = ''; if (!file) return; const friendlyName = file.name.toLowerCase().endsWith('.zip') ? 'Wokwi .zip' : '.vlx'; - if ( - !window.confirm( - `Load this ${friendlyName} project? Your current workspace will be replaced. ` + - `This cannot be undone.`, - ) - ) { - return; - } + const confirmed = await showConfirmDialog( + t('editor.fileExplorer.confirmLoad.message', { type: friendlyName }), + { + kind: 'error', + title: t('editor.fileExplorer.confirmLoad.title'), + confirmLabel: t('editor.fileExplorer.confirmLoad.confirm'), + cancelLabel: t('editor.fileTabs.cancel'), + danger: true, + }, + ); + if (!confirmed) return; try { const result = await importProjectFile(file); // .zip needs the caller to apply the payload to the stores (we keep @@ -287,9 +291,8 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl } catch (err) { showMessageDialog((err as Error).message, { kind: 'error' }); } - }, []); + }, [t]); - const { t } = useTranslation(); const { fileGroups, activeFileId, @@ -503,11 +506,18 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl setRenamingId(null); }, [renamingId, renameValue, renameFile]); - const handleDelete = (fileId: string, groupId: string) => { + const handleDelete = async (fileId: string, groupId: string) => { setContextMenu(null); const files = fileGroups[groupId] ?? []; if (files.length <= 1) return; - if (!window.confirm(t('editor.fileExplorer.confirmDelete'))) return; + const confirmed = await showConfirmDialog(t('editor.fileExplorer.confirmDelete'), { + kind: 'error', + title: t('editor.fileExplorer.contextMenu.delete'), + confirmLabel: t('editor.fileExplorer.contextMenu.delete'), + cancelLabel: t('editor.fileTabs.cancel'), + danger: true, + }); + if (!confirmed) return; deleteFile(fileId); }; diff --git a/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx b/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx index f9b284d2..3c221fe3 100644 --- a/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx +++ b/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx @@ -5,9 +5,11 @@ */ import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { useVfsStore } from '../../store/useVfsStore'; import type { VfsNode } from '../../store/useVfsStore'; import { getBoardBridge, useSimulatorStore } from '../../store/useSimulatorStore'; +import { showConfirmDialog } from '../../store/useMessageDialogStore'; /** Resolve true once the board's guest Linux has booted to a shell * (board.piBooted), or false after timeoutMs. Polls the store. */ @@ -244,6 +246,7 @@ interface VirtualFileSystemProps { } export const VirtualFileSystem: React.FC = ({ boardId, onFileSelect }) => { + const { t } = useTranslation(); const { initBoardVfs, getRootId, @@ -335,11 +338,21 @@ export const VirtualFileSystem: React.FC = ({ boardId, o setNewNodeName(''); }, [boardId, creatingIn, newNodeName, newNodeType, createNode]); - const handleDelete = (nodeId: string) => { + const handleDelete = async (nodeId: string) => { setCtxMenu(null); const node = getNode(boardId, nodeId); if (!node || node.parentId === null) return; - if (!window.confirm(`Delete "${node.name}"?`)) return; + const confirmed = await showConfirmDialog( + t('editor.pi.confirmDelete.message', { name: node.name }), + { + kind: 'error', + title: t('editor.pi.confirmDelete.title'), + confirmLabel: t('editor.pi.confirmDelete.confirm'), + cancelLabel: t('editor.pi.confirmDelete.cancel'), + danger: true, + }, + ); + if (!confirmed) return; if (selectedNodeId === nodeId) setSelectedNode(boardId, null); deleteNode(boardId, nodeId); }; diff --git a/frontend/src/components/simulator/BoardOptionsModal.tsx b/frontend/src/components/simulator/BoardOptionsModal.tsx index 0b68e5b8..327418fc 100644 --- a/frontend/src/components/simulator/BoardOptionsModal.tsx +++ b/frontend/src/components/simulator/BoardOptionsModal.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import type { BoardKind } from '../../types/board'; import { BOARD_KIND_LABELS } from '../../types/board'; import type { @@ -19,6 +20,7 @@ import { boardSupportsPsram, getDefaultOptionsForKind, } from '../../types/boardOptions'; +import { showConfirmDialog } from '../../store/useMessageDialogStore'; import './BoardOptionsModal.css'; interface BoardOptionsModalProps { @@ -80,6 +82,7 @@ export const BoardOptionsModal = ({ onApply, onSpiffsChange, }: BoardOptionsModalProps) => { + const { t } = useTranslation(); const seed = useMemo( () => currentOptions ?? getDefaultOptionsForKind(boardKind), [currentOptions, boardKind], @@ -121,8 +124,15 @@ export const BoardOptionsModal = ({ const next: SpiffsFile[] = [...spiffsFiles]; for (const f of Array.from(files)) { if (next.some((existing) => existing.name === f.name)) { - const overwrite = window.confirm( - `A file named "${f.name}" already exists. Overwrite?`, + const overwrite = await showConfirmDialog( + t('editor.boardOptions.confirmOverwrite.message', { name: f.name }), + { + kind: 'error', + title: t('editor.boardOptions.confirmOverwrite.title'), + confirmLabel: t('editor.boardOptions.confirmOverwrite.confirm'), + cancelLabel: t('editor.boardOptions.confirmOverwrite.cancel'), + danger: true, + }, ); if (!overwrite) continue; } diff --git a/frontend/src/components/ui/MessageDialogHost.tsx b/frontend/src/components/ui/MessageDialogHost.tsx index e8e34a1c..40511a99 100644 --- a/frontend/src/components/ui/MessageDialogHost.tsx +++ b/frontend/src/components/ui/MessageDialogHost.tsx @@ -18,15 +18,18 @@ const ACCENTS: Record { - const { open, kind, title, message, close } = useMessageDialogStore(); + const { open, mode, kind, title, message, confirmLabel, cancelLabel, danger, close } = + useMessageDialogStore(); const okRef = useRef(null); + const isConfirm = mode === 'confirm'; useEffect(() => { if (!open) return; - // Focus OK so Enter dismisses, matching the native alert() flow. + // Focus the primary button so Enter confirms/dismisses, matching the + // native alert()/confirm() flow. okRef.current?.focus(); const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') close(); + if (e.key === 'Escape') close(false); }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); @@ -40,7 +43,7 @@ export const MessageDialogHost = () => {
close(false)} style={{ position: 'fixed', inset: 0, @@ -92,24 +95,45 @@ export const MessageDialogHost = () => { {message}
-
+
+ {isConfirm && ( + + )}
diff --git a/frontend/src/i18n/locales/de/common2.json b/frontend/src/i18n/locales/de/common2.json index cbc524ee..dd7f5fbc 100644 --- a/frontend/src/i18n/locales/de/common2.json +++ b/frontend/src/i18n/locales/de/common2.json @@ -36,6 +36,17 @@ "fileExplorer": { "workspace": "ARBEITSBEREICH", "newWorkspace": "Neuer Arbeitsbereich (löscht Boards, Komponenten, Leitungen und Dateien)", + "confirmNew": { + "title": "Neuen Arbeitsbereich starten?", + "message": "Dies löscht alle Boards, Komponenten, Leitungen und Dateien. Dies kann nicht rückgängig gemacht werden.", + "confirm": "Neu starten", + "cancel": "Abbrechen" + }, + "confirmLoad": { + "title": "Projekt laden?", + "message": "Dieses {{type}}-Projekt laden? Dein aktueller Arbeitsbereich wird ersetzt. Dies kann nicht rückgängig gemacht werden.", + "confirm": "Laden" + }, "saveProject": "Projekt speichern (Strg+S)", "clickToEdit": "zum Bearbeiten klicken", "collapse": "Einklappen", @@ -267,6 +278,14 @@ "rotate": "Drehen", "delete": "Löschen" }, + "boardOptions": { + "confirmOverwrite": { + "title": "Datei überschreiben?", + "message": "Eine Datei namens \"{{name}}\" existiert bereits. Überschreiben?", + "confirm": "Überschreiben", + "cancel": "Überspringen" + } + }, "pi": { "connected": "Verbunden", "starting": "Starte…", @@ -286,6 +305,12 @@ "offlineNote1": "Hinweis: Der Dateiexplorer zeigt einen Staging-Bereich an.", "offlineNote2": "„Auf den Pi hochladen“ überträgt diese Dateien auf das laufende System und startet den Pi bei Bedarf automatisch.", "loadingTerminal": "Terminal wird geladen…", + "confirmDelete": { + "title": "Löschen", + "message": "\"{{name}}\" löschen?", + "confirm": "Löschen", + "cancel": "Abbrechen" + }, "selectFile": "Wählen Sie eine Datei aus dem Explorer aus, um sie zu bearbeiten." }, "sensorPanel": { diff --git a/frontend/src/i18n/locales/en/common2.json b/frontend/src/i18n/locales/en/common2.json index 45636a1a..422ee8e7 100644 --- a/frontend/src/i18n/locales/en/common2.json +++ b/frontend/src/i18n/locales/en/common2.json @@ -38,6 +38,17 @@ "fileExplorer": { "workspace": "WORKSPACE", "newWorkspace": "New workspace (clears boards, components, wires and files)", + "confirmNew": { + "title": "Start a new workspace?", + "message": "This clears every board, component, wire and file. This cannot be undone.", + "confirm": "Start new", + "cancel": "Cancel" + }, + "confirmLoad": { + "title": "Load project?", + "message": "Load this {{type}} project? Your current workspace will be replaced. This cannot be undone.", + "confirm": "Load" + }, "saveProject": "Save project (Ctrl+S)", "clickToEdit": "click to edit", "collapse": "Collapse", @@ -269,6 +280,14 @@ "rotate": "Rotate", "delete": "Delete" }, + "boardOptions": { + "confirmOverwrite": { + "title": "Overwrite file?", + "message": "A file named \"{{name}}\" already exists. Overwrite?", + "confirm": "Overwrite", + "cancel": "Skip" + } + }, "pi": { "connected": "Connected", "starting": "Starting…", @@ -288,6 +307,12 @@ "offlineNote1": "Note: The file explorer shows a staging area.", "offlineNote2": "\"Upload to Pi\" sends these files to the running system, starting the Pi automatically if needed.", "loadingTerminal": "Loading terminal…", + "confirmDelete": { + "title": "Delete", + "message": "Delete \"{{name}}\"?", + "confirm": "Delete", + "cancel": "Cancel" + }, "selectFile": "Select a file from the explorer to edit it." }, "sensorPanel": { diff --git a/frontend/src/i18n/locales/es/common2.json b/frontend/src/i18n/locales/es/common2.json index e32ea7ad..f845cf99 100644 --- a/frontend/src/i18n/locales/es/common2.json +++ b/frontend/src/i18n/locales/es/common2.json @@ -38,6 +38,17 @@ "fileExplorer": { "workspace": "ESPACIO DE TRABAJO", "newWorkspace": "Nuevo espacio de trabajo (borra placas, componentes, cables y archivos)", + "confirmNew": { + "title": "¿Iniciar un nuevo espacio de trabajo?", + "message": "Esto borra todas las placas, componentes, cables y archivos. No se puede deshacer.", + "confirm": "Empezar de nuevo", + "cancel": "Cancelar" + }, + "confirmLoad": { + "title": "¿Cargar proyecto?", + "message": "¿Cargar este proyecto {{type}}? Tu espacio de trabajo actual será reemplazado. No se puede deshacer.", + "confirm": "Cargar" + }, "saveProject": "Guardar proyecto (Ctrl+S)", "clickToEdit": "clic para editar", "collapse": "Contraer", @@ -269,6 +280,14 @@ "rotate": "Rotar", "delete": "Eliminar" }, + "boardOptions": { + "confirmOverwrite": { + "title": "¿Sobrescribir archivo?", + "message": "Ya existe un archivo llamado \"{{name}}\". ¿Sobrescribir?", + "confirm": "Sobrescribir", + "cancel": "Omitir" + } + }, "pi": { "connected": "Conectado", "starting": "Iniciando…", @@ -288,6 +307,12 @@ "offlineNote1": "Nota: El explorador de archivos muestra un área de preparación.", "offlineNote2": "\"Subir al Pi\" envía estos archivos al sistema en marcha; arranca el Pi automáticamente si hace falta.", "loadingTerminal": "Cargando terminal…", + "confirmDelete": { + "title": "Eliminar", + "message": "¿Eliminar \"{{name}}\"?", + "confirm": "Eliminar", + "cancel": "Cancelar" + }, "selectFile": "Selecciona un archivo del explorador para editarlo." }, "sensorPanel": { diff --git a/frontend/src/i18n/locales/fr/common2.json b/frontend/src/i18n/locales/fr/common2.json index 7430596b..c51d2783 100644 --- a/frontend/src/i18n/locales/fr/common2.json +++ b/frontend/src/i18n/locales/fr/common2.json @@ -36,6 +36,17 @@ "fileExplorer": { "workspace": "ESPACE DE TRAVAIL", "newWorkspace": "Nouvel espace de travail (efface cartes, composants, fils et fichiers)", + "confirmNew": { + "title": "Démarrer un nouvel espace de travail ?", + "message": "Ceci efface toutes les cartes, composants, fils et fichiers. Cette action est irréversible.", + "confirm": "Recommencer", + "cancel": "Annuler" + }, + "confirmLoad": { + "title": "Charger le projet ?", + "message": "Charger ce projet {{type}} ? Votre espace de travail actuel sera remplacé. Cette action est irréversible.", + "confirm": "Charger" + }, "saveProject": "Enregistrer le projet (Ctrl+S)", "clickToEdit": "cliquer pour éditer", "collapse": "Réduire", @@ -267,6 +278,14 @@ "rotate": "Pivoter", "delete": "Supprimer" }, + "boardOptions": { + "confirmOverwrite": { + "title": "Écraser le fichier ?", + "message": "Un fichier nommé « {{name}} » existe déjà. L'écraser ?", + "confirm": "Écraser", + "cancel": "Ignorer" + } + }, "pi": { "connected": "Connecté", "starting": "Démarrage…", @@ -286,6 +305,12 @@ "offlineNote1": "Remarque : L'explorateur de fichiers affiche une zone de préparation.", "offlineNote2": "« Envoyer vers le Pi » transfère ces fichiers vers le système en cours d'exécution et démarre le Pi automatiquement si nécessaire.", "loadingTerminal": "Chargement du terminal…", + "confirmDelete": { + "title": "Supprimer", + "message": "Supprimer « {{name}} » ?", + "confirm": "Supprimer", + "cancel": "Annuler" + }, "selectFile": "Sélectionnez un fichier dans l'explorateur pour l'éditer." }, "sensorPanel": { diff --git a/frontend/src/i18n/locales/it/common2.json b/frontend/src/i18n/locales/it/common2.json index 625a68da..d9973214 100644 --- a/frontend/src/i18n/locales/it/common2.json +++ b/frontend/src/i18n/locales/it/common2.json @@ -36,6 +36,17 @@ "fileExplorer": { "workspace": "WORKSPACE", "newWorkspace": "Nuovo workspace (cancella schede, componenti, cavi e file)", + "confirmNew": { + "title": "Avviare un nuovo workspace?", + "message": "Questo cancella tutte le schede, i componenti, i cavi e i file. Non può essere annullato.", + "confirm": "Ricomincia", + "cancel": "Annulla" + }, + "confirmLoad": { + "title": "Caricare il progetto?", + "message": "Caricare questo progetto {{type}}? Il tuo workspace attuale sarà sostituito. Non può essere annullato.", + "confirm": "Carica" + }, "saveProject": "Salva progetto (Ctrl+S)", "clickToEdit": "clicca per modificare", "collapse": "Comprimi", @@ -267,6 +278,14 @@ "rotate": "Ruota", "delete": "Elimina" }, + "boardOptions": { + "confirmOverwrite": { + "title": "Sovrascrivere il file?", + "message": "Esiste già un file di nome \"{{name}}\". Sovrascrivere?", + "confirm": "Sovrascrivi", + "cancel": "Salta" + } + }, "pi": { "connected": "Connesso", "starting": "Avvio…", @@ -286,6 +305,12 @@ "offlineNote1": "Nota: L'esplora file mostra un'area di staging.", "offlineNote2": "\"Carica sul Pi\" invia questi file al sistema in esecuzione e avvia il Pi automaticamente se necessario.", "loadingTerminal": "Caricamento terminale…", + "confirmDelete": { + "title": "Elimina", + "message": "Eliminare \"{{name}}\"?", + "confirm": "Elimina", + "cancel": "Annulla" + }, "selectFile": "Seleziona un file dall'esplora per modificarlo." }, "sensorPanel": { diff --git a/frontend/src/i18n/locales/ja/common2.json b/frontend/src/i18n/locales/ja/common2.json index 764b657e..26f48980 100644 --- a/frontend/src/i18n/locales/ja/common2.json +++ b/frontend/src/i18n/locales/ja/common2.json @@ -36,6 +36,17 @@ "fileExplorer": { "workspace": "ワークスペース", "newWorkspace": "新規ワークスペース (ボード、コンポーネント、配線、ファイルをクリア)", + "confirmNew": { + "title": "新しいワークスペースを開始しますか?", + "message": "すべてのボード、コンポーネント、配線、ファイルが消去されます。この操作は元に戻せません。", + "confirm": "新規開始", + "cancel": "キャンセル" + }, + "confirmLoad": { + "title": "プロジェクトを読み込みますか?", + "message": "この {{type}} プロジェクトを読み込みますか?現在のワークスペースが置き換えられます。この操作は元に戻せません。", + "confirm": "読み込む" + }, "saveProject": "プロジェクトを保存 (Ctrl+S)", "clickToEdit": "クリックして編集", "collapse": "折りたたむ", @@ -267,6 +278,14 @@ "rotate": "回転", "delete": "削除" }, + "boardOptions": { + "confirmOverwrite": { + "title": "ファイルを上書きしますか?", + "message": "\"{{name}}\" という名前のファイルは既に存在します。上書きしますか?", + "confirm": "上書き", + "cancel": "スキップ" + } + }, "pi": { "connected": "接続済み", "starting": "起動中…", @@ -286,6 +305,12 @@ "offlineNote1": "注: ファイルエクスプローラはステージング領域を表示します。", "offlineNote2": "「Pi にアップロード」は実行中のシステムにこれらのファイルを転送します。必要に応じて Pi を自動的に起動します。", "loadingTerminal": "端末を読み込み中…", + "confirmDelete": { + "title": "削除", + "message": "\"{{name}}\" を削除しますか?", + "confirm": "削除", + "cancel": "キャンセル" + }, "selectFile": "エクスプローラからファイルを選択して編集します。" }, "sensorPanel": { diff --git a/frontend/src/i18n/locales/pt-br/common2.json b/frontend/src/i18n/locales/pt-br/common2.json index c64ad49f..93ea34f3 100644 --- a/frontend/src/i18n/locales/pt-br/common2.json +++ b/frontend/src/i18n/locales/pt-br/common2.json @@ -36,6 +36,17 @@ "fileExplorer": { "workspace": "WORKSPACE", "newWorkspace": "Novo workspace (limpa placas, componentes, fios e arquivos)", + "confirmNew": { + "title": "Iniciar um novo workspace?", + "message": "Isso limpa todas as placas, componentes, fios e arquivos. Não pode ser desfeito.", + "confirm": "Começar novo", + "cancel": "Cancelar" + }, + "confirmLoad": { + "title": "Carregar projeto?", + "message": "Carregar este projeto {{type}}? Seu workspace atual será substituído. Não pode ser desfeito.", + "confirm": "Carregar" + }, "saveProject": "Salvar projeto (Ctrl+S)", "clickToEdit": "clique para editar", "collapse": "Recolher", @@ -267,6 +278,14 @@ "rotate": "Girar", "delete": "Excluir" }, + "boardOptions": { + "confirmOverwrite": { + "title": "Substituir arquivo?", + "message": "Já existe um arquivo chamado \"{{name}}\". Substituir?", + "confirm": "Substituir", + "cancel": "Ignorar" + } + }, "pi": { "connected": "Conectado", "starting": "Iniciando…", @@ -286,6 +305,12 @@ "offlineNote1": "Nota: O explorador de arquivos mostra uma área de preparação.", "offlineNote2": "\"Enviar para o Pi\" transfere esses arquivos para o sistema em execução e inicia o Pi automaticamente se necessário.", "loadingTerminal": "Carregando terminal…", + "confirmDelete": { + "title": "Excluir", + "message": "Excluir \"{{name}}\"?", + "confirm": "Excluir", + "cancel": "Cancelar" + }, "selectFile": "Selecione um arquivo no explorador para editá-lo." }, "sensorPanel": { diff --git a/frontend/src/i18n/locales/ru/common2.json b/frontend/src/i18n/locales/ru/common2.json index 78c69598..357c4544 100644 --- a/frontend/src/i18n/locales/ru/common2.json +++ b/frontend/src/i18n/locales/ru/common2.json @@ -36,6 +36,17 @@ "fileExplorer": { "workspace": "РАБОЧАЯ ОБЛАСТЬ", "newWorkspace": "Новая рабочая область (очищает платы, компоненты, провода и файлы)", + "confirmNew": { + "title": "Начать новую рабочую область?", + "message": "Это очистит все платы, компоненты, провода и файлы. Это действие нельзя отменить.", + "confirm": "Начать заново", + "cancel": "Отмена" + }, + "confirmLoad": { + "title": "Загрузить проект?", + "message": "Загрузить этот проект {{type}}? Ваша текущая рабочая область будет заменена. Это действие нельзя отменить.", + "confirm": "Загрузить" + }, "saveProject": "Сохранить проект (Ctrl+S)", "clickToEdit": "нажмите для редактирования", "collapse": "Свернуть", @@ -267,6 +278,14 @@ "rotate": "Повернуть", "delete": "Удалить" }, + "boardOptions": { + "confirmOverwrite": { + "title": "Перезаписать файл?", + "message": "Файл с именем \"{{name}}\" уже существует. Перезаписать?", + "confirm": "Перезаписать", + "cancel": "Пропустить" + } + }, "pi": { "connected": "Подключено", "starting": "Запуск…", @@ -286,6 +305,12 @@ "offlineNote1": "Примечание: Проводник файлов показывает область подготовки.", "offlineNote2": "«Загрузить на Pi» отправляет эти файлы в работающую систему и при необходимости запускает Pi автоматически.", "loadingTerminal": "Загрузка терминала…", + "confirmDelete": { + "title": "Удалить", + "message": "Удалить \"{{name}}\"?", + "confirm": "Удалить", + "cancel": "Отмена" + }, "selectFile": "Выберите файл в проводнике для редактирования." }, "sensorPanel": { diff --git a/frontend/src/i18n/locales/zh-cn/common2.json b/frontend/src/i18n/locales/zh-cn/common2.json index 87ad5bb3..ec0d4894 100644 --- a/frontend/src/i18n/locales/zh-cn/common2.json +++ b/frontend/src/i18n/locales/zh-cn/common2.json @@ -36,6 +36,17 @@ "fileExplorer": { "workspace": "工作区", "newWorkspace": "新建工作区 (清除板、元件、导线和文件)", + "confirmNew": { + "title": "开始新的工作区?", + "message": "这将清除所有板、元件、导线和文件。此操作无法撤销。", + "confirm": "新建", + "cancel": "取消" + }, + "confirmLoad": { + "title": "加载项目?", + "message": "加载此 {{type}} 项目?您当前的工作区将被替换。此操作无法撤销。", + "confirm": "加载" + }, "saveProject": "保存项目 (Ctrl+S)", "clickToEdit": "点击编辑", "collapse": "折叠", @@ -267,6 +278,14 @@ "rotate": "旋转", "delete": "删除" }, + "boardOptions": { + "confirmOverwrite": { + "title": "覆盖文件?", + "message": "已存在名为 \"{{name}}\" 的文件。是否覆盖?", + "confirm": "覆盖", + "cancel": "跳过" + } + }, "pi": { "connected": "已连接", "starting": "启动中…", @@ -286,6 +305,12 @@ "offlineNote1": "注意:文件浏览器显示暂存区域。", "offlineNote2": "“上传到 Pi”会将这些文件传输到正在运行的系统,并在需要时自动启动 Pi。", "loadingTerminal": "加载终端中…", + "confirmDelete": { + "title": "删除", + "message": "删除 \"{{name}}\"?", + "confirm": "删除", + "cancel": "取消" + }, "selectFile": "从浏览器中选择一个文件进行编辑。" }, "sensorPanel": { diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 1715e38d..c614d4e4 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -29,6 +29,7 @@ import { useEditorStore } from '../store/useEditorStore'; import { useCompileLogsStore } from '../store/useCompileLogsStore'; import { useOscilloscopeStore } from '../store/useOscilloscopeStore'; import { useProjectStore } from '../store/useProjectStore'; +import { showConfirmDialog } from '../store/useMessageDialogStore'; import { useAutoSaveProject } from '../hooks/useAutoSaveProject'; import type { CompilationLog } from '../utils/compilationLogger'; import { isPiBoardKind } from '../types/board'; @@ -195,14 +196,18 @@ export const EditorPage: React.FC = () => { triggerSaveAction(); }, []); - const handleNewClick = useCallback(() => { - if ( - !window.confirm( - 'Start a new workspace? This clears every board, component, wire and file. This cannot be undone.', - ) - ) { - return; - } + const handleNewClick = useCallback(async () => { + const confirmed = await showConfirmDialog( + t('editor.fileExplorer.confirmNew.message'), + { + kind: 'error', + title: t('editor.fileExplorer.confirmNew.title'), + confirmLabel: t('editor.fileExplorer.confirmNew.confirm'), + cancelLabel: t('editor.fileExplorer.confirmNew.cancel'), + danger: true, + }, + ); + if (!confirmed) return; const sim = useSimulatorStore.getState(); sim.boards.forEach((b) => sim.stopBoard(b.id)); const ids = sim.boards.map((b) => b.id); @@ -214,7 +219,7 @@ export const EditorPage: React.FC = () => { .getState() .addBoard('arduino-uno', DEFAULT_BOARD_POSITION.x, DEFAULT_BOARD_POSITION.y); useSimulatorStore.getState().setActiveBoardId(newId); - }, []); + }, [t]); // Track mobile breakpoint useEffect(() => { diff --git a/frontend/src/store/useMessageDialogStore.ts b/frontend/src/store/useMessageDialogStore.ts index 22db7762..7c980124 100644 --- a/frontend/src/store/useMessageDialogStore.ts +++ b/frontend/src/store/useMessageDialogStore.ts @@ -14,37 +14,97 @@ import { create } from 'zustand'; export type MessageDialogKind = 'info' | 'success' | 'error'; +/** 'alert' shows a single OK button; 'confirm' shows Cancel + Confirm. */ +export type MessageDialogMode = 'alert' | 'confirm'; + export interface MessageDialogOptions { kind?: MessageDialogKind; /** Optional header line. Callers pass an already-translated string. */ title?: string; } +export interface ConfirmDialogOptions extends MessageDialogOptions { + /** Label for the confirming button. Callers pass an already-translated string. */ + confirmLabel?: string; + /** Label for the dismissing button. Callers pass an already-translated string. */ + cancelLabel?: string; + /** Style the confirm button as a destructive action (red). */ + danger?: boolean; +} + interface MessageDialogState { open: boolean; + mode: MessageDialogMode; kind: MessageDialogKind; title: string | null; message: string; + confirmLabel: string; + cancelLabel: string; + danger: boolean; + /** Set while a confirm dialog is open; called with the user's choice. */ + resolve: ((confirmed: boolean) => void) | null; show: (message: string, opts?: MessageDialogOptions) => void; - close: () => void; + confirm: (message: string, opts?: ConfirmDialogOptions) => Promise; + /** Resolve/close. `result` is only meaningful for confirm dialogs. */ + close: (result?: boolean) => void; } -export const useMessageDialogStore = create((set) => ({ +export const useMessageDialogStore = create((set, get) => ({ open: false, + mode: 'alert', kind: 'info', title: null, message: '', - show: (message, opts) => + confirmLabel: 'OK', + cancelLabel: 'Cancel', + danger: false, + resolve: null, + show: (message, opts) => { + // Reject any in-flight confirm before replacing it with an alert. + get().resolve?.(false); set({ open: true, + mode: 'alert', message, kind: opts?.kind ?? 'info', title: opts?.title ?? null, + resolve: null, + }); + }, + confirm: (message, opts) => + new Promise((resolve) => { + // Reject any in-flight confirm before replacing it. + get().resolve?.(false); + set({ + open: true, + mode: 'confirm', + message, + kind: opts?.kind ?? 'info', + title: opts?.title ?? null, + confirmLabel: opts?.confirmLabel ?? 'OK', + cancelLabel: opts?.cancelLabel ?? 'Cancel', + danger: opts?.danger ?? false, + resolve, + }); }), - close: () => set({ open: false }), + close: (result = false) => { + get().resolve?.(result); + set({ open: false, resolve: null }); + }, })); /** 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); } + +/** + * In-app replacement for window.confirm(). Returns a promise resolving to + * true when the user confirms, false when they cancel/dismiss. + */ +export function showConfirmDialog( + message: string, + opts?: ConfirmDialogOptions, +): Promise { + return useMessageDialogStore.getState().confirm(message, opts); +}