feat(workspace): live synchronisation, verbindungsindikator, leere ordner persistenz und sperrkonfliktschutz
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s

This commit is contained in:
sandrodach committed 2026-10-26 10:18:02 +01:00
1 parent 35fd682fcd
commit 2f92e1f49a
4 files changed
+234 -14

No files matched your search

@@ -47,9 +47,10 @@ import {
Edit3,
Hash,
User as UserIcon,
Radio,
} from 'lucide-react';
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types';
import { api, getToken, triggerHaptic } from '../../lib/api';
import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected } from '../../lib/api';
import { CodeBlockItem } from './CodeBlockItem';
import { UserAutocomplete } from '../UserAutocomplete';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
@@ -128,6 +129,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [showIconPicker, setShowIconPicker] = useState(false);
const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false);
// WebSocket connection status for live indicator
const [wsConnected, setWsConnected] = useState<boolean>(() => isWebSocketConnected());
useEffect(() => {
const unsub = onWebSocketStatus((connected) => {
setWsConnected(connected);
if (connected) {
// Fresh re-fetch when reconnecting
api.getFolders().then(setCustomFolders).catch(() => {});
api.getSharedFolders().then(setSharedFolders).catch(() => {});
}
});
return unsub;
}, []);
// Folder state (synchronized with server)
const [customFolders, setCustomFolders] = useState<string[]>([]);
useEffect(() => {
@@ -266,6 +282,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const ensureLock = async (force = false): Promise<boolean> => {
if (!selectedDoc || selectedDoc.permission === 'read') return false;
if (isLockedByOther) return false;
if (holdsLockRef.current && !force) {
return true;
}
@@ -295,7 +312,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null;
}
if (!isReadOnly) {
if (!isReadOnly && !isLockedByOther) {
ensureLock();
}
};
@@ -309,14 +326,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (isDraggingBlockRef.current) {
return;
}
// Short grace period: if user clicked into another block in the doc, focus will cancel this.
// If user clicked outside or stopped editing, save and release the lock immediately!
// Generous grace period: if user clicked into another block in the doc or paused typing
idleUnlockTimerRef.current = setTimeout(async () => {
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
await flushPendingSave();
await releaseMyLock();
}
}, 1500);
}, 8000);
};
// Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD)
@@ -325,6 +341,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const executeSaveToBackend = async (docToSave: Document) => {
if (isReadOnly) return;
let saveSucceeded = false;
try {
const saved = await api.updateDocument(docToSave.id, {
title: docToSave.title,
@@ -334,6 +351,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
tags: docToSave.tags,
isPinned: docToSave.isPinned,
});
saveSucceeded = true;
setSelectedDoc((prev) =>
prev && prev.id === saved.id
? {
@@ -352,15 +370,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {});
}
} catch (e: any) {
console.error('Failed to save document to backend', e);
if (e.message && e.message.includes('423')) {
api.getDocLock(docToSave.id).then((res) => {
if (res.lock) setActiveLock(res.lock);
});
}).catch(() => {});
}
} finally {
if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) {
pendingSaveDocRef.current = null;
setSaveState('saved');
if (saveSucceeded) {
if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) {
pendingSaveDocRef.current = null;
setSaveState('saved');
}
} else {
// Keep pendingSaveDocRef so changes are preserved and not silently marked as saved!
setSaveState('saving');
}
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
if (idleUnlockTimerRef.current) {
@@ -370,7 +394,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
await releaseMyLock();
}
}, 2000);
}, 8000);
}
}
};
@@ -3677,6 +3701,52 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</button>
</>
)}
<div
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
wsConnected
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline - Verbindung wird wiederhergestellt'}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</div>
</div>
</div>
{/* Mobile Compact Header Bar (Live-Sync Status, Title, Drawer Switcher) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border">
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
onClick={() => setShowMobileFileDrawer(true)}
className="flex items-center gap-1.5 text-sm font-semibold text-main truncate hover:text-accent transition-colors cursor-pointer"
>
<FolderTree className="w-4 h-4 text-accent flex-shrink-0" />
<span className="truncate">{selectedDoc ? selectedDoc.title || 'Unbenannte Notiz' : 'Dateibaum & Ordner'}</span>
</button>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{selectedDoc && (
<button
type="button"
onClick={openShareModal}
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
title="Notiz teilen"
>
<Users className="w-4 h-4 text-accent" />
</button>
)}
<div
className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
wsConnected
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline - Verbindung wird wiederhergestellt'}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</div>
</div>
</div>
@@ -3845,6 +3915,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{isLockedByOther && (
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 rounded-card flex items-center gap-2.5 text-xs font-medium animate-pulse">
<Lock className="w-4 h-4 text-amber-500 flex-shrink-0" />
<div>
<span>Diese Notiz wird aktuell von <strong>{activeLock?.userName || 'einem anderen Benutzer'}</strong> bearbeitet.</span>
<p className="text-[11px] opacity-80 mt-0.5">Die Bearbeitung ist vorübergehend gesperrt, um Zeilen- und Datenverlust zu vermeiden.</p>
</div>
</div>
)}
{!isLockedByOther && selectedDoc.permission === 'read' && (
<div className="p-2.5 bg-blue-500/10 border border-blue-500/30 text-blue-700 dark:text-blue-300 rounded-card flex items-center gap-2 text-xs">
<Eye className="w-4 h-4 text-blue-500 flex-shrink-0" />
+48
View File
@@ -1019,6 +1019,8 @@ export function getOfflineQueue(): Thought[] {
let activeGlobalWS: WebSocket | null = null;
let wsReconnectTimer: any = null;
let wsListeners: ((msg: WSMessage) => void)[] = [];
let wsStatusListeners: ((connected: boolean) => void)[] = [];
let isWSConnected = false;
let manualDisconnect = false;
export function buildWebSocketURL(listId?: string): string {
@@ -1030,6 +1032,30 @@ export function buildWebSocketURL(listId?: string): string {
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
}
export function isWebSocketConnected(): boolean {
return isWSConnected && activeGlobalWS !== null && activeGlobalWS.readyState === WebSocket.OPEN;
}
export function onWebSocketStatus(callback: (connected: boolean) => void): () => void {
wsStatusListeners.push(callback);
callback(isWebSocketConnected());
return () => {
wsStatusListeners = wsStatusListeners.filter((fn) => fn !== callback);
};
}
function notifyStatusChange(connected: boolean) {
isWSConnected = connected;
wsStatusListeners.forEach((fn) => {
try {
fn(connected);
} catch (e) {
console.error('WS status listener error', e);
}
});
window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected } }));
}
export function connectWebSocket(): void {
const token = getToken();
if (!token) return;
@@ -1044,6 +1070,10 @@ export function connectWebSocket(): void {
const ws = new WebSocket(url);
activeGlobalWS = ws;
ws.onopen = () => {
notifyStatusChange(true);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as WSMessage;
@@ -1062,6 +1092,7 @@ export function connectWebSocket(): void {
ws.onclose = () => {
activeGlobalWS = null;
notifyStatusChange(false);
if (!manualDisconnect && getToken()) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = setTimeout(() => {
@@ -1071,15 +1102,31 @@ export function connectWebSocket(): void {
};
ws.onerror = () => {
notifyStatusChange(false);
try {
ws.close();
} catch {}
};
} catch (e) {
console.error('Failed to connect WebSocket', e);
notifyStatusChange(false);
}
}
// Ensure reconnect when browser recovers from sleep or tab visibility
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
if (getToken() && !isWebSocketConnected()) {
connectWebSocket();
}
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && getToken() && !isWebSocketConnected()) {
connectWebSocket();
}
});
}
export function disconnectWebSocket(): void {
manualDisconnect = true;
clearTimeout(wsReconnectTimer);
@@ -1089,6 +1136,7 @@ export function disconnectWebSocket(): void {
} catch {}
activeGlobalWS = null;
}
notifyStatusChange(false);
}
export function sendWebSocketMessage(msg: any): void {