fix: MEDIUM issues #23-#30 — stale deps, export modal, cache race, StrictMode dedup, SeatingService ref, SVG import persist, group-as-block multi-select, session expiry
This commit is contained in:
@@ -267,7 +267,10 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteExpiredSessions(): void {
|
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 ──────────────────────────────────
|
// ─── Notifications ──────────────────────────────────
|
||||||
|
|||||||
+59
-23
@@ -62,9 +62,6 @@ const initialKISuggestions: KISuggestion[] = [
|
|||||||
{ id: 'sug-3', label: 'Flächen berechnen' },
|
{ id: 'sug-3', label: 'Flächen berechnen' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Prevent duplicate drawing creation across StrictMode double-render
|
|
||||||
const loadedProjects = new Set<string>();
|
|
||||||
|
|
||||||
// ─── CAD Editor Component ───────────────────────────────
|
// ─── CAD Editor Component ───────────────────────────────
|
||||||
interface CADEditorProps {
|
interface CADEditorProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -163,15 +160,18 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
// History panel
|
// History panel
|
||||||
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
||||||
|
|
||||||
|
// Export format selector (cross-browser replacement for nwsave)
|
||||||
|
const [exportFormatOpen, setExportFormatOpen] = useState(false);
|
||||||
|
|
||||||
// Elements + undo/redo (HistoryManager)
|
// Elements + undo/redo (HistoryManager)
|
||||||
const [elements, setElements] = useState<CADElement[]>([]);
|
const [elements, setElements] = useState<CADElement[]>([]);
|
||||||
const historyManagerRef = React.useRef<HistoryManager>(new HistoryManager());
|
const historyManagerRef = React.useRef<HistoryManager>(new HistoryManager());
|
||||||
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
||||||
const [toolState, setToolState] = useState<ToolState | null>(null);
|
const [toolState, setToolState] = useState<ToolState | null>(null);
|
||||||
|
|
||||||
|
const seatingServiceRef = useRef<SeatingService>(new SeatingService());
|
||||||
const seatCount = useMemo(() => {
|
const seatCount = useMemo(() => {
|
||||||
const svc = new SeatingService();
|
return seatingServiceRef.current.countSeats(elements).total;
|
||||||
return svc.countSeats(elements).total;
|
|
||||||
}, [elements]);
|
}, [elements]);
|
||||||
|
|
||||||
// Groups
|
// Groups
|
||||||
@@ -479,7 +479,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
setSavedStatus('Fehler beim Speichern');
|
setSavedStatus('Fehler beim Speichern');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [elements, layers, blocks, groups, bgConfig, syncHistory, token, collab]);
|
}, [layers, blocks, groups, bgConfig, syncHistory, token, collab]);
|
||||||
|
|
||||||
const handleElementsModified = useCallback((modified: CADElement[]) => {
|
const handleElementsModified = useCallback((modified: CADElement[]) => {
|
||||||
setElements((prev) => {
|
setElements((prev) => {
|
||||||
@@ -582,11 +582,30 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
const svc = new BlockService();
|
const svc = new BlockService();
|
||||||
const block = svc.importSVG(svg, name, category);
|
const block = svc.importSVG(svg, name, category);
|
||||||
setBlocks((prev) => [...prev, block]);
|
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' }]);
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: `SVG importiert: ${name}`, type: 'info' }]);
|
||||||
}, []);
|
}, [collab, drawingId, token]);
|
||||||
|
|
||||||
const handleSaveGroupAsBlock = useCallback((name: string) => {
|
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) => {
|
setBlocks((prev) => {
|
||||||
const newId = `blk_grp_${Date.now()}`;
|
const newId = `blk_grp_${Date.now()}`;
|
||||||
const block: BlockDefinition = {
|
const block: BlockDefinition = {
|
||||||
@@ -596,10 +615,18 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
category: 'Custom',
|
category: 'Custom',
|
||||||
elements: selectedEls.map(el => ({ ...el, id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` })),
|
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];
|
return [...prev, block];
|
||||||
});
|
});
|
||||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block erstellt: ${name} (${selectedEls.length} Elemente)`, type: 'info' }]);
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block erstellt: ${name} (${selectedEls.length} Elemente)`, type: 'info' }]);
|
||||||
}, [selectedElement]);
|
}, [selectedElement, collab, drawingId, token]);
|
||||||
|
|
||||||
const handleBlockCategoryChange = useCallback((cat: string) => {
|
const handleBlockCategoryChange = useCallback((cat: string) => {
|
||||||
setBlockCategory(cat);
|
setBlockCategory(cat);
|
||||||
@@ -763,20 +790,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'export') {
|
if (action === 'export') {
|
||||||
const input = document.createElement('input');
|
setExportFormatOpen(true);
|
||||||
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();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1425,6 +1439,28 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
onClose={() => setHistoryPanelOpen(false)}
|
onClose={() => setHistoryPanelOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{exportFormatOpen && (
|
||||||
|
<div className="export-format-overlay" onClick={() => setExportFormatOpen(false)}>
|
||||||
|
<div className="export-format-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Export als…</h3>
|
||||||
|
<div className="export-format-buttons">
|
||||||
|
{(['dxf', 'svg', 'pdf', 'png', 'json'] as ExportFormat[]).map(fmt => (
|
||||||
|
<button
|
||||||
|
key={fmt}
|
||||||
|
className="export-format-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setExportFormatOpen(false);
|
||||||
|
handleExport(fmt);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmt.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="export-format-cancel" onClick={() => setExportFormatOpen(false)}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -349,12 +349,20 @@ export async function createBlockTyped(token: string, drawingId: string, block:
|
|||||||
return dbBlockToFrontend(raw);
|
return dbBlockToFrontend(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectLoadCache = new Map<string, Promise<ProjectData>>();
|
const projectLoadCache = new Map<string, { promise: Promise<ProjectData>; requestId: number }>();
|
||||||
|
const projectLoadRequestCounter = new Map<string, number>();
|
||||||
|
|
||||||
export async function loadProjectDataTyped(token: string, projectId: string): Promise<ProjectData> {
|
export async function loadProjectDataTyped(token: string, projectId: string): Promise<ProjectData> {
|
||||||
// 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);
|
const existing = projectLoadCache.get(projectId);
|
||||||
if (existing) return existing;
|
if (existing && existing.requestId === currentRequestId - 1) {
|
||||||
|
return existing.promise;
|
||||||
|
}
|
||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
const drawings = await getDrawings(token, projectId);
|
const drawings = await getDrawings(token, projectId);
|
||||||
@@ -373,6 +381,13 @@ export async function loadProjectDataTyped(token: string, projectId: string): Pr
|
|||||||
getBlocks(token, drawing.id),
|
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 {
|
return {
|
||||||
project,
|
project,
|
||||||
drawing,
|
drawing,
|
||||||
@@ -382,8 +397,14 @@ export async function loadProjectDataTyped(token: string, projectId: string): Pr
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
projectLoadCache.set(projectId, promise);
|
projectLoadCache.set(projectId, { promise, requestId: currentRequestId });
|
||||||
promise.finally(() => projectLoadCache.delete(projectId));
|
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;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2212,3 +2212,61 @@ body.drawer-open { overflow: hidden; }
|
|||||||
.tree-drag-after { border-bottom: 2px solid var(--color-primary); }
|
.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-drag-inside { background: var(--color-surface-2); outline: 2px solid var(--color-primary); outline-offset: -2px; }
|
||||||
.tree-dragging { opacity: 0.5; }
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user