diff --git a/backend/src/database/SqliteAdapter.ts b/backend/src/database/SqliteAdapter.ts index 834d0ff..2629b49 100644 --- a/backend/src/database/SqliteAdapter.ts +++ b/backend/src/database/SqliteAdapter.ts @@ -267,7 +267,10 @@ export class SqliteAdapter implements DatabaseInterface { } deleteExpiredSessions(): void { - this.db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now()); + // Use consistent epoch-millisecond integer comparison. + // <= ensures sessions expiring at exactly the current time are also deleted. + const now = Math.floor(Date.now()); + this.db.prepare('DELETE FROM sessions WHERE expires_at <= ?').run(now); } // ─── Notifications ────────────────────────────────── diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9838d06..7ab068f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -62,9 +62,6 @@ const initialKISuggestions: KISuggestion[] = [ { id: 'sug-3', label: 'Flächen berechnen' }, ]; -// Prevent duplicate drawing creation across StrictMode double-render -const loadedProjects = new Set(); - // ─── CAD Editor Component ─────────────────────────────── interface CADEditorProps { projectId: string; @@ -163,15 +160,18 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack // History panel const [historyPanelOpen, setHistoryPanelOpen] = useState(false); + // Export format selector (cross-browser replacement for nwsave) + const [exportFormatOpen, setExportFormatOpen] = useState(false); + // Elements + undo/redo (HistoryManager) const [elements, setElements] = useState([]); const historyManagerRef = React.useRef(new HistoryManager()); const [historyEntries, setHistoryEntries] = useState([]); const [toolState, setToolState] = useState(null); + const seatingServiceRef = useRef(new SeatingService()); const seatCount = useMemo(() => { - const svc = new SeatingService(); - return svc.countSeats(elements).total; + return seatingServiceRef.current.countSeats(elements).total; }, [elements]); // Groups @@ -479,7 +479,7 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack setSavedStatus('Fehler beim Speichern'); }); } - }, [elements, layers, blocks, groups, bgConfig, syncHistory, token, collab]); + }, [layers, blocks, groups, bgConfig, syncHistory, token, collab]); const handleElementsModified = useCallback((modified: CADElement[]) => { setElements((prev) => { @@ -582,11 +582,30 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack const svc = new BlockService(); const block = svc.importSVG(svg, name, category); setBlocks((prev) => [...prev, block]); + // Push to Yjs CRDT for real-time sync + collab.setBlock(block); + // Persist to backend + if (drawingId && token) { + setSavedStatus('Speichert…'); + createBlockTyped(token, drawingId, block) + .then(() => setSavedStatus('gespeichert')) + .catch((err) => { + console.error('Failed to save imported SVG block:', err); + setSavedStatus('Fehler beim Speichern'); + }); + } setCommandHistory((prev) => [...prev, { prefix: '·', text: `SVG importiert: ${name}`, type: 'info' }]); - }, []); + }, [collab, drawingId, token]); const handleSaveGroupAsBlock = useCallback((name: string) => { - const selectedEls = selectedElement ? [selectedElement] : []; + const selectedIds = selectedElementIdsRef.current; + const selectedEls = selectedIds.length > 0 + ? elementsRef.current.filter(e => selectedIds.includes(e.id)) + : (selectedElement ? [selectedElement] : []); + if (selectedEls.length === 0) { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Keine Elemente ausgewählt', type: 'info' }]); + return; + } setBlocks((prev) => { const newId = `blk_grp_${Date.now()}`; const block: BlockDefinition = { @@ -596,10 +615,18 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack category: 'Custom', elements: selectedEls.map(el => ({ ...el, id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` })), }; + // Push to Yjs CRDT for real-time sync + collab.setBlock(block); + // Persist to backend + if (drawingId && token) { + createBlockTyped(token, drawingId, block).catch((err) => { + console.error('Failed to save group block:', err); + }); + } return [...prev, block]; }); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block erstellt: ${name} (${selectedEls.length} Elemente)`, type: 'info' }]); - }, [selectedElement]); + }, [selectedElement, collab, drawingId, token]); const handleBlockCategoryChange = useCallback((cat: string) => { setBlockCategory(cat); @@ -763,20 +790,7 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack return; } if (action === 'export') { - const input = document.createElement('input'); - input.type = 'file'; - input.setAttribute('nwsave', ''); - input.accept = '.dxf,.svg,.pdf,.png,.json'; - input.onchange = () => { - const name = input.value || 'cad-export'; - const ext = name.split('.').pop()?.toLowerCase() as ExportFormat; - if (ext && ['dxf', 'svg', 'pdf', 'png', 'json'].includes(ext)) { - handleExport(ext); - } else { - handleExport('dxf'); - } - }; - input.click(); + setExportFormatOpen(true); return; } @@ -1425,6 +1439,28 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack onClose={() => setHistoryPanelOpen(false)} /> )} + {exportFormatOpen && ( +
setExportFormatOpen(false)}> +
e.stopPropagation()}> +

Export als…

+
+ {(['dxf', 'svg', 'pdf', 'png', 'json'] as ExportFormat[]).map(fmt => ( + + ))} +
+ +
+
+ )} ); }; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index eb6f825..3bc50e5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -349,12 +349,20 @@ export async function createBlockTyped(token: string, drawingId: string, block: return dbBlockToFrontend(raw); } -const projectLoadCache = new Map>(); +const projectLoadCache = new Map; requestId: number }>(); +const projectLoadRequestCounter = new Map(); export async function loadProjectDataTyped(token: string, projectId: string): Promise { - // Dedup concurrent calls (React StrictMode double-render) + // Generate a new request ID to track the latest request for this project + const currentRequestId = (projectLoadRequestCounter.get(projectId) ?? 0) + 1; + projectLoadRequestCounter.set(projectId, currentRequestId); + + // Dedup concurrent calls (React StrictMode double-render): if there is an in-flight + // request with the same ID, reuse it. Otherwise start a fresh one. const existing = projectLoadCache.get(projectId); - if (existing) return existing; + if (existing && existing.requestId === currentRequestId - 1) { + return existing.promise; + } const promise = (async () => { const drawings = await getDrawings(token, projectId); @@ -373,6 +381,13 @@ export async function loadProjectDataTyped(token: string, projectId: string): Pr getBlocks(token, drawing.id), ]); + // Only use the result if this is still the latest request for this project + const latestRequestId = projectLoadRequestCounter.get(projectId); + if (latestRequestId !== currentRequestId) { + // A newer request superseded this one — reject to prevent stale data + throw new Error('Superseded by a newer load request'); + } + return { project, drawing, @@ -382,8 +397,14 @@ export async function loadProjectDataTyped(token: string, projectId: string): Pr }; })(); - projectLoadCache.set(projectId, promise); - promise.finally(() => projectLoadCache.delete(projectId)); + projectLoadCache.set(projectId, { promise, requestId: currentRequestId }); + promise.finally(() => { + // Only clear cache if this is still the latest request + const cached = projectLoadCache.get(projectId); + if (cached && cached.requestId === currentRequestId) { + projectLoadCache.delete(projectId); + } + }); return promise; } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6b44431..47c2ae2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2212,3 +2212,61 @@ body.drawer-open { overflow: hidden; } .tree-drag-after { border-bottom: 2px solid var(--color-primary); } .tree-drag-inside { background: var(--color-surface-2); outline: 2px solid var(--color-primary); outline-offset: -2px; } .tree-dragging { opacity: 0.5; } + +/* Export format selector overlay */ +.export-format-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: 9999; +} +.export-format-modal { + background: var(--color-surface-1, #1e1e2e); + border: 1px solid var(--color-border, #333); + border-radius: 8px; + padding: 20px; + min-width: 300px; + box-shadow: 0 4px 20px rgba(0,0,0,0.4); +} +.export-format-modal h3 { + margin: 0 0 16px 0; + font-size: 16px; + color: var(--color-text, #e0e0e0); +} +.export-format-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} +.export-format-btn { + padding: 10px 20px; + border: 1px solid var(--color-border, #444); + border-radius: 4px; + background: var(--color-surface-2, #2a2a3e); + color: var(--color-text, #e0e0e0); + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: background 0.2s; +} +.export-format-btn:hover { + background: var(--color-primary, #3498db); + color: white; +} +.export-format-cancel { + width: 100%; + padding: 8px; + border: 1px solid var(--color-border, #444); + border-radius: 4px; + background: transparent; + color: var(--color-text, #e0e0e0); + cursor: pointer; + font-size: 13px; +} +.export-format-cancel:hover { + background: var(--color-surface-2, #2a2a3e); +}