From 85d32e64df0f4d22bd7c2d6b3a51275c09813f72 Mon Sep 17 00:00:00 2001 From: Joanna Lau <118241363+sunray4@users.noreply.github.com> Date: Thu, 20 Nov 2025 00:48:04 -0500 Subject: [PATCH] feat: read-only mode (#168) * add react router * add share button * working version * loaded diagram metadata and avoid redunant code * add multilingual support * remove styling * remove diagram storage * change failed load page to alert * remove loading condition * remove unused translations * prevent interactive mode from flashing up during readonly redirect * improve check for readonly --- package-lock.json | 88 ++- packages/fossflow-app/package.json | 1 + .../fossflow-app/public/i18n/app/bn-BD.json | 4 + .../fossflow-app/public/i18n/app/en-US.json | 4 + .../fossflow-app/public/i18n/app/es-ES.json | 4 + .../fossflow-app/public/i18n/app/fr-FR.json | 4 + .../fossflow-app/public/i18n/app/hi-IN.json | 4 + .../fossflow-app/public/i18n/app/it-IT.json | 4 + .../fossflow-app/public/i18n/app/pt-BR.json | 4 + .../fossflow-app/public/i18n/app/ru-RU.json | 4 + .../fossflow-app/public/i18n/app/zh-CN.json | 4 + packages/fossflow-app/src/App.tsx | 592 +++++++++++++----- .../src/components/DiagramManager.css | 9 + .../src/components/DiagramManager.tsx | 153 +++-- packages/fossflow-app/src/i18n.ts | 26 +- packages/fossflow-app/src/i18n/bn-BD.json | 4 + packages/fossflow-app/src/i18n/en-US.json | 4 + packages/fossflow-app/src/i18n/es-ES.json | 4 + packages/fossflow-app/src/i18n/fr-FR.json | 4 + packages/fossflow-app/src/i18n/hi-IN.json | 4 + packages/fossflow-app/src/i18n/it-IT.json | 4 + packages/fossflow-app/src/i18n/pt-BR.json | 4 + packages/fossflow-app/src/i18n/ru-RU.json | 4 + packages/fossflow-app/src/i18n/zh-CN.json | 4 + 24 files changed, 691 insertions(+), 250 deletions(-) diff --git a/package-lock.json b/package-lock.json index a1211f5..1d140f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3703,9 +3703,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -17365,35 +17365,50 @@ } }, "node_modules/react-router": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", + "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-router-dom": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", + "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.1" + "react-router": "7.9.6" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/react-smooth": { @@ -18263,6 +18278,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -20917,6 +20938,7 @@ "react-dom": "^18.3.1", "react-error-boundary": "^6.0.0", "react-i18next": "^15.7.4", + "react-router-dom": "^7.9.6", "web-vitals": "^2.1.4" }, "devDependencies": { @@ -21563,6 +21585,38 @@ "node": ">=8" } }, + "packages/fossflow-lib/node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "packages/fossflow-lib/node_modules/react-router-dom/node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, "packages/fossflow-lib/node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/packages/fossflow-app/package.json b/packages/fossflow-app/package.json index 591e2cc..4cb6c2a 100644 --- a/packages/fossflow-app/package.json +++ b/packages/fossflow-app/package.json @@ -13,6 +13,7 @@ "react-dom": "^18.3.1", "react-error-boundary": "^6.0.0", "react-i18next": "^15.7.4", + "react-router-dom": "^7.9.6", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/packages/fossflow-app/public/i18n/app/bn-BD.json b/packages/fossflow-app/public/i18n/app/bn-BD.json index 684e5a0..40f3d1a 100644 --- a/packages/fossflow-app/public/i18n/app/bn-BD.json +++ b/packages/fossflow-app/public/i18n/app/bn-BD.json @@ -41,6 +41,10 @@ "noteMessage": "রপ্তানি করা JSON ফাইলগুলি পরে আমদানি করা যেতে পারে বা অন্যদের সাথে শেয়ার করা যেতে পারে।", "btnDownload": "JSON ডাউনলোড করুন", "btnCancel": "বাতিল করুন" + }, + "readOnly": { + "mode": "শুধুমাত্র দেখার মোড", + "failed": "ডায়াগ্রাম লোড করতে ব্যর্থ" } }, "alert": { diff --git a/packages/fossflow-app/public/i18n/app/en-US.json b/packages/fossflow-app/public/i18n/app/en-US.json index 1e19a3d..ffce153 100644 --- a/packages/fossflow-app/public/i18n/app/en-US.json +++ b/packages/fossflow-app/public/i18n/app/en-US.json @@ -41,6 +41,10 @@ "noteMessage": "Exported JSON files can be imported later or shared with others.", "btnDownload": "Download JSON", "btnCancel": "Cancel" + }, + "readOnly": { + "mode": "View-Only Mode", + "failed": "Failed to load diagram" } }, "alert": { diff --git a/packages/fossflow-app/public/i18n/app/es-ES.json b/packages/fossflow-app/public/i18n/app/es-ES.json index b239b00..850cb69 100644 --- a/packages/fossflow-app/public/i18n/app/es-ES.json +++ b/packages/fossflow-app/public/i18n/app/es-ES.json @@ -41,6 +41,10 @@ "noteMessage": "Los archivos JSON exportados pueden importarse posteriormente o compartirse con otros.", "btnDownload": "Descargar JSON", "btnCancel": "Cancelar" + }, + "readOnly": { + "mode": "Modo de solo lectura", + "failed": "Error al cargar el diagrama" } }, "alert": { diff --git a/packages/fossflow-app/public/i18n/app/fr-FR.json b/packages/fossflow-app/public/i18n/app/fr-FR.json index 1aee5cf..57c72ac 100644 --- a/packages/fossflow-app/public/i18n/app/fr-FR.json +++ b/packages/fossflow-app/public/i18n/app/fr-FR.json @@ -41,6 +41,10 @@ "noteMessage": "Les fichiers JSON exportés peuvent être importés ultérieurement ou partagés avec d'autres.", "btnDownload": "Télécharger JSON", "btnCancel": "Annuler" + }, + "readOnly": { + "mode": "Mode lecture seule", + "failed": "Échec du chargement du diagramme" } }, "alert": { diff --git a/packages/fossflow-app/public/i18n/app/hi-IN.json b/packages/fossflow-app/public/i18n/app/hi-IN.json index 64a8988..396cb2e 100644 --- a/packages/fossflow-app/public/i18n/app/hi-IN.json +++ b/packages/fossflow-app/public/i18n/app/hi-IN.json @@ -41,6 +41,10 @@ "noteMessage": "निर्यात की गई JSON फ़ाइलों को बाद में आयात किया जा सकता है या दूसरों के साथ साझा किया जा सकता है।", "btnDownload": "JSON डाउनलोड करें", "btnCancel": "रद्द करें" + }, + "readOnly": { + "mode": "केवल देखने का मोड", + "failed": "आरेख लोड करने में विफल" } }, "alert": { diff --git a/packages/fossflow-app/public/i18n/app/it-IT.json b/packages/fossflow-app/public/i18n/app/it-IT.json index 5efcb67..85b4ef4 100644 --- a/packages/fossflow-app/public/i18n/app/it-IT.json +++ b/packages/fossflow-app/public/i18n/app/it-IT.json @@ -41,6 +41,10 @@ "noteMessage": "I file JSON esportati possono essere importati in seguito o condivisi con altri.", "btnDownload": "Scarica JSON", "btnCancel": "Annulla" + }, + "readOnly": { + "mode": "Modalità sola lettura", + "failed": "Impossibile caricare il diagramma" } }, "alert": { diff --git a/packages/fossflow-app/public/i18n/app/pt-BR.json b/packages/fossflow-app/public/i18n/app/pt-BR.json index bccc363..cd8f55f 100644 --- a/packages/fossflow-app/public/i18n/app/pt-BR.json +++ b/packages/fossflow-app/public/i18n/app/pt-BR.json @@ -41,6 +41,10 @@ "noteMessage": "Arquivos JSON exportados podem ser importados posteriormente ou compartilhados com outros.", "btnDownload": "Baixar JSON", "btnCancel": "Cancelar" + }, + "readOnly": { + "mode": "Modo somente leitura", + "failed": "Falha ao carregar diagrama" } }, "alert": { diff --git a/packages/fossflow-app/public/i18n/app/ru-RU.json b/packages/fossflow-app/public/i18n/app/ru-RU.json index 6262e5c..117feeb 100644 --- a/packages/fossflow-app/public/i18n/app/ru-RU.json +++ b/packages/fossflow-app/public/i18n/app/ru-RU.json @@ -41,6 +41,10 @@ "noteMessage": "Экспортированные файлы JSON можно импортировать позже или поделиться с другими.", "btnDownload": "Скачать JSON", "btnCancel": "Отмена" + }, + "readOnly": { + "mode": "Режим только для чтения", + "failed": "Не удалось загрузить диаграмму" } }, "alert": { diff --git a/packages/fossflow-app/public/i18n/app/zh-CN.json b/packages/fossflow-app/public/i18n/app/zh-CN.json index bcd3008..3916fe1 100644 --- a/packages/fossflow-app/public/i18n/app/zh-CN.json +++ b/packages/fossflow-app/public/i18n/app/zh-CN.json @@ -41,6 +41,10 @@ "noteMessage": "导出的 JSON 文件可以稍后导入或与他人共享。", "btnDownload": "下载 JSON", "btnCancel": "取消" + }, + "readOnly": { + "mode": "阅读模式", + "failed": "加载图表失败" } }, "alert": { diff --git a/packages/fossflow-app/src/App.tsx b/packages/fossflow-app/src/App.tsx index 68ff463..483425d 100644 --- a/packages/fossflow-app/src/App.tsx +++ b/packages/fossflow-app/src/App.tsx @@ -3,7 +3,11 @@ import { Isoflow } from 'fossflow'; import { flattenCollections } from '@isoflow/isopacks/dist/utils'; import isoflowIsopack from '@isoflow/isopacks/dist/isoflow'; import { useTranslation } from 'react-i18next'; -import { DiagramData, mergeDiagramData, extractSavableData } from './diagramUtils'; +import { + DiagramData, + mergeDiagramData, + extractSavableData +} from './diagramUtils'; import { StorageManager } from './StorageManager'; import { DiagramManager } from './components/DiagramManager'; import { storageManager } from './services/storageService'; @@ -11,11 +15,11 @@ import ChangeLanguage from './components/ChangeLanguage'; import { allLocales } from 'fossflow'; import { useIconPackManager, IconPackName } from './services/iconPackManager'; import './App.css'; +import { BrowserRouter, Route, Routes, useParams } from 'react-router-dom'; // Load core isoflow icons (always loaded) const coreIcons = flattenCollections([isoflowIsopack]); - interface SavedDiagram { id: string; name: string; @@ -25,11 +29,25 @@ interface SavedDiagram { } function App() { + return ( + + + } /> + } /> + + + ); +} + +function EditorPage() { // Initialize icon pack manager with core icons const iconPackManager = useIconPackManager(coreIcons); + const { readonlyDiagramId } = useParams<{ readonlyDiagramId: string }>(); const [diagrams, setDiagrams] = useState([]); - const [currentDiagram, setCurrentDiagram] = useState(null); + const [currentDiagram, setCurrentDiagram] = useState( + null + ); const [diagramName, setDiagramName] = useState(''); const [showSaveDialog, setShowSaveDialog] = useState(false); const [showLoadDialog, setShowLoadDialog] = useState(false); @@ -41,7 +59,9 @@ function App() { const [showStorageManager, setShowStorageManager] = useState(false); const [showDiagramManager, setShowDiagramManager] = useState(false); const [serverStorageAvailable, setServerStorageAvailable] = useState(false); - + const isReadonlyUrl = + window.location.pathname.startsWith('/display/') && readonlyDiagramId; + // Initialize with empty diagram data // Create default colors for connectors const defaultColors = [ @@ -53,15 +73,16 @@ function App() { { id: 'black', value: '#000000' }, { id: 'gray', value: '#666666' } ]; - - + const [diagramData, setDiagramData] = useState(() => { // Initialize with last opened data if available const lastOpenedData = localStorage.getItem('fossflow-last-opened-data'); if (lastOpenedData) { try { const data = JSON.parse(lastOpenedData); - const importedIcons = (data.icons || []).filter((icon: any) => icon.collection === 'imported'); + const importedIcons = (data.icons || []).filter((icon: any) => { + return icon.collection === 'imported'; + }); const mergedIcons = [...coreIcons, ...importedIcons]; return { ...data, @@ -87,20 +108,59 @@ function App() { // Check for server storage availability useEffect(() => { - storageManager.initialize().then(() => { - setServerStorageAvailable(storageManager.isServerStorage()); - }).catch(console.error); + storageManager + .initialize() + .then(() => { + setServerStorageAvailable(storageManager.isServerStorage()); + }) + .catch(console.error); }, []); + // Check if readonlyDiagramId exists - if exists, load diagram in view-only mode + useEffect(() => { + if (!isReadonlyUrl || !serverStorageAvailable) return; + const loadReadonlyDiagram = async () => { + try { + const storage = storageManager.getStorage(); + // Get diagram metadata + const diagramList = await storage.listDiagrams(); + const diagramInfo = diagramList.find((d) => { + return d.id === readonlyDiagramId; + }); + // Load the diagram data from server storage + const data = await storage.loadDiagram(readonlyDiagramId); + // Convert to SavedDiagram interface format + const readonlyDiagram: SavedDiagram = { + id: readonlyDiagramId, + name: diagramInfo?.name || data.title || 'Readonly Diagram', + data: data, + createdAt: new Date().toISOString(), + updatedAt: + diagramInfo?.lastModified.toISOString() || new Date().toISOString() + }; + await loadDiagram(readonlyDiagram, true); + } catch (error) { + // Alert if unable to load readonly diagram and redirect to new diagram + alert(t('dialog.readOnly.failed')); + window.location.href = '/'; + } + }; + loadReadonlyDiagram(); + }, [readonlyDiagramId, serverStorageAvailable]); + // Update diagramData when loaded icons change useEffect(() => { - setDiagramData(prev => ({ - ...prev, - icons: [ - ...iconPackManager.loadedIcons, - ...(prev.icons || []).filter(icon => icon.collection === 'imported') - ] - })); + setDiagramData((prev) => { + return { + ...prev, + icons: [ + ...iconPackManager.loadedIcons, + ...(prev.icons || []).filter((icon) => { + return icon.collection === 'imported'; + }) + ] + }; + }); }, [iconPackManager.loadedIcons]); // Load diagrams from localStorage on component mount @@ -109,14 +169,16 @@ function App() { if (savedDiagrams) { setDiagrams(JSON.parse(savedDiagrams)); } - + // Load last opened diagram metadata (data is already loaded in state initialization) const lastOpenedId = localStorage.getItem('fossflow-last-opened'); - + if (lastOpenedId && savedDiagrams) { try { const allDiagrams = JSON.parse(savedDiagrams); - const lastDiagram = allDiagrams.find((d: SavedDiagram) => d.id === lastOpenedId); + const lastDiagram = allDiagrams.find((d: SavedDiagram) => { + return d.id === lastOpenedId; + }); if (lastDiagram) { setCurrentDiagram(lastDiagram); setDiagramName(lastDiagram.name); @@ -129,18 +191,23 @@ function App() { } }, [diagramData]); - // Save diagrams to localStorage whenever they change + // Save diagrams to localStorage whenever they change useEffect(() => { try { // Store diagrams without the full icon data - const diagramsToStore = diagrams.map(d => ({ - ...d, - data: { - ...d.data, - icons: [] // Don't store icons with each diagram - } - })); - localStorage.setItem('fossflow-diagrams', JSON.stringify(diagramsToStore)); + const diagramsToStore = diagrams.map((d) => { + return { + ...d, + data: { + ...d.data, + icons: [] // Don't store icons with each diagram + } + }; + }); + localStorage.setItem( + 'fossflow-diagrams', + JSON.stringify(diagramsToStore) + ); } catch (e) { console.error('Failed to save diagrams:', e); if (e instanceof DOMException && e.name === 'QuotaExceededError') { @@ -156,10 +223,10 @@ function App() { } // Check if a diagram with this name already exists (excluding current) - const existingDiagram = diagrams.find(d => - d.name === diagramName.trim() && d.id !== currentDiagram?.id - ); - + const existingDiagram = diagrams.find((d) => { + return d.name === diagramName.trim() && d.id !== currentDiagram?.id; + }); + if (existingDiagram) { const confirmOverwrite = window.confirm( t('alert.diagramExists', { name: diagramName }) @@ -170,9 +237,14 @@ function App() { } // Construct save data - include only imported icons - const importedIcons = (currentModel?.icons || diagramData.icons || []) - .filter(icon => icon.collection === 'imported'); - + const importedIcons = ( + currentModel?.icons || + diagramData.icons || + [] + ).filter((icon) => { + return icon.collection === 'imported'; + }); + const savedData = { title: diagramName, icons: importedIcons, // Save only imported icons with diagram @@ -181,7 +253,6 @@ function App() { views: currentModel?.views || diagramData.views || [], fitToScreen: true }; - const newDiagram: SavedDiagram = { id: currentDiagram?.id || Date.now().toString(), @@ -193,10 +264,24 @@ function App() { if (currentDiagram) { // Update existing diagram - setDiagrams(diagrams.map(d => d.id === currentDiagram.id ? newDiagram : d)); + setDiagrams( + diagrams.map((d) => { + return d.id === currentDiagram.id ? newDiagram : d; + }) + ); } else if (existingDiagram) { // Replace existing diagram with same name - setDiagrams(diagrams.map(d => d.id === existingDiagram.id ? { ...newDiagram, id: existingDiagram.id, createdAt: existingDiagram.createdAt } : d)); + setDiagrams( + diagrams.map((d) => { + return d.id === existingDiagram.id + ? { + ...newDiagram, + id: existingDiagram.id, + createdAt: existingDiagram.createdAt + } + : d; + }) + ); newDiagram.id = existingDiagram.id; newDiagram.createdAt = existingDiagram.createdAt; } else { @@ -208,11 +293,14 @@ function App() { setShowSaveDialog(false); setHasUnsavedChanges(false); setLastAutoSave(new Date()); - + // Save as last opened try { localStorage.setItem('fossflow-last-opened', newDiagram.id); - localStorage.setItem('fossflow-last-opened-data', JSON.stringify(newDiagram.data)); + localStorage.setItem( + 'fossflow-last-opened-data', + JSON.stringify(newDiagram.data) + ); } catch (e) { console.error('Failed to save diagram:', e); if (e instanceof DOMException && e.name === 'QuotaExceededError') { @@ -222,8 +310,15 @@ function App() { } }; - const loadDiagram = async (diagram: SavedDiagram) => { - if (hasUnsavedChanges && !window.confirm(t('alert.unsavedChanges'))) { + const loadDiagram = async ( + diagram: SavedDiagram, + skipUnsavedCheck = false + ) => { + if ( + !skipUnsavedCheck && + hasUnsavedChanges && + !window.confirm(t('alert.unsavedChanges')) + ) { return; } @@ -231,25 +326,32 @@ function App() { await iconPackManager.loadPacksForDiagram(diagram.data.items || []); // Merge imported icons with loaded icon set - const importedIcons = (diagram.data.icons || []).filter((icon: any) => icon.collection === 'imported'); + const importedIcons = (diagram.data.icons || []).filter((icon: any) => { + return icon.collection === 'imported'; + }); const mergedIcons = [...iconPackManager.loadedIcons, ...importedIcons]; const dataWithIcons = { ...diagram.data, icons: mergedIcons }; - + setCurrentDiagram(diagram); setDiagramName(diagram.name); setDiagramData(dataWithIcons); setCurrentModel(dataWithIcons); - setFossflowKey(prev => prev + 1); // Force re-render of FossFLOW + setFossflowKey((prev) => { + return prev + 1; + }); // Force re-render of FossFLOW setShowLoadDialog(false); setHasUnsavedChanges(false); - + // Save as last opened (without icons) try { localStorage.setItem('fossflow-last-opened', diagram.id); - localStorage.setItem('fossflow-last-opened-data', JSON.stringify(diagram.data)); + localStorage.setItem( + 'fossflow-last-opened-data', + JSON.stringify(diagram.data) + ); } catch (e) { console.error('Failed to save last opened:', e); } @@ -257,7 +359,11 @@ function App() { const deleteDiagram = (id: string) => { if (window.confirm(t('alert.confirmDelete'))) { - setDiagrams(diagrams.filter(d => d.id !== id)); + setDiagrams( + diagrams.filter((d) => { + return d.id !== id; + }) + ); if (currentDiagram?.id === id) { setCurrentDiagram(null); setDiagramName(''); @@ -283,7 +389,9 @@ function App() { setDiagramName(''); setDiagramData(emptyDiagram); setCurrentModel(emptyDiagram); // Reset current model too - setFossflowKey(prev => prev + 1); // Force re-render of FossFLOW + setFossflowKey((prev) => { + return prev + 1; + }); // Force re-render of FossFLOW setHasUnsavedChanges(false); // Clear last opened @@ -295,7 +403,7 @@ function App() { const handleModelUpdated = (model: any) => { // Store the current model state whenever it updates // The model from Isoflow contains the COMPLETE state including all icons - + // Simply store the complete model as-is since it has everything const updatedModel = { title: model.title || diagramName || 'Untitled', @@ -305,40 +413,45 @@ function App() { views: model.views || [], fitToScreen: true }; - + setCurrentModel(updatedModel); setDiagramData(updatedModel); - setHasUnsavedChanges(true); + + if (!isReadonlyUrl) { + setHasUnsavedChanges(true); + } }; const exportDiagram = () => { // Use the most recent model data - prefer currentModel as it gets updated by handleModelUpdated const modelToExport = currentModel || diagramData; - + // Get ALL icons from the current model (which includes both default and imported) const allModelIcons = modelToExport.icons || []; - + // For safety, also check diagramData for any imported icons not in currentModel - const diagramImportedIcons = (diagramData.icons || []).filter(icon => icon.collection === 'imported'); - + const diagramImportedIcons = (diagramData.icons || []).filter((icon) => { + return icon.collection === 'imported'; + }); + // Create a map to deduplicate icons by ID, preferring the ones from currentModel const iconMap = new Map(); - + // First add all icons from the model (includes defaults + imported) - allModelIcons.forEach(icon => { + allModelIcons.forEach((icon) => { iconMap.set(icon.id, icon); }); - + // Then add any imported icons from diagramData that might be missing - diagramImportedIcons.forEach(icon => { + diagramImportedIcons.forEach((icon) => { if (!iconMap.has(icon.id)) { iconMap.set(icon.id, icon); } }); - + // Get all unique icons const allIcons = Array.from(iconMap.values()); - + const exportData = { title: diagramName || modelToExport.title || 'Exported Diagram', icons: allIcons, // Include ALL icons (default + imported) for portability @@ -347,9 +460,9 @@ function App() { views: modelToExport.views || [], fitToScreen: true }; - + const jsonString = JSON.stringify(exportData, null, 2); - + // Create a blob and download link const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -358,12 +471,11 @@ function App() { a.download = `${diagramName || 'diagram'}-${new Date().toISOString().split('T')[0]}.json`; a.click(); URL.revokeObjectURL(url); - + setShowExportDialog(false); setHasUnsavedChanges(false); // Mark as saved after export }; - const handleDiagramManagerLoad = async (id: string, data: any) => { console.log(`App: handleDiagramManagerLoad called for diagram ${id}`); @@ -396,21 +508,31 @@ function App() { // For backward compatibility with old saves, we detect and merge let finalIcons; - const hasDefaultIcons = loadedIcons.some((icon: any) => - icon.collection === 'isoflow' || icon.collection === 'aws' || icon.collection === 'gcp' - ); + const hasDefaultIcons = loadedIcons.some((icon: any) => { + return ( + icon.collection === 'isoflow' || + icon.collection === 'aws' || + icon.collection === 'gcp' + ); + }); if (hasDefaultIcons) { // New format: Server saved ALL icons (default + imported) // Use them directly to preserve any custom icon modifications - console.log(`App: Using all ${loadedIcons.length} icons from server (includes defaults + imported)`); + console.log( + `App: Using all ${loadedIcons.length} icons from server (includes defaults + imported)` + ); finalIcons = loadedIcons; } else { // Old format: Server only saved imported icons // Merge imported icons with currently loaded icon packs - const importedIcons = loadedIcons.filter((icon: any) => icon.collection === 'imported'); + const importedIcons = loadedIcons.filter((icon: any) => { + return icon.collection === 'imported'; + }); finalIcons = [...iconPackManager.loadedIcons, ...importedIcons]; - console.log(`App: Old format detected. Merged ${importedIcons.length} imported icons with ${iconPackManager.loadedIcons.length} defaults = ${finalIcons.length} total`); + console.log( + `App: Old format detected. Merged ${importedIcons.length} imported icons with ${iconPackManager.loadedIcons.length} defaults = ${finalIcons.length} total` + ); } const mergedData: DiagramData = { @@ -441,26 +563,33 @@ function App() { // Update diagramData and key together // This ensures Isoflow gets the correct data with the new key setDiagramData(mergedData); - setFossflowKey(prev => { + setFossflowKey((prev) => { const newKey = prev + 1; console.log(`App: Updated fossflowKey from ${prev} to ${newKey}`); return newKey; }); - console.log(`App: Finished loading diagram ${id}, final icon count: ${finalIcons.length}`); + console.log( + `App: Finished loading diagram ${id}, final icon count: ${finalIcons.length}` + ); }; // i18n const { t, i18n } = useTranslation('app'); - + // Auto-save functionality useEffect(() => { if (!currentModel || !hasUnsavedChanges || !currentDiagram) return; const autoSaveTimer = setTimeout(() => { // Include imported icons in auto-save - const importedIcons = (currentModel?.icons || diagramData.icons || []) - .filter(icon => icon.collection === 'imported'); + const importedIcons = ( + currentModel?.icons || + diagramData.icons || + [] + ).filter((icon) => { + return icon.collection === 'imported'; + }); const savedData = { title: diagramName || currentDiagram.name, @@ -477,13 +606,18 @@ function App() { updatedAt: new Date().toISOString() }; - setDiagrams(prevDiagrams => - prevDiagrams.map(d => d.id === currentDiagram.id ? updatedDiagram : d) - ); + setDiagrams((prevDiagrams) => { + return prevDiagrams.map((d) => { + return d.id === currentDiagram.id ? updatedDiagram : d; + }); + }); // Update last opened data try { - localStorage.setItem('fossflow-last-opened-data', JSON.stringify(savedData)); + localStorage.setItem( + 'fossflow-last-opened-data', + JSON.stringify(savedData) + ); setLastAutoSave(new Date()); setHasUnsavedChanges(false); } catch (e) { @@ -495,9 +629,11 @@ function App() { } }, 5000); // Auto-save after 5 seconds of changes - return () => clearTimeout(autoSaveTimer); + return () => { + return clearTimeout(autoSaveTimer); + }; }, [currentModel, hasUnsavedChanges, currentDiagram, diagramName]); - + // Warn before closing if there are unsaved changes useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { @@ -509,7 +645,9 @@ function App() { }; window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); + return () => { + return window.removeEventListener('beforeunload', handleBeforeUnload); + }; }, [hasUnsavedChanges]); // Keyboard shortcuts @@ -536,52 +674,107 @@ function App() { }; window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + return () => { + return window.removeEventListener('keydown', handleKeyDown); + }; }, [currentDiagram, hasUnsavedChanges]); return (
- - {serverStorageAvailable && ( - + {!isReadonlyUrl && ( + <> + + {serverStorageAvailable && ( + + )} + + + + + + )} + {isReadonlyUrl && ( +
+ {t('dialog.readOnly.mode')} +
)} - - - - - {currentDiagram ? `${t('status.current')}: ${currentDiagram.name}` : diagramName || t('status.untitled')} - {hasUnsavedChanges && • {t('status.modified')}} - - ({t('status.sessionStorageNote')}) - + {isReadonlyUrl ? ( + + {t('status.current')}: {diagramName} + + ) : ( + <> + {currentDiagram + ? `${t('status.current')}: ${currentDiagram.name}` + : diagramName || t('status.untitled')} + {hasUnsavedChanges && ( + + • {t('status.modified')} + + )} + + ({t('status.sessionStorageNote')}) + + + )}
@@ -590,7 +783,7 @@ function App() { key={fossflowKey} initialData={diagramData} onModelUpdated={handleModelUpdated} - editorMode="EDITABLE" + editorMode={isReadonlyUrl ? 'EXPLORABLE_READONLY' : 'EDITABLE'} locale={allLocales[i18n.language as keyof typeof allLocales]} iconPackManager={{ lazyLoadingEnabled: iconPackManager.lazyLoadingEnabled, @@ -609,28 +802,45 @@ function App() {

{t('dialog.save.title')}

-
- ⚠️ {t('dialog.save.warningTitle')}: {t('dialog.save.warningMessage')} +
+ ⚠️ {t('dialog.save.warningTitle')}:{' '} + {t('dialog.save.warningMessage')}
- +
setDiagramName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && saveDiagram()} + onChange={(e) => { + return setDiagramName(e.target.value); + }} + onKeyDown={(e) => { + return e.key === 'Enter' && saveDiagram(); + }} autoFocus />
- +
@@ -641,64 +851,100 @@ function App() {

{t('dialog.load.title')}

-
- ⚠️ {t('dialog.load.noteTitle')}: {t('dialog.load.noteMessage')} +
+ ⚠️ {t('dialog.load.noteTitle')}:{' '} + {t('dialog.load.noteMessage')}
{diagrams.length === 0 ? (

{t('dialog.load.noSavedDiagrams')}

) : ( - diagrams.map(diagram => ( -
-
- {diagram.name} -
- {t('dialog.load.updated')}: {new Date(diagram.updatedAt).toLocaleString()} + diagrams.map((diagram) => { + return ( +
+
+ {diagram.name} +
+ + {t('dialog.load.updated')}:{' '} + {new Date(diagram.updatedAt).toLocaleString()} + +
+
+ + +
-
- - -
-
- )) + ); + }) )}
- +
)} - {/* Export Dialog */} {showExportDialog && (

{t('dialog.export.title')}

-
+

- ✅ {t('dialog.export.recommendedTitle')}: {t('dialog.export.recommendedMessage')} + ✅ {t('dialog.export.recommendedTitle')}:{' '} + {t('dialog.export.recommendedMessage')}

{t('dialog.export.noteMessage')}

- - + +
@@ -706,7 +952,11 @@ function App() { {/* Storage Manager */} {showStorageManager && ( - setShowStorageManager(false)} /> + { + return setShowStorageManager(false); + }} + /> )} {/* Diagram Manager */} @@ -715,7 +965,9 @@ function App() { onLoadDiagram={handleDiagramManagerLoad} currentDiagramId={currentDiagram?.id} currentDiagramData={currentModel || diagramData} - onClose={() => setShowDiagramManager(false)} + onClose={() => { + return setShowDiagramManager(false); + }} /> )}
diff --git a/packages/fossflow-app/src/components/DiagramManager.css b/packages/fossflow-app/src/components/DiagramManager.css index a8a785e..10c9a9d 100644 --- a/packages/fossflow-app/src/components/DiagramManager.css +++ b/packages/fossflow-app/src/components/DiagramManager.css @@ -129,6 +129,15 @@ background: #da190b; } +.action-button.share { + background: #2196f3; + color: white; +} + +.action-button.share:hover { + background: #0b7dda; +} + .loading { padding: 40px; text-align: center; diff --git a/packages/fossflow-app/src/components/DiagramManager.tsx b/packages/fossflow-app/src/components/DiagramManager.tsx index dbaac29..89336bd 100644 --- a/packages/fossflow-app/src/components/DiagramManager.tsx +++ b/packages/fossflow-app/src/components/DiagramManager.tsx @@ -9,11 +9,11 @@ interface Props { onClose: () => void; } -export const DiagramManager: React.FC = ({ - onLoadDiagram, - currentDiagramId, +export const DiagramManager: React.FC = ({ + onLoadDiagram, + currentDiagramId, currentDiagramData, - onClose + onClose }) => { const [diagrams, setDiagrams] = useState([]); const [loading, setLoading] = useState(true); @@ -36,7 +36,9 @@ export const DiagramManager: React.FC = ({ await storageManager.initialize(); const isServer = storageManager.isServerStorage(); setIsServerStorage(isServer); - console.log(`DiagramManager: Using ${isServer ? 'server' : 'session'} storage`); + console.log( + `DiagramManager: Using ${isServer ? 'server' : 'session'} storage` + ); // Load diagram list const storage = storageManager.getStorage(); @@ -45,7 +47,8 @@ export const DiagramManager: React.FC = ({ console.log(`DiagramManager: Loaded ${list.length} diagrams`); setDiagrams(list); } catch (err) { - const errorMsg = err instanceof Error ? err.message : 'Failed to load diagrams'; + const errorMsg = + err instanceof Error ? err.message : 'Failed to load diagrams'; console.error('DiagramManager error:', err); setError(errorMsg); } finally { @@ -66,7 +69,9 @@ export const DiagramManager: React.FC = ({ onLoadDiagram(id, data); // Small delay to ensure parent component finishes state updates - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => { + return setTimeout(resolve, 100); + }); onClose(); } catch (err) { @@ -91,6 +96,24 @@ export const DiagramManager: React.FC = ({ } }; + const handleCopyShareLink = (id: string) => { + const shareUrl = `${window.location.origin}/display/${id}`; + navigator.clipboard + .writeText(shareUrl) + .then(() => { + alert(`Share link copied to clipboard:\n${shareUrl}`); + }) + .catch(() => { + const textArea = document.createElement('textarea'); + textArea.value = shareUrl; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + alert(`Share link copied to clipboard:\n${shareUrl}`); + }); + }; + const handleSave = async () => { if (!saveName.trim()) { setError('Please enter a diagram name'); @@ -101,9 +124,9 @@ export const DiagramManager: React.FC = ({ const storage = storageManager.getStorage(); // Check if a diagram with this name already exists (excluding current diagram) - const existingDiagram = diagrams.find(d => - d.name === saveName.trim() && d.id !== currentDiagramId - ); + const existingDiagram = diagrams.find((d) => { + return d.name === saveName.trim() && d.id !== currentDiagramId; + }); if (existingDiagram) { const confirmOverwrite = window.confirm( @@ -132,8 +155,12 @@ export const DiagramManager: React.FC = ({ name: saveName }; - console.log(`DiagramManager: Saving diagram with ${dataToSave.icons?.length || 0} icons`); - const importedCount = (dataToSave.icons || []).filter((icon: any) => icon.collection === 'imported').length; + console.log( + `DiagramManager: Saving diagram with ${dataToSave.icons?.length || 0} icons` + ); + const importedCount = (dataToSave.icons || []).filter((icon: any) => { + return icon.collection === 'imported'; + }).length; console.log(`DiagramManager: Including ${importedCount} imported icons`); if (currentDiagramId) { @@ -157,11 +184,15 @@ export const DiagramManager: React.FC = ({

Diagram Manager

- +
- + {isServerStorage ? '🌐 Server Storage' : '💾 Local Storage'} {isServerStorage && ( @@ -171,14 +202,10 @@ export const DiagramManager: React.FC = ({ )}
- {error && ( -
- {error} -
- )} + {error &&
{error}
}
-
) : ( - diagrams.map(diagram => ( -
-
-

{diagram.name}

- - Last modified: {diagram.lastModified.toLocaleString()} - {diagram.size && ` • ${(diagram.size / 1024).toFixed(1)} KB`} - + diagrams.map((diagram) => { + return ( +
+
+

{diagram.name}

+ + Last modified: {diagram.lastModified.toLocaleString()} + {diagram.size && + ` • ${(diagram.size / 1024).toFixed(1)} KB`} + +
+
+ + + +
-
- - -
-
- )) + ); + }) )}
)} @@ -238,17 +281,27 @@ export const DiagramManager: React.FC = ({ type="text" placeholder="Diagram name" value={saveName} - onChange={(e) => setSaveName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSave()} + onChange={(e) => { + return setSaveName(e.target.value); + }} + onKeyDown={(e) => { + return e.key === 'Enter' && handleSave(); + }} autoFocus />
- +
)}
); -}; \ No newline at end of file +}; diff --git a/packages/fossflow-app/src/i18n.ts b/packages/fossflow-app/src/i18n.ts index 46f0434..dee001f 100644 --- a/packages/fossflow-app/src/i18n.ts +++ b/packages/fossflow-app/src/i18n.ts @@ -11,55 +11,55 @@ i18n fallbackLng: 'en-US', debug: process.env.NODE_ENV === 'development', interpolation: { - escapeValue: false, + escapeValue: false }, ns: ['app'], backend: { - loadPath: './i18n/{{ns}}/{{lng}}.json' + loadPath: '/i18n/{{ns}}/{{lng}}.json' }, detection: { order: ['localStorage'], - caches: ['localStorage'], + caches: ['localStorage'] } }); export const supportedLanguages = [ { label: 'English', - value: 'en-US', + value: 'en-US' }, { label: '中文', - value: 'zh-CN', + value: 'zh-CN' }, { label: 'Español', - value: 'es-ES', + value: 'es-ES' }, { label: 'Português', - value: 'pt-BR', + value: 'pt-BR' }, { label: 'Français', - value: 'fr-FR', + value: 'fr-FR' }, { label: 'हिन्दी', - value: 'hi-IN', + value: 'hi-IN' }, { label: 'বাংলা', - value: 'bn-BD', + value: 'bn-BD' }, { label: 'Русский', - value: 'ru-RU', + value: 'ru-RU' }, { label: 'Italian', - value: 'it-IT', - }, + value: 'it-IT' + } ]; export default i18n; diff --git a/packages/fossflow-app/src/i18n/bn-BD.json b/packages/fossflow-app/src/i18n/bn-BD.json index 684e5a0..40f3d1a 100644 --- a/packages/fossflow-app/src/i18n/bn-BD.json +++ b/packages/fossflow-app/src/i18n/bn-BD.json @@ -41,6 +41,10 @@ "noteMessage": "রপ্তানি করা JSON ফাইলগুলি পরে আমদানি করা যেতে পারে বা অন্যদের সাথে শেয়ার করা যেতে পারে।", "btnDownload": "JSON ডাউনলোড করুন", "btnCancel": "বাতিল করুন" + }, + "readOnly": { + "mode": "শুধুমাত্র দেখার মোড", + "failed": "ডায়াগ্রাম লোড করতে ব্যর্থ" } }, "alert": { diff --git a/packages/fossflow-app/src/i18n/en-US.json b/packages/fossflow-app/src/i18n/en-US.json index 1e19a3d..ffce153 100644 --- a/packages/fossflow-app/src/i18n/en-US.json +++ b/packages/fossflow-app/src/i18n/en-US.json @@ -41,6 +41,10 @@ "noteMessage": "Exported JSON files can be imported later or shared with others.", "btnDownload": "Download JSON", "btnCancel": "Cancel" + }, + "readOnly": { + "mode": "View-Only Mode", + "failed": "Failed to load diagram" } }, "alert": { diff --git a/packages/fossflow-app/src/i18n/es-ES.json b/packages/fossflow-app/src/i18n/es-ES.json index b239b00..850cb69 100644 --- a/packages/fossflow-app/src/i18n/es-ES.json +++ b/packages/fossflow-app/src/i18n/es-ES.json @@ -41,6 +41,10 @@ "noteMessage": "Los archivos JSON exportados pueden importarse posteriormente o compartirse con otros.", "btnDownload": "Descargar JSON", "btnCancel": "Cancelar" + }, + "readOnly": { + "mode": "Modo de solo lectura", + "failed": "Error al cargar el diagrama" } }, "alert": { diff --git a/packages/fossflow-app/src/i18n/fr-FR.json b/packages/fossflow-app/src/i18n/fr-FR.json index 1aee5cf..57c72ac 100644 --- a/packages/fossflow-app/src/i18n/fr-FR.json +++ b/packages/fossflow-app/src/i18n/fr-FR.json @@ -41,6 +41,10 @@ "noteMessage": "Les fichiers JSON exportés peuvent être importés ultérieurement ou partagés avec d'autres.", "btnDownload": "Télécharger JSON", "btnCancel": "Annuler" + }, + "readOnly": { + "mode": "Mode lecture seule", + "failed": "Échec du chargement du diagramme" } }, "alert": { diff --git a/packages/fossflow-app/src/i18n/hi-IN.json b/packages/fossflow-app/src/i18n/hi-IN.json index 64a8988..396cb2e 100644 --- a/packages/fossflow-app/src/i18n/hi-IN.json +++ b/packages/fossflow-app/src/i18n/hi-IN.json @@ -41,6 +41,10 @@ "noteMessage": "निर्यात की गई JSON फ़ाइलों को बाद में आयात किया जा सकता है या दूसरों के साथ साझा किया जा सकता है।", "btnDownload": "JSON डाउनलोड करें", "btnCancel": "रद्द करें" + }, + "readOnly": { + "mode": "केवल देखने का मोड", + "failed": "आरेख लोड करने में विफल" } }, "alert": { diff --git a/packages/fossflow-app/src/i18n/it-IT.json b/packages/fossflow-app/src/i18n/it-IT.json index 5efcb67..85b4ef4 100644 --- a/packages/fossflow-app/src/i18n/it-IT.json +++ b/packages/fossflow-app/src/i18n/it-IT.json @@ -41,6 +41,10 @@ "noteMessage": "I file JSON esportati possono essere importati in seguito o condivisi con altri.", "btnDownload": "Scarica JSON", "btnCancel": "Annulla" + }, + "readOnly": { + "mode": "Modalità sola lettura", + "failed": "Impossibile caricare il diagramma" } }, "alert": { diff --git a/packages/fossflow-app/src/i18n/pt-BR.json b/packages/fossflow-app/src/i18n/pt-BR.json index bccc363..cd8f55f 100644 --- a/packages/fossflow-app/src/i18n/pt-BR.json +++ b/packages/fossflow-app/src/i18n/pt-BR.json @@ -41,6 +41,10 @@ "noteMessage": "Arquivos JSON exportados podem ser importados posteriormente ou compartilhados com outros.", "btnDownload": "Baixar JSON", "btnCancel": "Cancelar" + }, + "readOnly": { + "mode": "Modo somente leitura", + "failed": "Falha ao carregar diagrama" } }, "alert": { diff --git a/packages/fossflow-app/src/i18n/ru-RU.json b/packages/fossflow-app/src/i18n/ru-RU.json index 6262e5c..117feeb 100644 --- a/packages/fossflow-app/src/i18n/ru-RU.json +++ b/packages/fossflow-app/src/i18n/ru-RU.json @@ -41,6 +41,10 @@ "noteMessage": "Экспортированные файлы JSON можно импортировать позже или поделиться с другими.", "btnDownload": "Скачать JSON", "btnCancel": "Отмена" + }, + "readOnly": { + "mode": "Режим только для чтения", + "failed": "Не удалось загрузить диаграмму" } }, "alert": { diff --git a/packages/fossflow-app/src/i18n/zh-CN.json b/packages/fossflow-app/src/i18n/zh-CN.json index bcd3008..3916fe1 100644 --- a/packages/fossflow-app/src/i18n/zh-CN.json +++ b/packages/fossflow-app/src/i18n/zh-CN.json @@ -41,6 +41,10 @@ "noteMessage": "导出的 JSON 文件可以稍后导入或与他人共享。", "btnDownload": "下载 JSON", "btnCancel": "取消" + }, + "readOnly": { + "mode": "阅读模式", + "failed": "加载图表失败" } }, "alert": {