mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-23 22:48:57 -05:00
feat: Add server-side storage for persistent diagram management
- Express.js backend for diagram CRUD operations - Filesystem persistence via Docker volume mount - Automatic storage detection in UI - Overwrite protection with confirmation dialogs - Server storage enabled by default in Docker Closes #48
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# Server Storage Configuration
|
||||
# Set to true to enable server-side storage for multi-device access
|
||||
# Default is true - diagrams are saved to ./diagrams directory on the host
|
||||
ENABLE_SERVER_STORAGE=true
|
||||
|
||||
# Git backup (optional)
|
||||
# Set to true to automatically commit changes to git
|
||||
ENABLE_GIT_BACKUP=false
|
||||
|
||||
# Storage path (inside container)
|
||||
# This is mapped to ./diagrams on the host via Docker volume
|
||||
STORAGE_PATH=/data/diagrams
|
||||
|
||||
# Backend port (usually doesn't need changing)
|
||||
BACKEND_PORT=3001
|
||||
33
Dockerfile
33
Dockerfile
@@ -21,14 +21,35 @@ COPY . .
|
||||
# Build the library first, then the app
|
||||
RUN npm run build:lib && npm run build:app
|
||||
|
||||
# Use Nginx as the production server
|
||||
FROM nginx:alpine
|
||||
# Use Node with nginx for production
|
||||
FROM node:22-alpine
|
||||
|
||||
# Install nginx
|
||||
RUN apk add --no-cache nginx
|
||||
|
||||
# Copy backend code
|
||||
COPY --from=build /app/packages/fossflow-backend /app/packages/fossflow-backend
|
||||
|
||||
# Copy the built React app to Nginx's web server directory
|
||||
COPY --from=build /app/packages/fossflow-app/build /usr/share/nginx/html
|
||||
|
||||
# Expose port 80 for the Nginx server
|
||||
EXPOSE 80
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/http.d/default.conf
|
||||
|
||||
# Start Nginx when the container runs
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
# Copy and set up entrypoint script
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Create data directory for persistent storage
|
||||
RUN mkdir -p /data/diagrams
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 80 3001
|
||||
|
||||
# Environment variables with defaults
|
||||
ENV ENABLE_SERVER_STORAGE=true
|
||||
ENV STORAGE_PATH=/data/diagrams
|
||||
ENV BACKEND_PORT=3001
|
||||
|
||||
# Start services
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
21
README.md
21
README.md
@@ -9,6 +9,13 @@ FossFLOW is a powerful, open-source Progressive Web App (PWA) for creating beaut
|
||||
|
||||
## Recent Updates (August 2025)
|
||||
|
||||
### Server Storage Support
|
||||
- **Persistent Storage** - Diagrams saved to server filesystem, persist across browser sessions
|
||||
- **Multi-device Access** - Access your diagrams from any device when using Docker deployment
|
||||
- **Automatic Detection** - UI automatically shows server storage when available
|
||||
- **Overwrite Protection** - Confirmation dialog when saving with duplicate names
|
||||
- **Docker Integration** - Server storage enabled by default in Docker deployments
|
||||
|
||||
### Enhanced Interaction Features
|
||||
- **Configurable Hotkeys** - Three profiles (QWERTY, SMNRCT, None) for tool selection with visual indicators
|
||||
- **Advanced Pan Controls** - Multiple pan methods including empty area drag, middle/right click, modifier keys (Ctrl/Alt), and keyboard navigation (Arrow/WASD/IJKL)
|
||||
@@ -40,6 +47,7 @@ FossFLOW is a powerful, open-source Progressive Web App (PWA) for creating beaut
|
||||
- 📤 **Import/Export** - Share diagrams as JSON files
|
||||
- 🎯 **Session Storage** - Quick save without dialogs
|
||||
- 🌐 **Offline Support** - Work without internet connection
|
||||
- 🗄️ **Server Storage** - Optional persistent storage when using Docker (enabled by default)
|
||||
|
||||
## Try it online
|
||||
|
||||
@@ -48,11 +56,18 @@ Go to https://stan-smith.github.io/FossFLOW/
|
||||
## 🐳 Quick Deploy with Docker
|
||||
|
||||
```bash
|
||||
# Using Docker Compose (recommended)
|
||||
# Using Docker Compose (recommended - includes persistent storage)
|
||||
docker compose up
|
||||
|
||||
# Or run directly from Docker Hub
|
||||
docker run -p 80:80 stnsmith/fossflow:latest
|
||||
# Or run directly from Docker Hub with persistent storage
|
||||
docker run -p 80:80 -v ./diagrams:/data/diagrams stnsmith/fossflow:latest
|
||||
```
|
||||
|
||||
Server storage is enabled by default in Docker. Your diagrams will be saved to `./diagrams` on the host.
|
||||
|
||||
To disable server storage, set `ENABLE_SERVER_STORAGE=false`:
|
||||
```bash
|
||||
docker run -p 80:80 -e ENABLE_SERVER_STORAGE=false stnsmith/fossflow:latest
|
||||
```
|
||||
|
||||
## Quick Start (Local Development)
|
||||
|
||||
13
compose.dev.yml
Normal file
13
compose.dev.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
fossflow:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:80"
|
||||
- "3001:3001"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- ENABLE_SERVER_STORAGE=true
|
||||
- STORAGE_PATH=/data/diagrams
|
||||
- ENABLE_GIT_BACKUP=false
|
||||
volumes:
|
||||
- ./diagrams:/data/diagrams
|
||||
@@ -5,4 +5,9 @@ services:
|
||||
ports:
|
||||
- 80:80
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NODE_ENV=production
|
||||
- ENABLE_SERVER_STORAGE=${ENABLE_SERVER_STORAGE:-true}
|
||||
- STORAGE_PATH=/data/diagrams
|
||||
- ENABLE_GIT_BACKUP=${ENABLE_GIT_BACKUP:-false}
|
||||
volumes:
|
||||
- ./diagrams:/data/diagrams
|
||||
16
docker-entrypoint.sh
Normal file
16
docker-entrypoint.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Start Node.js backend if server storage is enabled
|
||||
if [ "$ENABLE_SERVER_STORAGE" = "true" ]; then
|
||||
echo "Starting FossFLOW backend server..."
|
||||
cd /app/packages/fossflow-backend
|
||||
npm install --production
|
||||
node server.js &
|
||||
echo "Backend server started"
|
||||
else
|
||||
echo "Server storage disabled, backend not started"
|
||||
fi
|
||||
|
||||
# Start nginx
|
||||
echo "Starting nginx..."
|
||||
nginx -g "daemon off;"
|
||||
23
nginx.conf
Normal file
23
nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to Node.js backend
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
667
package-lock.json
generated
667
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,13 +9,16 @@
|
||||
"scripts": {
|
||||
"dev": "npm run start --workspace=packages/fossflow-app",
|
||||
"dev:lib": "npm run dev --workspace=packages/fossflow-lib",
|
||||
"dev:backend": "npm run dev --workspace=packages/fossflow-backend",
|
||||
"build": "npm run build:lib && npm run build:app",
|
||||
"build:lib": "npm run build --workspace=packages/fossflow-lib",
|
||||
"build:app": "npm run build --workspace=packages/fossflow-app",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"lint": "npm run lint --workspaces --if-present",
|
||||
"clean": "npm run clean --workspaces --if-present && rm -rf node_modules",
|
||||
"publish:lib": "npm run build:lib && npm publish --workspace=packages/fossflow-lib"
|
||||
"publish:lib": "npm run build:lib && npm publish --workspace=packages/fossflow-lib",
|
||||
"docker:build": "docker build -t fossflow:local .",
|
||||
"docker:run": "docker compose -f compose.dev.yml up"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.19.0",
|
||||
|
||||
@@ -8,6 +8,8 @@ import azureIsopack from '@isoflow/isopacks/dist/azure';
|
||||
import kubernetesIsopack from '@isoflow/isopacks/dist/kubernetes';
|
||||
import { DiagramData, mergeDiagramData, extractSavableData } from './diagramUtils';
|
||||
import { StorageManager } from './StorageManager';
|
||||
import { DiagramManager } from './components/DiagramManager';
|
||||
import { storageManager } from './services/storageService';
|
||||
import './App.css';
|
||||
|
||||
const icons = flattenCollections([
|
||||
@@ -42,6 +44,8 @@ function App() {
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [lastAutoSave, setLastAutoSave] = useState<Date | null>(null);
|
||||
const [showStorageManager, setShowStorageManager] = useState(false);
|
||||
const [showDiagramManager, setShowDiagramManager] = useState(false);
|
||||
const [serverStorageAvailable, setServerStorageAvailable] = useState(false);
|
||||
|
||||
// Initialize with empty diagram data
|
||||
// Create default colors for connectors
|
||||
@@ -65,6 +69,13 @@ function App() {
|
||||
fitToScreen: true
|
||||
});
|
||||
|
||||
// Check for server storage availability
|
||||
useEffect(() => {
|
||||
storageManager.initialize().then(() => {
|
||||
setServerStorageAvailable(storageManager.isServerStorage());
|
||||
}).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Load diagrams from localStorage on component mount
|
||||
useEffect(() => {
|
||||
const savedDiagrams = localStorage.getItem('fossflow-diagrams');
|
||||
@@ -128,6 +139,20 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a diagram with this name already exists (excluding current)
|
||||
const existingDiagram = diagrams.find(d =>
|
||||
d.name === diagramName.trim() && d.id !== currentDiagram?.id
|
||||
);
|
||||
|
||||
if (existingDiagram) {
|
||||
const confirmOverwrite = window.confirm(
|
||||
`A diagram named "${diagramName}" already exists in this session. This will overwrite it. Are you sure you want to continue?`
|
||||
);
|
||||
if (!confirmOverwrite) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Construct save data WITHOUT icons (they're loaded separately)
|
||||
const savedData = {
|
||||
title: diagramName,
|
||||
@@ -150,6 +175,11 @@ function App() {
|
||||
if (currentDiagram) {
|
||||
// Update existing diagram
|
||||
setDiagrams(diagrams.map(d => 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));
|
||||
newDiagram.id = existingDiagram.id;
|
||||
newDiagram.createdAt = existingDiagram.createdAt;
|
||||
} else {
|
||||
// Add new diagram
|
||||
setDiagrams([...diagrams, newDiagram]);
|
||||
@@ -342,6 +372,30 @@ function App() {
|
||||
// Trigger file input click
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleDiagramManagerLoad = (id: string, data: any) => {
|
||||
// Load diagram from server storage
|
||||
const mergedData: DiagramData = {
|
||||
...data,
|
||||
title: data.title || data.name || 'Loaded Diagram',
|
||||
icons: icons, // Always use app icons
|
||||
colors: data.colors?.length ? data.colors : defaultColors,
|
||||
fitToScreen: data.fitToScreen !== false
|
||||
};
|
||||
|
||||
setDiagramData(mergedData);
|
||||
setDiagramName(data.name || 'Loaded Diagram');
|
||||
setCurrentModel(mergedData);
|
||||
setCurrentDiagram({
|
||||
id,
|
||||
name: data.name || 'Loaded Diagram',
|
||||
data: mergedData,
|
||||
createdAt: data.created || new Date().toISOString(),
|
||||
updatedAt: data.lastModified || new Date().toISOString()
|
||||
});
|
||||
setFossflowKey(prev => prev + 1); // Force re-render
|
||||
setHasUnsavedChanges(false);
|
||||
};
|
||||
|
||||
// Auto-save functionality
|
||||
useEffect(() => {
|
||||
@@ -402,6 +456,14 @@ function App() {
|
||||
<div className="App">
|
||||
<div className="toolbar">
|
||||
<button onClick={newDiagram}>New Diagram</button>
|
||||
{serverStorageAvailable && (
|
||||
<button
|
||||
onClick={() => setShowDiagramManager(true)}
|
||||
style={{ backgroundColor: '#2196F3', color: 'white' }}
|
||||
>
|
||||
🌐 Server Storage
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowSaveDialog(true)}>Save (Session Only)</button>
|
||||
<button onClick={() => setShowLoadDialog(true)}>Load (Session Only)</button>
|
||||
<button
|
||||
@@ -601,6 +663,16 @@ function App() {
|
||||
{showStorageManager && (
|
||||
<StorageManager onClose={() => setShowStorageManager(false)} />
|
||||
)}
|
||||
|
||||
{/* Diagram Manager */}
|
||||
{showDiagramManager && (
|
||||
<DiagramManager
|
||||
onLoadDiagram={handleDiagramManagerLoad}
|
||||
currentDiagramId={currentDiagram?.id}
|
||||
currentDiagramData={currentModel || diagramData}
|
||||
onClose={() => setShowDiagramManager(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
237
packages/fossflow-app/src/components/DiagramManager.css
Normal file
237
packages/fossflow-app/src/components/DiagramManager.css
Normal file
@@ -0,0 +1,237 @@
|
||||
.diagram-manager-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.diagram-manager {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.diagram-manager-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.diagram-manager-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.storage-info {
|
||||
padding: 15px 20px;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.storage-badge {
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.storage-badge.server {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.storage-badge.local {
|
||||
background: #fff3e0;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
.storage-note {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 10px 20px;
|
||||
border-left: 4px solid #c62828;
|
||||
}
|
||||
|
||||
.diagram-manager-actions {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.action-button.primary {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-button.primary:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.action-button.danger {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-button.danger:hover {
|
||||
background: #da190b;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.diagram-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.diagram-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.diagram-item:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.diagram-info h3 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.diagram-meta {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.diagram-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.diagram-actions .action-button {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.save-dialog {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.save-dialog h3 {
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.save-dialog input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog-buttons button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-buttons button:last-child {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
218
packages/fossflow-app/src/components/DiagramManager.tsx
Normal file
218
packages/fossflow-app/src/components/DiagramManager.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { storageManager, DiagramInfo } from '../services/storageService';
|
||||
import './DiagramManager.css';
|
||||
|
||||
interface Props {
|
||||
onLoadDiagram: (id: string, data: any) => void;
|
||||
currentDiagramId?: string;
|
||||
currentDiagramData?: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DiagramManager: React.FC<Props> = ({
|
||||
onLoadDiagram,
|
||||
currentDiagramId,
|
||||
currentDiagramData,
|
||||
onClose
|
||||
}) => {
|
||||
const [diagrams, setDiagrams] = useState<DiagramInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isServerStorage, setIsServerStorage] = useState(false);
|
||||
const [saveName, setSaveName] = useState('');
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadDiagrams();
|
||||
}, []);
|
||||
|
||||
const loadDiagrams = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Initialize storage if not already done
|
||||
await storageManager.initialize();
|
||||
setIsServerStorage(storageManager.isServerStorage());
|
||||
|
||||
// Load diagram list
|
||||
const storage = storageManager.getStorage();
|
||||
const list = await storage.listDiagrams();
|
||||
setDiagrams(list);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load diagrams');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoad = async (id: string) => {
|
||||
try {
|
||||
const storage = storageManager.getStorage();
|
||||
const data = await storage.loadDiagram(id);
|
||||
onLoadDiagram(id, data);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load diagram');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('Are you sure you want to delete this diagram?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = storageManager.getStorage();
|
||||
await storage.deleteDiagram(id);
|
||||
await loadDiagrams(); // Refresh list
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete diagram');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveName.trim()) {
|
||||
setError('Please enter a diagram name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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
|
||||
);
|
||||
|
||||
if (existingDiagram) {
|
||||
const confirmOverwrite = window.confirm(
|
||||
`A diagram named "${saveName}" already exists. This will overwrite it. Are you sure you want to continue?`
|
||||
);
|
||||
if (!confirmOverwrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the existing diagram first
|
||||
await storage.deleteDiagram(existingDiagram.id);
|
||||
}
|
||||
|
||||
const dataToSave = {
|
||||
...currentDiagramData,
|
||||
name: saveName
|
||||
};
|
||||
|
||||
if (currentDiagramId) {
|
||||
// Update existing
|
||||
await storage.saveDiagram(currentDiagramId, dataToSave);
|
||||
} else {
|
||||
// Create new
|
||||
await storage.createDiagram(dataToSave);
|
||||
}
|
||||
|
||||
setShowSaveDialog(false);
|
||||
setSaveName('');
|
||||
await loadDiagrams(); // Refresh list
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save diagram');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="diagram-manager-overlay">
|
||||
<div className="diagram-manager">
|
||||
<div className="diagram-manager-header">
|
||||
<h2>Diagram Manager</h2>
|
||||
<button className="close-button" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="storage-info">
|
||||
<span className={`storage-badge ${isServerStorage ? 'server' : 'local'}`}>
|
||||
{isServerStorage ? '🌐 Server Storage' : '💾 Local Storage'}
|
||||
</span>
|
||||
{isServerStorage && (
|
||||
<span className="storage-note">
|
||||
Diagrams are saved on the server and available across all devices
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="diagram-manager-actions">
|
||||
<button
|
||||
className="action-button primary"
|
||||
onClick={() => {
|
||||
setSaveName(currentDiagramData?.name || 'Untitled Diagram');
|
||||
setShowSaveDialog(true);
|
||||
}}
|
||||
>
|
||||
💾 Save Current Diagram
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">Loading diagrams...</div>
|
||||
) : (
|
||||
<div className="diagram-list">
|
||||
{diagrams.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No saved diagrams</p>
|
||||
<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>
|
||||
</div>
|
||||
<div className="diagram-actions">
|
||||
<button
|
||||
className="action-button"
|
||||
onClick={() => handleLoad(diagram.id)}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
className="action-button danger"
|
||||
onClick={() => handleDelete(diagram.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Dialog */}
|
||||
{showSaveDialog && (
|
||||
<div className="save-dialog">
|
||||
<h3>Save Diagram</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Diagram name"
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="dialog-buttons">
|
||||
<button onClick={handleSave}>Save</button>
|
||||
<button onClick={() => setShowSaveDialog(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
191
packages/fossflow-app/src/services/storageService.ts
Normal file
191
packages/fossflow-app/src/services/storageService.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Model } from 'fossflow/dist/types';
|
||||
|
||||
export interface DiagramInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
lastModified: Date;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface StorageService {
|
||||
isAvailable(): Promise<boolean>;
|
||||
listDiagrams(): Promise<DiagramInfo[]>;
|
||||
loadDiagram(id: string): Promise<Model>;
|
||||
saveDiagram(id: string, data: Model): Promise<void>;
|
||||
deleteDiagram(id: string): Promise<void>;
|
||||
createDiagram(data: Model): Promise<string>;
|
||||
}
|
||||
|
||||
// Server Storage Implementation
|
||||
class ServerStorage implements StorageService {
|
||||
private baseUrl: string;
|
||||
private available: boolean | null = null;
|
||||
|
||||
constructor(baseUrl: string = '') {
|
||||
// In production (Docker), use relative paths (nginx proxy)
|
||||
// In development, use localhost:3001
|
||||
const isDevelopment = window.location.hostname === 'localhost' && window.location.port === '3000';
|
||||
this.baseUrl = baseUrl || (isDevelopment ? 'http://localhost:3001' : '');
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (this.available !== null) return this.available;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/storage/status`);
|
||||
const data = await response.json();
|
||||
this.available = data.enabled;
|
||||
return this.available;
|
||||
} catch (error) {
|
||||
console.log('Server storage not available:', error);
|
||||
this.available = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async listDiagrams(): Promise<DiagramInfo[]> {
|
||||
const response = await fetch(`${this.baseUrl}/api/diagrams`);
|
||||
if (!response.ok) throw new Error('Failed to list diagrams');
|
||||
|
||||
const diagrams = await response.json();
|
||||
return diagrams.map((d: any) => ({
|
||||
...d,
|
||||
lastModified: new Date(d.lastModified)
|
||||
}));
|
||||
}
|
||||
|
||||
async loadDiagram(id: string): Promise<Model> {
|
||||
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`);
|
||||
if (!response.ok) throw new Error('Failed to load diagram');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async saveDiagram(id: string, data: Model): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to save diagram');
|
||||
}
|
||||
|
||||
async deleteDiagram(id: string): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete diagram');
|
||||
}
|
||||
|
||||
async createDiagram(data: Model): Promise<string> {
|
||||
const response = await fetch(`${this.baseUrl}/api/diagrams`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create diagram');
|
||||
const result = await response.json();
|
||||
return result.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Session Storage Implementation (existing functionality)
|
||||
class SessionStorage implements StorageService {
|
||||
private readonly KEY_PREFIX = 'fossflow_diagram_';
|
||||
private readonly LIST_KEY = 'fossflow_diagrams';
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true; // Session storage is always available
|
||||
}
|
||||
|
||||
async listDiagrams(): Promise<DiagramInfo[]> {
|
||||
const listStr = sessionStorage.getItem(this.LIST_KEY);
|
||||
if (!listStr) return [];
|
||||
|
||||
const list = JSON.parse(listStr);
|
||||
return list.map((item: any) => ({
|
||||
...item,
|
||||
lastModified: new Date(item.lastModified)
|
||||
}));
|
||||
}
|
||||
|
||||
async loadDiagram(id: string): Promise<Model> {
|
||||
const data = sessionStorage.getItem(`${this.KEY_PREFIX}${id}`);
|
||||
if (!data) throw new Error('Diagram not found');
|
||||
return JSON.parse(data);
|
||||
}
|
||||
|
||||
async saveDiagram(id: string, data: Model): Promise<void> {
|
||||
sessionStorage.setItem(`${this.KEY_PREFIX}${id}`, JSON.stringify(data));
|
||||
|
||||
// Update list
|
||||
const list = await this.listDiagrams();
|
||||
const existing = list.findIndex(d => d.id === id);
|
||||
const info: DiagramInfo = {
|
||||
id,
|
||||
name: (data as any).name || 'Untitled Diagram',
|
||||
lastModified: new Date(),
|
||||
size: JSON.stringify(data).length
|
||||
};
|
||||
|
||||
if (existing >= 0) {
|
||||
list[existing] = info;
|
||||
} else {
|
||||
list.push(info);
|
||||
}
|
||||
|
||||
sessionStorage.setItem(this.LIST_KEY, JSON.stringify(list));
|
||||
}
|
||||
|
||||
async deleteDiagram(id: string): Promise<void> {
|
||||
sessionStorage.removeItem(`${this.KEY_PREFIX}${id}`);
|
||||
|
||||
// Update list
|
||||
const list = await this.listDiagrams();
|
||||
const filtered = list.filter(d => d.id !== id);
|
||||
sessionStorage.setItem(this.LIST_KEY, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
async createDiagram(data: Model): Promise<string> {
|
||||
const id = `diagram_${Date.now()}`;
|
||||
await this.saveDiagram(id, data);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
// Storage Manager - decides which storage to use
|
||||
class StorageManager {
|
||||
private serverStorage: ServerStorage;
|
||||
private sessionStorage: SessionStorage;
|
||||
private activeStorage: StorageService | null = null;
|
||||
|
||||
constructor() {
|
||||
this.serverStorage = new ServerStorage();
|
||||
this.sessionStorage = new SessionStorage();
|
||||
}
|
||||
|
||||
async initialize(): Promise<StorageService> {
|
||||
// Try server storage first
|
||||
if (await this.serverStorage.isAvailable()) {
|
||||
console.log('Using server storage');
|
||||
this.activeStorage = this.serverStorage;
|
||||
} else {
|
||||
console.log('Using session storage');
|
||||
this.activeStorage = this.sessionStorage;
|
||||
}
|
||||
return this.activeStorage;
|
||||
}
|
||||
|
||||
getStorage(): StorageService {
|
||||
if (!this.activeStorage) {
|
||||
throw new Error('Storage not initialized. Call initialize() first.');
|
||||
}
|
||||
return this.activeStorage;
|
||||
}
|
||||
|
||||
isServerStorage(): boolean {
|
||||
return this.activeStorage === this.serverStorage;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const storageManager = new StorageManager();
|
||||
20
packages/fossflow-backend/package.json
Normal file
20
packages/fossflow-backend/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "fossflow-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Optional backend server for FossFLOW persistent storage",
|
||||
"main": "server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
197
packages/fossflow-backend/server.js
Normal file
197
packages/fossflow-backend/server.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.BACKEND_PORT || 3001;
|
||||
|
||||
// Configuration from environment variables
|
||||
const STORAGE_ENABLED = process.env.ENABLE_SERVER_STORAGE === 'true';
|
||||
const STORAGE_PATH = process.env.STORAGE_PATH || '/data/diagrams';
|
||||
const ENABLE_GIT_BACKUP = process.env.ENABLE_GIT_BACKUP === 'true';
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// Health check / Storage status endpoint
|
||||
app.get('/api/storage/status', (req, res) => {
|
||||
res.json({
|
||||
enabled: STORAGE_ENABLED,
|
||||
gitBackup: ENABLE_GIT_BACKUP,
|
||||
version: '1.0.0'
|
||||
});
|
||||
});
|
||||
|
||||
// Only enable storage endpoints if storage is enabled
|
||||
if (STORAGE_ENABLED) {
|
||||
// Ensure storage directory exists
|
||||
async function ensureStorageDir() {
|
||||
try {
|
||||
await fs.access(STORAGE_PATH);
|
||||
} catch {
|
||||
await fs.mkdir(STORAGE_PATH, { recursive: true });
|
||||
console.log(`Created storage directory: ${STORAGE_PATH}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize storage
|
||||
ensureStorageDir().catch(console.error);
|
||||
|
||||
// List all diagrams
|
||||
app.get('/api/diagrams', async (req, res) => {
|
||||
try {
|
||||
const files = await fs.readdir(STORAGE_PATH);
|
||||
const diagrams = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json') && file !== 'metadata.json') {
|
||||
const filePath = path.join(STORAGE_PATH, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
diagrams.push({
|
||||
id: file.replace('.json', ''),
|
||||
name: data.name || 'Untitled Diagram',
|
||||
lastModified: stats.mtime,
|
||||
size: stats.size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json(diagrams);
|
||||
} catch (error) {
|
||||
console.error('Error listing diagrams:', error);
|
||||
res.status(500).json({ error: 'Failed to list diagrams' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get specific diagram
|
||||
app.get('/api/diagrams/:id', async (req, res) => {
|
||||
try {
|
||||
const filePath = path.join(STORAGE_PATH, `${req.params.id}.json`);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
res.json(JSON.parse(content));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.status(404).json({ error: 'Diagram not found' });
|
||||
} else {
|
||||
console.error('Error reading diagram:', error);
|
||||
res.status(500).json({ error: 'Failed to read diagram' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save or update diagram
|
||||
app.put('/api/diagrams/:id', async (req, res) => {
|
||||
try {
|
||||
const filePath = path.join(STORAGE_PATH, `${req.params.id}.json`);
|
||||
const data = {
|
||||
...req.body,
|
||||
id: req.params.id,
|
||||
lastModified: new Date().toISOString()
|
||||
};
|
||||
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
|
||||
|
||||
// Git backup if enabled
|
||||
if (ENABLE_GIT_BACKUP) {
|
||||
// TODO: Implement git commit
|
||||
console.log('Git backup not yet implemented');
|
||||
}
|
||||
|
||||
res.json({ success: true, id: req.params.id });
|
||||
} catch (error) {
|
||||
console.error('Error saving diagram:', error);
|
||||
res.status(500).json({ error: 'Failed to save diagram' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete diagram
|
||||
app.delete('/api/diagrams/:id', async (req, res) => {
|
||||
try {
|
||||
const filePath = path.join(STORAGE_PATH, `${req.params.id}.json`);
|
||||
await fs.unlink(filePath);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.status(404).json({ error: 'Diagram not found' });
|
||||
} else {
|
||||
console.error('Error deleting diagram:', error);
|
||||
res.status(500).json({ error: 'Failed to delete diagram' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new diagram
|
||||
app.post('/api/diagrams', async (req, res) => {
|
||||
try {
|
||||
const id = req.body.id || `diagram_${Date.now()}`;
|
||||
const filePath = path.join(STORAGE_PATH, `${id}.json`);
|
||||
|
||||
// Check if already exists
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return res.status(409).json({ error: 'Diagram already exists' });
|
||||
} catch {
|
||||
// File doesn't exist, proceed
|
||||
}
|
||||
|
||||
const data = {
|
||||
...req.body,
|
||||
id,
|
||||
created: new Date().toISOString(),
|
||||
lastModified: new Date().toISOString()
|
||||
};
|
||||
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
|
||||
res.status(201).json({ success: true, id });
|
||||
} catch (error) {
|
||||
console.error('Error creating diagram:', error);
|
||||
res.status(500).json({ error: 'Failed to create diagram' });
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
// Storage disabled - return appropriate responses
|
||||
app.get('/api/diagrams', (req, res) => {
|
||||
res.status(503).json({ error: 'Server storage is disabled' });
|
||||
});
|
||||
|
||||
app.get('/api/diagrams/:id', (req, res) => {
|
||||
res.status(503).json({ error: 'Server storage is disabled' });
|
||||
});
|
||||
|
||||
app.put('/api/diagrams/:id', (req, res) => {
|
||||
res.status(503).json({ error: 'Server storage is disabled' });
|
||||
});
|
||||
|
||||
app.delete('/api/diagrams/:id', (req, res) => {
|
||||
res.status(503).json({ error: 'Server storage is disabled' });
|
||||
});
|
||||
|
||||
app.post('/api/diagrams', (req, res) => {
|
||||
res.status(503).json({ error: 'Server storage is disabled' });
|
||||
});
|
||||
}
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`FossFLOW Backend Server running on port ${PORT}`);
|
||||
console.log(`Server storage: ${STORAGE_ENABLED ? 'ENABLED' : 'DISABLED'}`);
|
||||
if (STORAGE_ENABLED) {
|
||||
console.log(`Storage path: ${STORAGE_PATH}`);
|
||||
console.log(`Git backup: ${ENABLE_GIT_BACKUP ? 'ENABLED' : 'DISABLED'}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user