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
This commit is contained in:
Joanna Lau
2025-11-20 00:48:04 -05:00
committed by GitHub
parent 6276b56b7c
commit 85d32e64df
24 changed files with 691 additions and 250 deletions

88
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -41,6 +41,10 @@
"noteMessage": "রপ্তানি করা JSON ফাইলগুলি পরে আমদানি করা যেতে পারে বা অন্যদের সাথে শেয়ার করা যেতে পারে।",
"btnDownload": "JSON ডাউনলোড করুন",
"btnCancel": "বাতিল করুন"
},
"readOnly": {
"mode": "শুধুমাত্র দেখার মোড",
"failed": "ডায়াগ্রাম লোড করতে ব্যর্থ"
}
},
"alert": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -41,6 +41,10 @@
"noteMessage": "निर्यात की गई JSON फ़ाइलों को बाद में आयात किया जा सकता है या दूसरों के साथ साझा किया जा सकता है।",
"btnDownload": "JSON डाउनलोड करें",
"btnCancel": "रद्द करें"
},
"readOnly": {
"mode": "केवल देखने का मोड",
"failed": "आरेख लोड करने में विफल"
}
},
"alert": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -41,6 +41,10 @@
"noteMessage": "Экспортированные файлы JSON можно импортировать позже или поделиться с другими.",
"btnDownload": "Скачать JSON",
"btnCancel": "Отмена"
},
"readOnly": {
"mode": "Режим только для чтения",
"failed": "Не удалось загрузить диаграмму"
}
},
"alert": {

View File

@@ -41,6 +41,10 @@
"noteMessage": "导出的 JSON 文件可以稍后导入或与他人共享。",
"btnDownload": "下载 JSON",
"btnCancel": "取消"
},
"readOnly": {
"mode": "阅读模式",
"failed": "加载图表失败"
}
},
"alert": {

View File

@@ -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 (
<BrowserRouter>
<Routes>
<Route path="/" element={<EditorPage />} />
<Route path="/display/:readonlyDiagramId" element={<EditorPage />} />
</Routes>
</BrowserRouter>
);
}
function EditorPage() {
// Initialize icon pack manager with core icons
const iconPackManager = useIconPackManager(coreIcons);
const { readonlyDiagramId } = useParams<{ readonlyDiagramId: string }>();
const [diagrams, setDiagrams] = useState<SavedDiagram[]>([]);
const [currentDiagram, setCurrentDiagram] = useState<SavedDiagram | null>(null);
const [currentDiagram, setCurrentDiagram] = useState<SavedDiagram | null>(
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<DiagramData>(() => {
// 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 (
<div className="App">
<div className="toolbar">
<button onClick={newDiagram}>{t('nav.newDiagram')}</button>
{serverStorageAvailable && (
<button
onClick={() => setShowDiagramManager(true)}
style={{ backgroundColor: '#2196F3', color: 'white' }}
>
🌐 {t('nav.serverStorage')}
</button>
{!isReadonlyUrl && (
<>
<button onClick={newDiagram}>{t('nav.newDiagram')}</button>
{serverStorageAvailable && (
<button
onClick={() => {
return setShowDiagramManager(true);
}}
style={{ backgroundColor: '#2196F3', color: 'white' }}
>
🌐 {t('nav.serverStorage')}
</button>
)}
<button
onClick={() => {
return setShowSaveDialog(true);
}}
>
{t('nav.saveSessionOnly')}
</button>
<button
onClick={() => {
return setShowLoadDialog(true);
}}
>
{t('nav.loadSessionOnly')}
</button>
<button
onClick={() => {
return setShowExportDialog(true);
}}
style={{ backgroundColor: '#007bff' }}
>
💾 {t('nav.exportFile')}
</button>
<button
onClick={() => {
if (currentDiagram && hasUnsavedChanges) {
saveDiagram();
}
}}
disabled={!currentDiagram || !hasUnsavedChanges}
style={{
backgroundColor:
currentDiagram && hasUnsavedChanges ? '#ffc107' : '#6c757d',
opacity: currentDiagram && hasUnsavedChanges ? 1 : 0.5,
cursor:
currentDiagram && hasUnsavedChanges
? 'pointer'
: 'not-allowed'
}}
title="Save to current session only"
>
{t('nav.quickSaveSession')}
</button>
</>
)}
{isReadonlyUrl && (
<div
style={{
color: 'black',
padding: '8px 16px',
borderRadius: '4px',
fontWeight: 'bold',
border: '2px solid #000000'
}}
>
{t('dialog.readOnly.mode')}
</div>
)}
<button onClick={() => setShowSaveDialog(true)}>{t('nav.saveSessionOnly')}</button>
<button onClick={() => setShowLoadDialog(true)}>{t('nav.loadSessionOnly')}</button>
<button
onClick={() => setShowExportDialog(true)}
style={{ backgroundColor: '#007bff' }}
>
💾 {t('nav.exportFile')}
</button>
<button
onClick={() => {
if (currentDiagram && hasUnsavedChanges) {
saveDiagram();
}
}}
disabled={!currentDiagram || !hasUnsavedChanges}
style={{
backgroundColor: currentDiagram && hasUnsavedChanges ? '#ffc107' : '#6c757d',
opacity: currentDiagram && hasUnsavedChanges ? 1 : 0.5,
cursor: currentDiagram && hasUnsavedChanges ? 'pointer' : 'not-allowed'
}}
title="Save to current session only"
>
{t('nav.quickSaveSession')}
</button>
<ChangeLanguage />
<span className="current-diagram">
{currentDiagram ? `${t('status.current')}: ${currentDiagram.name}` : diagramName || t('status.untitled')}
{hasUnsavedChanges && <span style={{ color: '#ff9800', marginLeft: '10px' }}> {t('status.modified')}</span>}
<span style={{ fontSize: '12px', color: '#666', marginLeft: '10px' }}>
({t('status.sessionStorageNote')})
</span>
{isReadonlyUrl ? (
<span>
{t('status.current')}: {diagramName}
</span>
) : (
<>
{currentDiagram
? `${t('status.current')}: ${currentDiagram.name}`
: diagramName || t('status.untitled')}
{hasUnsavedChanges && (
<span style={{ color: '#ff9800', marginLeft: '10px' }}>
{t('status.modified')}
</span>
)}
<span
style={{ fontSize: '12px', color: '#666', marginLeft: '10px' }}
>
({t('status.sessionStorageNote')})
</span>
</>
)}
</span>
</div>
@@ -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() {
<div className="dialog-overlay">
<div className="dialog">
<h2>{t('dialog.save.title')}</h2>
<div style={{
backgroundColor: '#fff3cd',
border: '1px solid #ffeeba',
padding: '15px',
borderRadius: '4px',
marginBottom: '20px'
}}>
<strong> {t('dialog.save.warningTitle')}:</strong> {t('dialog.save.warningMessage')}
<div
style={{
backgroundColor: '#fff3cd',
border: '1px solid #ffeeba',
padding: '15px',
borderRadius: '4px',
marginBottom: '20px'
}}
>
<strong> {t('dialog.save.warningTitle')}:</strong>{' '}
{t('dialog.save.warningMessage')}
<br />
<span dangerouslySetInnerHTML={{ __html: t('dialog.save.warningExport') }} />
<span
dangerouslySetInnerHTML={{
__html: t('dialog.save.warningExport')
}}
/>
</div>
<input
type="text"
placeholder={t('dialog.save.placeholder')}
value={diagramName}
onChange={(e) => 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
/>
<div className="dialog-buttons">
<button onClick={saveDiagram}>{t('dialog.save.btnSave')}</button>
<button onClick={() => setShowSaveDialog(false)}>{t('dialog.save.btnCancel')}</button>
<button
onClick={() => {
return setShowSaveDialog(false);
}}
>
{t('dialog.save.btnCancel')}
</button>
</div>
</div>
</div>
@@ -641,64 +851,100 @@ function App() {
<div className="dialog-overlay">
<div className="dialog">
<h2>{t('dialog.load.title')}</h2>
<div style={{
backgroundColor: '#fff3cd',
border: '1px solid #ffeeba',
padding: '15px',
borderRadius: '4px',
marginBottom: '20px'
}}>
<strong> {t('dialog.load.noteTitle')}:</strong> {t('dialog.load.noteMessage')}
<div
style={{
backgroundColor: '#fff3cd',
border: '1px solid #ffeeba',
padding: '15px',
borderRadius: '4px',
marginBottom: '20px'
}}
>
<strong> {t('dialog.load.noteTitle')}:</strong>{' '}
{t('dialog.load.noteMessage')}
</div>
<div className="diagram-list">
{diagrams.length === 0 ? (
<p>{t('dialog.load.noSavedDiagrams')}</p>
) : (
diagrams.map(diagram => (
<div key={diagram.id} className="diagram-item">
<div>
<strong>{diagram.name}</strong>
<br />
<small>{t('dialog.load.updated')}: {new Date(diagram.updatedAt).toLocaleString()}</small>
diagrams.map((diagram) => {
return (
<div key={diagram.id} className="diagram-item">
<div>
<strong>{diagram.name}</strong>
<br />
<small>
{t('dialog.load.updated')}:{' '}
{new Date(diagram.updatedAt).toLocaleString()}
</small>
</div>
<div className="diagram-actions">
<button
onClick={() => {
return loadDiagram(diagram, false);
}}
>
{t('dialog.load.btnLoad')}
</button>
<button
onClick={() => {
return deleteDiagram(diagram.id);
}}
>
{t('dialog.load.btnDelete')}
</button>
</div>
</div>
<div className="diagram-actions">
<button onClick={() => loadDiagram(diagram)}>{t('dialog.load.btnLoad')}</button>
<button onClick={() => deleteDiagram(diagram.id)}>{t('dialog.load.btnDelete')}</button>
</div>
</div>
))
);
})
)}
</div>
<div className="dialog-buttons">
<button onClick={() => setShowLoadDialog(false)}>{t('dialog.load.btnClose')}</button>
<button
onClick={() => {
return setShowLoadDialog(false);
}}
>
{t('dialog.load.btnClose')}
</button>
</div>
</div>
</div>
)}
{/* Export Dialog */}
{showExportDialog && (
<div className="dialog-overlay">
<div className="dialog">
<h2>{t('dialog.export.title')}</h2>
<div style={{
backgroundColor: '#d4edda',
border: '1px solid #c3e6cb',
padding: '15px',
borderRadius: '8px',
marginBottom: '20px'
}}>
<div
style={{
backgroundColor: '#d4edda',
border: '1px solid #c3e6cb',
padding: '15px',
borderRadius: '8px',
marginBottom: '20px'
}}
>
<p style={{ margin: '0 0 10px 0' }}>
<strong> {t('dialog.export.recommendedTitle')}:</strong> {t('dialog.export.recommendedMessage')}
<strong> {t('dialog.export.recommendedTitle')}:</strong>{' '}
{t('dialog.export.recommendedMessage')}
</p>
<p style={{ margin: 0, fontSize: '14px', color: '#155724' }}>
{t('dialog.export.noteMessage')}
</p>
</div>
<div className="dialog-buttons">
<button onClick={exportDiagram}>{t('dialog.export.btnDownload')}</button>
<button onClick={() => setShowExportDialog(false)}>{t('dialog.export.btnCancel')}</button>
<button onClick={exportDiagram}>
{t('dialog.export.btnDownload')}
</button>
<button
onClick={() => {
return setShowExportDialog(false);
}}
>
{t('dialog.export.btnCancel')}
</button>
</div>
</div>
</div>
@@ -706,7 +952,11 @@ function App() {
{/* Storage Manager */}
{showStorageManager && (
<StorageManager onClose={() => setShowStorageManager(false)} />
<StorageManager
onClose={() => {
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);
}}
/>
)}
</div>

View File

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

View File

@@ -9,11 +9,11 @@ interface Props {
onClose: () => void;
}
export const DiagramManager: React.FC<Props> = ({
onLoadDiagram,
currentDiagramId,
export const DiagramManager: React.FC<Props> = ({
onLoadDiagram,
currentDiagramId,
currentDiagramData,
onClose
onClose
}) => {
const [diagrams, setDiagrams] = useState<DiagramInfo[]>([]);
const [loading, setLoading] = useState(true);
@@ -36,7 +36,9 @@ export const DiagramManager: React.FC<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
}
};
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<Props> = ({
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<Props> = ({
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<Props> = ({
<div className="diagram-manager">
<div className="diagram-manager-header">
<h2>Diagram Manager</h2>
<button className="close-button" onClick={onClose}>×</button>
<button className="close-button" onClick={onClose}>
×
</button>
</div>
<div className="storage-info">
<span className={`storage-badge ${isServerStorage ? 'server' : 'local'}`}>
<span
className={`storage-badge ${isServerStorage ? 'server' : 'local'}`}
>
{isServerStorage ? '🌐 Server Storage' : '💾 Local Storage'}
</span>
{isServerStorage && (
@@ -171,14 +202,10 @@ export const DiagramManager: React.FC<Props> = ({
)}
</div>
{error && (
<div className="error-message">
{error}
</div>
)}
{error && <div className="error-message">{error}</div>}
<div className="diagram-manager-actions">
<button
<button
className="action-button primary"
onClick={() => {
setSaveName(currentDiagramData?.name || 'Untitled Diagram');
@@ -199,33 +226,49 @@ export const DiagramManager: React.FC<Props> = ({
<p className="hint">Save your current diagram to get started</p>
</div>
) : (
diagrams.map(diagram => (
<div key={diagram.id} className="diagram-item">
<div className="diagram-info">
<h3>{diagram.name}</h3>
<span className="diagram-meta">
Last modified: {diagram.lastModified.toLocaleString()}
{diagram.size && `${(diagram.size / 1024).toFixed(1)} KB`}
</span>
diagrams.map((diagram) => {
return (
<div key={diagram.id} className="diagram-item">
<div className="diagram-info">
<h3>{diagram.name}</h3>
<span className="diagram-meta">
Last modified: {diagram.lastModified.toLocaleString()}
{diagram.size &&
`${(diagram.size / 1024).toFixed(1)} KB`}
</span>
</div>
<div className="diagram-actions">
<button
className="action-button"
onClick={() => {
return handleLoad(diagram.id);
}}
disabled={loading}
>
{loading ? 'Loading...' : 'Load'}
</button>
<button
className="action-button share"
onClick={() => {
return handleCopyShareLink(diagram.id);
}}
title="Copy shareable link"
>
Share
</button>
<button
className="action-button danger"
onClick={() => {
return handleDelete(diagram.id);
}}
disabled={loading}
>
Delete
</button>
</div>
</div>
<div className="diagram-actions">
<button
className="action-button"
onClick={() => handleLoad(diagram.id)}
disabled={loading}
>
{loading ? 'Loading...' : 'Load'}
</button>
<button
className="action-button danger"
onClick={() => handleDelete(diagram.id)}
disabled={loading}
>
Delete
</button>
</div>
</div>
))
);
})
)}
</div>
)}
@@ -238,17 +281,27 @@ export const DiagramManager: React.FC<Props> = ({
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
/>
<div className="dialog-buttons">
<button onClick={handleSave}>Save</button>
<button onClick={() => setShowSaveDialog(false)}>Cancel</button>
<button
onClick={() => {
return setShowSaveDialog(false);
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
);
};
};

View File

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

View File

@@ -41,6 +41,10 @@
"noteMessage": "রপ্তানি করা JSON ফাইলগুলি পরে আমদানি করা যেতে পারে বা অন্যদের সাথে শেয়ার করা যেতে পারে।",
"btnDownload": "JSON ডাউনলোড করুন",
"btnCancel": "বাতিল করুন"
},
"readOnly": {
"mode": "শুধুমাত্র দেখার মোড",
"failed": "ডায়াগ্রাম লোড করতে ব্যর্থ"
}
},
"alert": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -41,6 +41,10 @@
"noteMessage": "निर्यात की गई JSON फ़ाइलों को बाद में आयात किया जा सकता है या दूसरों के साथ साझा किया जा सकता है।",
"btnDownload": "JSON डाउनलोड करें",
"btnCancel": "रद्द करें"
},
"readOnly": {
"mode": "केवल देखने का मोड",
"failed": "आरेख लोड करने में विफल"
}
},
"alert": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -41,6 +41,10 @@
"noteMessage": "Экспортированные файлы JSON можно импортировать позже или поделиться с другими.",
"btnDownload": "Скачать JSON",
"btnCancel": "Отмена"
},
"readOnly": {
"mode": "Режим только для чтения",
"failed": "Не удалось загрузить диаграмму"
}
},
"alert": {

View File

@@ -41,6 +41,10 @@
"noteMessage": "导出的 JSON 文件可以稍后导入或与他人共享。",
"btnDownload": "下载 JSON",
"btnCancel": "取消"
},
"readOnly": {
"mode": "阅读模式",
"failed": "加载图表失败"
}
},
"alert": {