feat(collab): redesign note collaboration with REST auto-save, opt-in live session, and block locking
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
This commit is contained in:
1 parent
50b017f387
commit
0e06cc5577
11 files changed
+1373
-239
No files matched your search
@@ -15,11 +15,14 @@ import {
|
||||
Sparkles,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus } from '../../lib/useDocumentSync';
|
||||
import { Document, DocumentBlock, User, BlockLockInfo, LiveSessionInfo, WSMessage } from '../../lib/types';
|
||||
import { api, getToken, onWebSocketMessage } from '../../lib/api';
|
||||
import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus, getUserColor } from '../../lib/useDocumentSync';
|
||||
import { OutlinerTree } from './OutlinerTree';
|
||||
|
||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||
export type DocLiveState = 'idle' | 'connecting' | 'active' | 'blocked' | 'locked_by_other';
|
||||
|
||||
export interface DocumentEditorProps {
|
||||
docId: string;
|
||||
currentUser?: User | null;
|
||||
@@ -34,6 +37,9 @@ export interface DocumentEditorProps {
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
onBackMobile?: () => void;
|
||||
onSyncStatusChange?: (status: DocSyncStatus) => void;
|
||||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||
onLiveStateChange?: (state: DocLiveState, initiatorName?: string) => void;
|
||||
registerLiveToggle?: (handler: () => void) => void;
|
||||
}
|
||||
|
||||
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
|
||||
@@ -154,6 +160,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onWikiLinkClick,
|
||||
onBackMobile,
|
||||
onSyncStatusChange,
|
||||
onSaveStatusChange,
|
||||
onLiveStateChange,
|
||||
registerLiveToggle,
|
||||
}) => {
|
||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
||||
@@ -165,11 +174,29 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||
|
||||
// Live Collaboration & Locks state
|
||||
const [liveSession, setLiveSession] = useState<LiveSessionInfo | null>(null);
|
||||
const [isLiveEnabled, setIsLiveEnabled] = useState<boolean>(false);
|
||||
const [activeBlockLocks, setActiveBlockLocks] = useState<Record<string, BlockLockInfo>>({});
|
||||
|
||||
// AutoSave state
|
||||
const [saveStatus, setSaveStatus] = useState<DocSaveStatus>('saved');
|
||||
const [, setSaveErrorMessage] = useState<string | null>(null);
|
||||
const saveTimeoutRef = useRef<number | null>(null);
|
||||
const isSavingRef = useRef<boolean>(false);
|
||||
const lastSavedSnapshotRef = useRef<string>('');
|
||||
const isInitialLoadRef = useRef<boolean>(true);
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const titleDebounceRef = useRef<number | null>(null);
|
||||
const docRef = useRef<Document | null>(doc);
|
||||
docRef.current = doc;
|
||||
|
||||
const currentTitleRef = useRef<string>(title);
|
||||
currentTitleRef.current = title;
|
||||
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
|
||||
currentBlocksRef.current = blocks;
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
||||
const focusTitle = () => {
|
||||
@@ -209,6 +236,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
return doc.permission === 'owner' || doc.permission === 'write';
|
||||
}, [doc]);
|
||||
|
||||
// Is this document completely locked because another user has an active live session?
|
||||
const isLockedByOtherLive = Boolean(liveSession && liveSession.initiatorId !== currentUser?.id);
|
||||
const effectiveCanEdit = canEdit && !isLockedByOtherLive;
|
||||
|
||||
// Handle incoming WebSocket events for THIS document room
|
||||
const handleRemoteEvent = useCallback(
|
||||
(evt: DocSyncEvent) => {
|
||||
@@ -334,6 +365,137 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
[docId, onDocMetadataChange]
|
||||
);
|
||||
|
||||
// Extract all #tags present across the current blocks
|
||||
const extractedTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
for (const b of blocks) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
tagSet.add(m.slice(1).toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(tagSet);
|
||||
}, [blocks]);
|
||||
|
||||
const currentTagsRef = useRef<string[]>(extractedTags);
|
||||
currentTagsRef.current = extractedTags;
|
||||
|
||||
// AutoSave implementation (authoritative persistence via HTTP REST)
|
||||
const currentSnapshot = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
title,
|
||||
blocks,
|
||||
tags: extractedTags,
|
||||
});
|
||||
}, [title, blocks, extractedTags]);
|
||||
|
||||
const currentSnapshotRef = useRef<string>(currentSnapshot);
|
||||
currentSnapshotRef.current = currentSnapshot;
|
||||
|
||||
const executeAutoSave = useCallback(
|
||||
async (immediate: boolean = false) => {
|
||||
if (!docId || !canEdit || isLockedByOtherLive) return;
|
||||
if (isSavingRef.current && !immediate) return;
|
||||
|
||||
const snapshot = currentSnapshotRef.current;
|
||||
if (snapshot === lastSavedSnapshotRef.current) {
|
||||
setSaveStatus('saved');
|
||||
onSaveStatusChange?.('saved');
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingRef.current = true;
|
||||
setSaveStatus('saving');
|
||||
onSaveStatusChange?.('saving');
|
||||
|
||||
try {
|
||||
const updated = await api.updateDocument(docId, {
|
||||
title: currentTitleRef.current,
|
||||
blocks: currentBlocksRef.current,
|
||||
tags: currentTagsRef.current,
|
||||
});
|
||||
isSavingRef.current = false;
|
||||
lastSavedSnapshotRef.current = snapshot;
|
||||
setSaveStatus('saved');
|
||||
setSaveErrorMessage(null);
|
||||
onSaveStatusChange?.('saved');
|
||||
if (docRef.current) {
|
||||
docRef.current = updated;
|
||||
onDocMetadataChange(updated);
|
||||
}
|
||||
} catch (err: any) {
|
||||
isSavingRef.current = false;
|
||||
const msg = err?.message || 'Fehler beim Speichern';
|
||||
setSaveStatus('error');
|
||||
setSaveErrorMessage(msg);
|
||||
onSaveStatusChange?.('error', msg);
|
||||
}
|
||||
},
|
||||
[docId, canEdit, isLockedByOtherLive, onSaveStatusChange, onDocMetadataChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialLoadRef.current) return;
|
||||
if (currentSnapshot === lastSavedSnapshotRef.current) return;
|
||||
|
||||
setSaveStatus('unsaved');
|
||||
onSaveStatusChange?.('unsaved');
|
||||
|
||||
if (saveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
saveTimeoutRef.current = window.setTimeout(() => {
|
||||
saveTimeoutRef.current = null;
|
||||
executeAutoSave();
|
||||
}, 800);
|
||||
|
||||
return () => {
|
||||
if (saveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [currentSnapshot, executeAutoSave, onSaveStatusChange]);
|
||||
|
||||
// Flush on page unload / pagehide via keepalive fetch
|
||||
useEffect(() => {
|
||||
const handleUnload = () => {
|
||||
if (saveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
const snapshot = currentSnapshotRef.current;
|
||||
if (snapshot !== lastSavedSnapshotRef.current && canEdit && !isLockedByOtherLive) {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
fetch(`/api/v1/documents/${docId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: currentTitleRef.current,
|
||||
blocks: currentBlocksRef.current,
|
||||
tags: currentTagsRef.current,
|
||||
}),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
window.addEventListener('pagehide', handleUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleUnload);
|
||||
window.removeEventListener('pagehide', handleUnload);
|
||||
handleUnload();
|
||||
};
|
||||
}, [docId, canEdit, isLockedByOtherLive]);
|
||||
|
||||
const {
|
||||
status: syncStatus,
|
||||
remoteCursors,
|
||||
@@ -343,12 +505,158 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
sendBlockDeleted,
|
||||
sendBlockMoved,
|
||||
sendTitleUpdated,
|
||||
} = useDocumentSync(docId, handleRemoteEvent, canEdit, currentUser);
|
||||
} = useDocumentSync(docId, handleRemoteEvent, effectiveCanEdit, currentUser, isLiveEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
onSyncStatusChange?.(syncStatus);
|
||||
}, [syncStatus, onSyncStatusChange]);
|
||||
|
||||
const computedLiveState: DocLiveState = useMemo(() => {
|
||||
if (isLockedByOtherLive) return 'locked_by_other';
|
||||
if (!isLiveEnabled) return 'idle';
|
||||
if (syncStatus === 'connecting' || syncStatus === 'reconnecting') return 'connecting';
|
||||
if (syncStatus === 'blocked') return 'blocked';
|
||||
if (syncStatus === 'connected') return 'active';
|
||||
return 'idle';
|
||||
}, [isLockedByOtherLive, isLiveEnabled, syncStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
onLiveStateChange?.(computedLiveState, liveSession?.initiatorName);
|
||||
}, [computedLiveState, liveSession?.initiatorName, onLiveStateChange]);
|
||||
|
||||
const handleToggleLive = useCallback(() => {
|
||||
if (isLiveEnabled) {
|
||||
api.endLiveSession(docId).catch(() => {});
|
||||
setIsLiveEnabled(false);
|
||||
setLiveSession(null);
|
||||
} else {
|
||||
if (isLockedByOtherLive) return;
|
||||
api.startLiveSession(docId)
|
||||
.then((session) => {
|
||||
setLiveSession(session);
|
||||
setIsLiveEnabled(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[DocumentEditor] Failed to start live session:', err);
|
||||
});
|
||||
}
|
||||
}, [docId, isLiveEnabled, isLockedByOtherLive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registerLiveToggle) {
|
||||
registerLiveToggle(handleToggleLive);
|
||||
}
|
||||
}, [registerLiveToggle, handleToggleLive]);
|
||||
|
||||
// Load initial locks on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.getDocLocks(docId)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.liveSession) {
|
||||
setLiveSession(res.liveSession);
|
||||
if (res.liveSession.initiatorId === currentUser?.id) {
|
||||
setIsLiveEnabled(true);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(res.blockLocks)) {
|
||||
const map: Record<string, BlockLockInfo> = {};
|
||||
for (const l of res.blockLocks) {
|
||||
map[l.blockId] = l;
|
||||
}
|
||||
setActiveBlockLocks(map);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [docId, currentUser?.id]);
|
||||
|
||||
// Subscribe to realtime lock & live session events
|
||||
useEffect(() => {
|
||||
const unsub = onWebSocketMessage((msg: WSMessage) => {
|
||||
if (msg.event === 'DOC_BLOCK_LOCKED' && msg.payload?.docId === docId) {
|
||||
setActiveBlockLocks((prev) => ({
|
||||
...prev,
|
||||
[msg.payload.blockId]: msg.payload,
|
||||
}));
|
||||
} else if (msg.event === 'DOC_BLOCK_UNLOCKED' && msg.payload?.docId === docId) {
|
||||
setActiveBlockLocks((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[msg.payload.blockId];
|
||||
return next;
|
||||
});
|
||||
} else if (msg.event === 'DOC_LIVE_STARTED' && msg.payload?.docId === docId) {
|
||||
setLiveSession(msg.payload);
|
||||
} else if (msg.event === 'DOC_LIVE_ENDED' && msg.payload?.docId === docId) {
|
||||
setLiveSession(null);
|
||||
if (liveSession?.initiatorId === currentUser?.id) {
|
||||
setIsLiveEnabled(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
}, [docId, currentUser?.id, liveSession?.initiatorId]);
|
||||
|
||||
// Block focus/blur locks
|
||||
const localUserColor = useMemo(
|
||||
() => getUserColor(currentUser?.id || 'anon'),
|
||||
[currentUser?.id]
|
||||
);
|
||||
const focusedBlockIdRef = useRef<string | null>(null);
|
||||
const lockKeepaliveTimerRef = useRef<number | null>(null);
|
||||
|
||||
const handleFocusBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
if (!effectiveCanEdit) return;
|
||||
focusedBlockIdRef.current = blockId;
|
||||
api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {});
|
||||
|
||||
if (lockKeepaliveTimerRef.current !== null) {
|
||||
window.clearInterval(lockKeepaliveTimerRef.current);
|
||||
}
|
||||
lockKeepaliveTimerRef.current = window.setInterval(() => {
|
||||
if (focusedBlockIdRef.current === blockId) {
|
||||
api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {});
|
||||
}
|
||||
}, 15000);
|
||||
},
|
||||
[docId, effectiveCanEdit, localUserColor]
|
||||
);
|
||||
|
||||
const handleBlurBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
if (lockKeepaliveTimerRef.current !== null) {
|
||||
window.clearInterval(lockKeepaliveTimerRef.current);
|
||||
lockKeepaliveTimerRef.current = null;
|
||||
}
|
||||
if (focusedBlockIdRef.current === blockId) {
|
||||
focusedBlockIdRef.current = null;
|
||||
}
|
||||
api.unlockBlock(docId, blockId).catch(() => {});
|
||||
executeAutoSave();
|
||||
},
|
||||
[docId, executeAutoSave]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (lockKeepaliveTimerRef.current !== null) {
|
||||
window.clearInterval(lockKeepaliveTimerRef.current);
|
||||
lockKeepaliveTimerRef.current = null;
|
||||
}
|
||||
if (focusedBlockIdRef.current) {
|
||||
api.unlockBlock(docId, focusedBlockIdRef.current).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
// Fetch isolated document state on mount
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
@@ -361,11 +669,31 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
.getDocument(docId)
|
||||
.then((fetched) => {
|
||||
if (isCancelled) return;
|
||||
const normalized = ensureNonEmptyBlocks(docId, fetched.blocks);
|
||||
setDoc(fetched);
|
||||
setTitle(fetched.title || 'Unbenannte Notiz');
|
||||
setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks));
|
||||
setBlocks(normalized);
|
||||
setLoading(false);
|
||||
onDocMetadataChange(fetched);
|
||||
|
||||
const initialTags: string[] = [];
|
||||
for (const b of normalized) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
const tag = m.slice(1).toLowerCase();
|
||||
if (!initialTags.includes(tag)) initialTags.push(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSavedSnapshotRef.current = JSON.stringify({
|
||||
title: fetched.title || 'Unbenannte Notiz',
|
||||
blocks: normalized,
|
||||
tags: initialTags,
|
||||
});
|
||||
isInitialLoadRef.current = false;
|
||||
setSaveStatus('saved');
|
||||
onSaveStatusChange?.('saved');
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (isCancelled) return;
|
||||
@@ -382,20 +710,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
// Extract all #tags present across the current blocks
|
||||
const extractedTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
for (const b of blocks) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
tagSet.add(m.slice(1).toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(tagSet);
|
||||
}, [blocks]);
|
||||
|
||||
// Keep parent sidebar metadata in sync when blocks/tags change
|
||||
useEffect(() => {
|
||||
if (!docRef.current) return;
|
||||
@@ -425,7 +739,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
}, [remoteCursors]);
|
||||
|
||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!canEdit) return;
|
||||
if (!effectiveCanEdit) return;
|
||||
const nextTitle = e.target.value;
|
||||
setTitle(nextTitle);
|
||||
|
||||
@@ -434,7 +748,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
}
|
||||
titleDebounceRef.current = window.setTimeout(() => {
|
||||
titleDebounceRef.current = null;
|
||||
sendTitleUpdated(nextTitle);
|
||||
if (isLiveEnabled) {
|
||||
sendTitleUpdated(nextTitle);
|
||||
}
|
||||
}, 250);
|
||||
};
|
||||
|
||||
@@ -442,8 +758,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
titleDebounceRef.current = null;
|
||||
sendTitleUpdated(title);
|
||||
if (isLiveEnabled) {
|
||||
sendTitleUpdated(title);
|
||||
}
|
||||
}
|
||||
executeAutoSave();
|
||||
};
|
||||
|
||||
// Flush title debouncing on window blur so background window never holds stale pending title
|
||||
@@ -452,25 +771,31 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
titleDebounceRef.current = null;
|
||||
sendTitleUpdated(title);
|
||||
if (isLiveEnabled) {
|
||||
sendTitleUpdated(title);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
return () => window.removeEventListener('blur', handleWindowBlur);
|
||||
}, [title, sendTitleUpdated]);
|
||||
}, [title, isLiveEnabled, sendTitleUpdated]);
|
||||
|
||||
const handleUpdateBlock = useCallback(
|
||||
(updatedBlock: DocumentBlock) => {
|
||||
if (!effectiveCanEdit) return;
|
||||
setBlocks((prev) =>
|
||||
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
|
||||
);
|
||||
sendBlockUpdated(updatedBlock);
|
||||
if (isLiveEnabled) {
|
||||
sendBlockUpdated(updatedBlock);
|
||||
}
|
||||
},
|
||||
[sendBlockUpdated]
|
||||
[effectiveCanEdit, isLiveEnabled, sendBlockUpdated]
|
||||
);
|
||||
|
||||
const handleCreateBlock = useCallback(
|
||||
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||||
if (!effectiveCanEdit) return;
|
||||
setBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
if (afterBlockId) {
|
||||
@@ -478,33 +803,41 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
if (idx >= 0) {
|
||||
next.splice(idx + 1, 0, newBlock);
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||
if (isLiveEnabled) {
|
||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
next.push(newBlock);
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||
if (isLiveEnabled) {
|
||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
},
|
||||
[docId, sendBlockCreated]
|
||||
[docId, effectiveCanEdit, isLiveEnabled, sendBlockCreated]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
if (!effectiveCanEdit) return;
|
||||
setBlocks((prev) => {
|
||||
const filtered = prev.filter((b) => b.id !== blockId);
|
||||
const normalized = ensureNonEmptyBlocks(docId, filtered);
|
||||
sendBlockDeleted(blockId, normalized);
|
||||
if (isLiveEnabled) {
|
||||
sendBlockDeleted(blockId, normalized);
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
},
|
||||
[docId, sendBlockDeleted]
|
||||
[docId, effectiveCanEdit, isLiveEnabled, sendBlockDeleted]
|
||||
);
|
||||
|
||||
const handleMoveBlock = useCallback(
|
||||
(blockId: string, newIndex: number, level?: number) => {
|
||||
if (!effectiveCanEdit) return;
|
||||
setBlocks((prev) => {
|
||||
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
||||
if (fromIdx === -1) return prev;
|
||||
@@ -517,11 +850,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length));
|
||||
copy.splice(boundedIdx, 0, updatedMoving);
|
||||
const normalized = ensureNonEmptyBlocks(docId, copy);
|
||||
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
|
||||
if (isLiveEnabled) {
|
||||
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
},
|
||||
[docId, sendBlockMoved]
|
||||
[docId, effectiveCanEdit, isLiveEnabled, sendBlockMoved]
|
||||
);
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
@@ -537,7 +872,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
|
||||
const handleSelectIcon = async (icon: string) => {
|
||||
setShowEmojiPicker(false);
|
||||
if (!doc || !canEdit) return;
|
||||
if (!doc || !effectiveCanEdit) return;
|
||||
try {
|
||||
const updated = await api.updateDocument(doc.id, { icon });
|
||||
setDoc(updated);
|
||||
@@ -790,6 +1125,16 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Session Active Banner (when another collaborator is live) */}
|
||||
{isLockedByOtherLive && (
|
||||
<div className="flex items-center gap-2 px-4 sm:px-6 py-2.5 bg-status-warn/15 border-b border-status-warn/30 text-status-warn text-xs font-medium">
|
||||
<Lock className="w-4 h-4 shrink-0" />
|
||||
<span>
|
||||
Live-Sitzung aktiv (Bearbeitet von <strong>{liveSession?.initiatorName || 'anderer Nutzer'}</strong>) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
|
||||
{zoomedBlock ? (
|
||||
@@ -830,7 +1175,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
}
|
||||
}}
|
||||
placeholder="Ast-Titel…"
|
||||
readOnly={!canEdit}
|
||||
readOnly={!effectiveCanEdit}
|
||||
className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
|
||||
/>
|
||||
</div>
|
||||
@@ -850,7 +1195,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
{childBlocks.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-faint border border-dashed border-border rounded-card my-2">
|
||||
<p className="mb-2">Dieser Ast hat noch keine Unterpunkte.</p>
|
||||
{canEdit && (
|
||||
{effectiveCanEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -875,7 +1220,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
<OutlinerTree
|
||||
docId={docId}
|
||||
blocks={childBlocks}
|
||||
canEdit={canEdit}
|
||||
canEdit={effectiveCanEdit}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={setZoomedBlockId}
|
||||
remoteCursors={remoteCursors}
|
||||
@@ -887,6 +1232,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
activeBlockLocks={activeBlockLocks}
|
||||
onFocusBlock={handleFocusBlock}
|
||||
onBlurBlock={handleBlurBlock}
|
||||
currentUserId={currentUser?.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -900,13 +1249,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
|
||||
onClick={() => effectiveCanEdit && setShowEmojiPicker((prev) => !prev)}
|
||||
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none"
|
||||
title="Icon ändern"
|
||||
>
|
||||
{doc?.icon || '📝'}
|
||||
</button>
|
||||
{showEmojiPicker && canEdit && (
|
||||
{showEmojiPicker && effectiveCanEdit && (
|
||||
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
|
||||
{EMOJI_PALETTE.map((emoji) => (
|
||||
<button
|
||||
@@ -926,7 +1275,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
readOnly={!canEdit}
|
||||
readOnly={!effectiveCanEdit}
|
||||
autoFocus={autoFocusTitle}
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleTitleBlur}
|
||||
@@ -939,7 +1288,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
<OutlinerTree
|
||||
docId={docId}
|
||||
blocks={blocks}
|
||||
canEdit={canEdit}
|
||||
canEdit={effectiveCanEdit}
|
||||
levelOffset={0}
|
||||
onZoomBlock={setZoomedBlockId}
|
||||
remoteCursors={remoteCursors}
|
||||
@@ -951,6 +1300,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
activeBlockLocks={activeBlockLocks}
|
||||
onFocusBlock={handleFocusBlock}
|
||||
onBlurBlock={handleBlurBlock}
|
||||
currentUserId={currentUser?.id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Maximize2,
|
||||
Link as LinkIcon,
|
||||
FileText,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||
@@ -65,6 +66,10 @@ export interface OutlinerBlockProps {
|
||||
isDragTarget?: boolean;
|
||||
isTargeted?: boolean;
|
||||
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||
isLockedByOther?: boolean;
|
||||
lockedByName?: string;
|
||||
lockedByColor?: string;
|
||||
onBlurBlock?: (blockId: string) => void;
|
||||
}
|
||||
|
||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
@@ -91,6 +96,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
isDragTarget,
|
||||
isTargeted,
|
||||
registerTextareaRef,
|
||||
isLockedByOther,
|
||||
lockedByName,
|
||||
lockedByColor,
|
||||
onBlurBlock,
|
||||
}) => {
|
||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||
@@ -109,6 +118,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onUpdateBlockRef.current = onUpdateBlock;
|
||||
const onFocusBlockRef = useRef(onFocusBlock);
|
||||
onFocusBlockRef.current = onFocusBlock;
|
||||
const onBlurBlockRef = useRef(onBlurBlock);
|
||||
onBlurBlockRef.current = onBlurBlock;
|
||||
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
||||
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
||||
const onDeleteBlockRef = useRef(onDeleteBlock);
|
||||
@@ -318,6 +329,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
flushDebounce(localContentRef.current);
|
||||
}
|
||||
sendCursorMovedRef.current?.(null);
|
||||
onBlurBlockRef.current?.(block.id);
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
|
||||
@@ -336,7 +348,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
if (!canEdit || isLockedByOther) return;
|
||||
|
||||
// Slash command navigation
|
||||
if (slashSearch !== null && filteredSlashCommands.length > 0) {
|
||||
@@ -486,7 +498,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
{/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
|
||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||
{canEdit && (
|
||||
{canEdit && !isLockedByOther && (
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
@@ -520,7 +532,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
{block.type === 'todo' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canEdit}
|
||||
disabled={!canEdit || isLockedByOther}
|
||||
onClick={() =>
|
||||
flushDebounce(localContent, { checked: !block.checked })
|
||||
}
|
||||
@@ -555,6 +567,16 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
{/* Main Block Content */}
|
||||
<div className="flex-1 min-w-0 pr-1 relative">
|
||||
{isLockedByOther && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold text-white shadow-xs mb-1 select-none"
|
||||
style={{ backgroundColor: lockedByColor || '#f59e0b' }}
|
||||
title={`Wird gerade bearbeitet von ${lockedByName || 'anderer Nutzer'}`}
|
||||
>
|
||||
<Lock className="w-2.5 h-2.5" />
|
||||
<span>{lockedByName || 'Gesperrt'}</span>
|
||||
</div>
|
||||
)}
|
||||
{block.type === 'divider' ? (
|
||||
<div className="py-2">
|
||||
<hr className="border-t border-border" />
|
||||
@@ -578,7 +600,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit}
|
||||
readOnly={!canEdit || isLockedByOther}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={handleClick}
|
||||
@@ -598,7 +620,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit}
|
||||
readOnly={!canEdit || isLockedByOther}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={handleClick}
|
||||
@@ -618,7 +640,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit}
|
||||
readOnly={!canEdit || isLockedByOther}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { DocumentBlock } from '../../lib/types';
|
||||
import { DocumentBlock, BlockLockInfo } from '../../lib/types';
|
||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||
import { OutlinerBlock } from './OutlinerBlock';
|
||||
|
||||
@@ -19,6 +19,10 @@ export interface OutlinerTreeProps {
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
initialFocusedBlockId?: string | null;
|
||||
activeBlockLocks?: Record<string, BlockLockInfo>;
|
||||
onFocusBlock?: (blockId: string) => void;
|
||||
onBlurBlock?: (blockId: string) => void;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
||||
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
@@ -36,6 +40,10 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
initialFocusedBlockId,
|
||||
activeBlockLocks,
|
||||
onFocusBlock,
|
||||
onBlurBlock,
|
||||
currentUserId,
|
||||
}) => {
|
||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
||||
@@ -319,35 +327,49 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
|
||||
<OutlinerBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
index={index}
|
||||
maxAllowedLevel={maxAllowedLevel}
|
||||
hasChildren={hasChildren}
|
||||
isFocused={focusedBlockId === block.id}
|
||||
canEdit={canEdit}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={onZoomBlock}
|
||||
activeRemoteCursors={cursorsByBlock[block.id] || []}
|
||||
sendCursorMoved={sendCursorMoved}
|
||||
onFocusBlock={setFocusedBlockId}
|
||||
onUpdateBlock={onUpdateBlock}
|
||||
onCreateBlockBelow={handleCreateBlockBelow}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onIndentBlock={handleIndentBlock}
|
||||
onNavigateFocus={handleNavigateFocus}
|
||||
onTagClick={onTagClick}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
onDragStartBlock={handleDragStartBlock}
|
||||
onDragOverBlock={handleDragOverBlock}
|
||||
onDropBlock={handleDropBlock}
|
||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||
isTargeted={highlightedBlockId === block.id}
|
||||
registerTextareaRef={registerTextareaRef}
|
||||
/>
|
||||
))}
|
||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => {
|
||||
const lockInfo = activeBlockLocks?.[block.id];
|
||||
const isLockedByOther = Boolean(lockInfo && lockInfo.userId !== currentUserId);
|
||||
|
||||
return (
|
||||
<OutlinerBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
index={index}
|
||||
maxAllowedLevel={maxAllowedLevel}
|
||||
hasChildren={hasChildren}
|
||||
isFocused={focusedBlockId === block.id}
|
||||
canEdit={canEdit}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={onZoomBlock}
|
||||
activeRemoteCursors={cursorsByBlock[block.id] || []}
|
||||
sendCursorMoved={sendCursorMoved}
|
||||
onFocusBlock={(bId) => {
|
||||
setFocusedBlockId(bId);
|
||||
onFocusBlock?.(bId);
|
||||
}}
|
||||
onBlurBlock={(bId) => {
|
||||
onBlurBlock?.(bId);
|
||||
}}
|
||||
onUpdateBlock={onUpdateBlock}
|
||||
onCreateBlockBelow={handleCreateBlockBelow}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onIndentBlock={handleIndentBlock}
|
||||
onNavigateFocus={handleNavigateFocus}
|
||||
onTagClick={onTagClick}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
onDragStartBlock={handleDragStartBlock}
|
||||
onDragOverBlock={handleDragOverBlock}
|
||||
onDropBlock={handleDropBlock}
|
||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||
isTargeted={highlightedBlockId === block.id}
|
||||
registerTextareaRef={registerTextareaRef}
|
||||
isLockedByOther={isLockedByOther}
|
||||
lockedByName={lockInfo?.userName}
|
||||
lockedByColor={lockInfo?.userColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{canEdit && (
|
||||
<div className="pt-2 pl-2">
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
PanelLeftOpen,
|
||||
ChevronLeft,
|
||||
Radio,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Document,
|
||||
@@ -47,7 +51,7 @@ import {
|
||||
import { DocSyncStatus } from '../../lib/useDocumentSync';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
import { DocumentEditor } from './DocumentEditor';
|
||||
import { DocumentEditor, DocSaveStatus, DocLiveState } from './DocumentEditor';
|
||||
|
||||
interface WorkspaceViewProps {
|
||||
currentUser?: User | null;
|
||||
@@ -303,6 +307,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
|
||||
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
|
||||
const [activeDocSyncStatus, setActiveDocSyncStatus] = useState<DocSyncStatus>('connected');
|
||||
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
|
||||
const [docSaveError, setDocSaveError] = useState<string | null>(null);
|
||||
const [docLiveState, setDocLiveState] = useState<DocLiveState>('idle');
|
||||
const [docLiveInitiator, setDocLiveInitiator] = useState<string | null>(null);
|
||||
const [showWsBlockedModal, setShowWsBlockedModal] = useState<boolean>(false);
|
||||
const liveToggleRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onRealtimeStatus((connected, transport) => {
|
||||
@@ -1133,45 +1143,136 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Header Section: Live Sync Status (identical to SharedListsView) */}
|
||||
{/* Right Header Section: Save Status & Live Collaboration Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const isDocActive = subView === 'editor' && Boolean(selectedDocId);
|
||||
const isConnected = isDocActive
|
||||
? activeDocSyncStatus === 'connected'
|
||||
: realtimeConnected;
|
||||
const isConnecting = isDocActive && (activeDocSyncStatus === 'connecting' || activeDocSyncStatus === 'reconnecting');
|
||||
const isSSE = !isDocActive && realtimeTransport === 'sse';
|
||||
|
||||
return (
|
||||
{subView === 'editor' && selectedDocId ? (
|
||||
<>
|
||||
{/* Save Status Pill */}
|
||||
<div
|
||||
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
|
||||
isConnected
|
||||
? isSSE
|
||||
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||||
: isConnecting
|
||||
? 'bg-amber-500/10 border-amber-500/30 text-amber-500'
|
||||
: 'bg-canvas border-border text-muted'
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
|
||||
docSaveStatus === 'saved'
|
||||
? 'bg-canvas border-border text-muted'
|
||||
: docSaveStatus === 'saving'
|
||||
? 'bg-panel border-accent/40 text-accent animate-pulse'
|
||||
: docSaveStatus === 'unsaved'
|
||||
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
|
||||
: 'bg-status-err/10 border-status-err/30 text-status-err'
|
||||
}`}
|
||||
title={
|
||||
isConnected
|
||||
? isSSE
|
||||
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
|
||||
: 'Live synchronisiert (WebSocket online)'
|
||||
: isConnecting
|
||||
? 'Verbindung wird aufgebaut…'
|
||||
: 'Offline'
|
||||
docSaveStatus === 'saved'
|
||||
? 'Alle Änderungen per HTTPS gesichert'
|
||||
: docSaveStatus === 'saving'
|
||||
? 'Änderungen werden per HTTPS gespeichert…'
|
||||
: docSaveStatus === 'unsaved'
|
||||
? 'Ungespeicherte Änderungen (Auto-Save aktiv)'
|
||||
: docSaveError || 'Fehler beim Speichern'
|
||||
}
|
||||
>
|
||||
<Radio
|
||||
className={`w-3.5 h-3.5 ${
|
||||
isConnected ? 'animate-pulse' : isConnecting ? 'animate-spin' : ''
|
||||
}`}
|
||||
/>
|
||||
{docSaveStatus === 'saved' && (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||||
<span>Gespeichert</span>
|
||||
</>
|
||||
)}
|
||||
{docSaveStatus === 'saving' && (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||||
<span>Speichert…</span>
|
||||
</>
|
||||
)}
|
||||
{docSaveStatus === 'unsaved' && (
|
||||
<>
|
||||
<Clock className="w-3.5 h-3.5 text-status-warn" />
|
||||
<span>Ungespeichert</span>
|
||||
</>
|
||||
)}
|
||||
{docSaveStatus === 'error' && (
|
||||
<>
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
|
||||
<span>Fehler</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Interactive Live Collaboration Button (replaces passive radio icon) */}
|
||||
{docLiveState === 'locked_by_other' ? (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-warn/15 border border-status-warn/40 text-status-warn select-none"
|
||||
title={`Live-Zusammenarbeit aktiv durch ${docLiveInitiator || 'anderer Nutzer'}`}
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate max-w-[120px]">Live: {docLiveInitiator || 'Gesperrt'}</span>
|
||||
</div>
|
||||
) : docLiveState === 'blocked' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWsBlockedModal(true)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-err/15 border border-status-err/40 text-status-err hover:bg-status-err/25 transition-colors cursor-pointer"
|
||||
title="WebSockets im Firmennetzwerk blockiert – Hier klicken für Infos"
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-status-err animate-pulse shrink-0" />
|
||||
<span>Live blockiert</span>
|
||||
</button>
|
||||
) : docLiveState === 'connecting' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-amber-500/15 border border-amber-500/40 text-amber-500 opacity-80 cursor-wait"
|
||||
>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-amber-500 shrink-0" />
|
||||
<span>Verbinde…</span>
|
||||
</button>
|
||||
) : docLiveState === 'active' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => liveToggleRef.current?.()}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-semibold bg-emerald-500/15 border border-emerald-500/40 text-emerald-500 hover:bg-emerald-500/25 transition-colors cursor-pointer"
|
||||
title="Live-Zusammenarbeit beenden (Tastendruck / WebSocket)"
|
||||
>
|
||||
<Radio className="w-3.5 h-3.5 text-emerald-500 animate-pulse shrink-0" />
|
||||
<span>Live aktiv</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => liveToggleRef.current?.()}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-main hover:border-accent transition-colors cursor-pointer"
|
||||
title="Live-Zusammenarbeit aktivieren (WebSockets)"
|
||||
>
|
||||
<Radio className="w-3.5 h-3.5 text-muted hover:text-accent shrink-0" />
|
||||
<span>Live</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Standalone Realtime Status Indicator for Todos / Hashtags */
|
||||
(() => {
|
||||
const isConnected = realtimeConnected;
|
||||
const isSSE = realtimeTransport === 'sse';
|
||||
return (
|
||||
<div
|
||||
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
|
||||
isConnected
|
||||
? isSSE
|
||||
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||||
: 'bg-canvas border-border text-muted'
|
||||
}`}
|
||||
title={
|
||||
isConnected
|
||||
? isSSE
|
||||
? 'Synchronisiert (Server-Sent Events / HTTPS)'
|
||||
: 'Synchronisiert (WebSocket online)'
|
||||
: 'Offline'
|
||||
}
|
||||
>
|
||||
<Radio
|
||||
className={`w-3.5 h-3.5 ${isConnected ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1414,6 +1515,17 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onWikiLinkClick={handleWikiLinkClick}
|
||||
onBackMobile={() => setMobileShowDetail(false)}
|
||||
onSyncStatusChange={setActiveDocSyncStatus}
|
||||
onSaveStatusChange={(status, err) => {
|
||||
setDocSaveStatus(status);
|
||||
setDocSaveError(err || null);
|
||||
}}
|
||||
onLiveStateChange={(state, initiator) => {
|
||||
setDocLiveState(state);
|
||||
setDocLiveInitiator(initiator || null);
|
||||
}}
|
||||
registerLiveToggle={(handler) => {
|
||||
liveToggleRef.current = handler;
|
||||
}}
|
||||
/>
|
||||
) : subView === 'tags' ? (
|
||||
/* =========================================================================
|
||||
@@ -2403,6 +2515,50 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* WebSocket Blocked Modal */}
|
||||
{showWsBlockedModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150">
|
||||
<div className="w-full max-w-md bg-canvas border border-border rounded-card shadow-modal p-6 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-status-err/15 flex items-center justify-center shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-status-err" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-main">
|
||||
WebSockets in diesem Netzwerk blockiert
|
||||
</h3>
|
||||
<p className="text-xs text-muted mt-1 leading-relaxed">
|
||||
Deine Firewall oder dein Firmen-Proxy blockiert WebSocket-Verbindungen (<code className="text-accent bg-panel px-1 py-0.5 rounded">ws://</code> / <code className="text-accent bg-panel px-1 py-0.5 rounded">wss://</code>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-subtle border border-border rounded-btn p-3 text-xs space-y-2 text-main">
|
||||
<div className="flex items-center gap-2 text-status-ok font-semibold">
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span>Kein Datenverlust: Auto-Save per HTTPS ist aktiv!</span>
|
||||
</div>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Jede getippte Zeile und alle Änderungen werden zuverlässig über Standard-HTTPS-Anfragen im Hintergrund gespeichert.
|
||||
</p>
|
||||
<p className="text-muted text-[11px] leading-relaxed">
|
||||
Lediglich die gleichzeitige Tastendruck-Echtzeitanzeige mit anderen Nutzern im selben Moment ist ohne WebSockets eingeschränkt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWsBlockedModal(false)}
|
||||
className="btn-action px-4 py-1.5 text-xs font-semibold bg-accent text-on-accent hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
Verstanden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user