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
+1306
-172
No files matched your search
@@ -15,11 +15,14 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Plus,
|
Plus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
import { Document, DocumentBlock, User, BlockLockInfo, LiveSessionInfo, WSMessage } from '../../lib/types';
|
||||||
import { api } from '../../lib/api';
|
import { api, getToken, onWebSocketMessage } from '../../lib/api';
|
||||||
import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus } from '../../lib/useDocumentSync';
|
import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus, getUserColor } from '../../lib/useDocumentSync';
|
||||||
import { OutlinerTree } from './OutlinerTree';
|
import { OutlinerTree } from './OutlinerTree';
|
||||||
|
|
||||||
|
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||||
|
export type DocLiveState = 'idle' | 'connecting' | 'active' | 'blocked' | 'locked_by_other';
|
||||||
|
|
||||||
export interface DocumentEditorProps {
|
export interface DocumentEditorProps {
|
||||||
docId: string;
|
docId: string;
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
@@ -34,6 +37,9 @@ export interface DocumentEditorProps {
|
|||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
onBackMobile?: () => void;
|
onBackMobile?: () => void;
|
||||||
onSyncStatusChange?: (status: DocSyncStatus) => void;
|
onSyncStatusChange?: (status: DocSyncStatus) => void;
|
||||||
|
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||||
|
onLiveStateChange?: (state: DocLiveState, initiatorName?: string) => void;
|
||||||
|
registerLiveToggle?: (handler: () => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
|
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
|
||||||
@@ -154,6 +160,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
onBackMobile,
|
onBackMobile,
|
||||||
onSyncStatusChange,
|
onSyncStatusChange,
|
||||||
|
onSaveStatusChange,
|
||||||
|
onLiveStateChange,
|
||||||
|
registerLiveToggle,
|
||||||
}) => {
|
}) => {
|
||||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
||||||
@@ -165,11 +174,29 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
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 titleInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const titleDebounceRef = useRef<number | null>(null);
|
const titleDebounceRef = useRef<number | null>(null);
|
||||||
const docRef = useRef<Document | null>(doc);
|
const docRef = useRef<Document | null>(doc);
|
||||||
docRef.current = doc;
|
docRef.current = doc;
|
||||||
|
|
||||||
|
const currentTitleRef = useRef<string>(title);
|
||||||
|
currentTitleRef.current = title;
|
||||||
|
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
|
||||||
|
currentBlocksRef.current = blocks;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
||||||
const focusTitle = () => {
|
const focusTitle = () => {
|
||||||
@@ -209,6 +236,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
return doc.permission === 'owner' || doc.permission === 'write';
|
return doc.permission === 'owner' || doc.permission === 'write';
|
||||||
}, [doc]);
|
}, [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
|
// Handle incoming WebSocket events for THIS document room
|
||||||
const handleRemoteEvent = useCallback(
|
const handleRemoteEvent = useCallback(
|
||||||
(evt: DocSyncEvent) => {
|
(evt: DocSyncEvent) => {
|
||||||
@@ -334,6 +365,137 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
[docId, onDocMetadataChange]
|
[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 {
|
const {
|
||||||
status: syncStatus,
|
status: syncStatus,
|
||||||
remoteCursors,
|
remoteCursors,
|
||||||
@@ -343,12 +505,158 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
sendBlockDeleted,
|
sendBlockDeleted,
|
||||||
sendBlockMoved,
|
sendBlockMoved,
|
||||||
sendTitleUpdated,
|
sendTitleUpdated,
|
||||||
} = useDocumentSync(docId, handleRemoteEvent, canEdit, currentUser);
|
} = useDocumentSync(docId, handleRemoteEvent, effectiveCanEdit, currentUser, isLiveEnabled);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSyncStatusChange?.(syncStatus);
|
onSyncStatusChange?.(syncStatus);
|
||||||
}, [syncStatus, onSyncStatusChange]);
|
}, [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
|
// Fetch isolated document state on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
@@ -361,11 +669,31 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
.getDocument(docId)
|
.getDocument(docId)
|
||||||
.then((fetched) => {
|
.then((fetched) => {
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
const normalized = ensureNonEmptyBlocks(docId, fetched.blocks);
|
||||||
setDoc(fetched);
|
setDoc(fetched);
|
||||||
setTitle(fetched.title || 'Unbenannte Notiz');
|
setTitle(fetched.title || 'Unbenannte Notiz');
|
||||||
setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks));
|
setBlocks(normalized);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
onDocMetadataChange(fetched);
|
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) => {
|
.catch((err: any) => {
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
@@ -382,20 +710,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
};
|
};
|
||||||
}, [docId]);
|
}, [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
|
// Keep parent sidebar metadata in sync when blocks/tags change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!docRef.current) return;
|
if (!docRef.current) return;
|
||||||
@@ -425,7 +739,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}, [remoteCursors]);
|
}, [remoteCursors]);
|
||||||
|
|
||||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!canEdit) return;
|
if (!effectiveCanEdit) return;
|
||||||
const nextTitle = e.target.value;
|
const nextTitle = e.target.value;
|
||||||
setTitle(nextTitle);
|
setTitle(nextTitle);
|
||||||
|
|
||||||
@@ -434,7 +748,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}
|
}
|
||||||
titleDebounceRef.current = window.setTimeout(() => {
|
titleDebounceRef.current = window.setTimeout(() => {
|
||||||
titleDebounceRef.current = null;
|
titleDebounceRef.current = null;
|
||||||
|
if (isLiveEnabled) {
|
||||||
sendTitleUpdated(nextTitle);
|
sendTitleUpdated(nextTitle);
|
||||||
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -442,8 +758,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
if (titleDebounceRef.current !== null) {
|
if (titleDebounceRef.current !== null) {
|
||||||
window.clearTimeout(titleDebounceRef.current);
|
window.clearTimeout(titleDebounceRef.current);
|
||||||
titleDebounceRef.current = null;
|
titleDebounceRef.current = null;
|
||||||
|
if (isLiveEnabled) {
|
||||||
sendTitleUpdated(title);
|
sendTitleUpdated(title);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
executeAutoSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Flush title debouncing on window blur so background window never holds stale pending title
|
// 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) {
|
if (titleDebounceRef.current !== null) {
|
||||||
window.clearTimeout(titleDebounceRef.current);
|
window.clearTimeout(titleDebounceRef.current);
|
||||||
titleDebounceRef.current = null;
|
titleDebounceRef.current = null;
|
||||||
|
if (isLiveEnabled) {
|
||||||
sendTitleUpdated(title);
|
sendTitleUpdated(title);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('blur', handleWindowBlur);
|
window.addEventListener('blur', handleWindowBlur);
|
||||||
return () => window.removeEventListener('blur', handleWindowBlur);
|
return () => window.removeEventListener('blur', handleWindowBlur);
|
||||||
}, [title, sendTitleUpdated]);
|
}, [title, isLiveEnabled, sendTitleUpdated]);
|
||||||
|
|
||||||
const handleUpdateBlock = useCallback(
|
const handleUpdateBlock = useCallback(
|
||||||
(updatedBlock: DocumentBlock) => {
|
(updatedBlock: DocumentBlock) => {
|
||||||
|
if (!effectiveCanEdit) return;
|
||||||
setBlocks((prev) =>
|
setBlocks((prev) =>
|
||||||
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
|
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
|
||||||
);
|
);
|
||||||
|
if (isLiveEnabled) {
|
||||||
sendBlockUpdated(updatedBlock);
|
sendBlockUpdated(updatedBlock);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[sendBlockUpdated]
|
[effectiveCanEdit, isLiveEnabled, sendBlockUpdated]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateBlock = useCallback(
|
const handleCreateBlock = useCallback(
|
||||||
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||||||
|
if (!effectiveCanEdit) return;
|
||||||
setBlocks((prev) => {
|
setBlocks((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
if (afterBlockId) {
|
if (afterBlockId) {
|
||||||
@@ -478,33 +803,41 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
next.splice(idx + 1, 0, newBlock);
|
next.splice(idx + 1, 0, newBlock);
|
||||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||||
|
if (isLiveEnabled) {
|
||||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||||
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
next.push(newBlock);
|
next.push(newBlock);
|
||||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||||
|
if (isLiveEnabled) {
|
||||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||||
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[docId, sendBlockCreated]
|
[docId, effectiveCanEdit, isLiveEnabled, sendBlockCreated]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteBlock = useCallback(
|
const handleDeleteBlock = useCallback(
|
||||||
(blockId: string) => {
|
(blockId: string) => {
|
||||||
|
if (!effectiveCanEdit) return;
|
||||||
setBlocks((prev) => {
|
setBlocks((prev) => {
|
||||||
const filtered = prev.filter((b) => b.id !== blockId);
|
const filtered = prev.filter((b) => b.id !== blockId);
|
||||||
const normalized = ensureNonEmptyBlocks(docId, filtered);
|
const normalized = ensureNonEmptyBlocks(docId, filtered);
|
||||||
|
if (isLiveEnabled) {
|
||||||
sendBlockDeleted(blockId, normalized);
|
sendBlockDeleted(blockId, normalized);
|
||||||
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[docId, sendBlockDeleted]
|
[docId, effectiveCanEdit, isLiveEnabled, sendBlockDeleted]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMoveBlock = useCallback(
|
const handleMoveBlock = useCallback(
|
||||||
(blockId: string, newIndex: number, level?: number) => {
|
(blockId: string, newIndex: number, level?: number) => {
|
||||||
|
if (!effectiveCanEdit) return;
|
||||||
setBlocks((prev) => {
|
setBlocks((prev) => {
|
||||||
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
||||||
if (fromIdx === -1) return prev;
|
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));
|
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length));
|
||||||
copy.splice(boundedIdx, 0, updatedMoving);
|
copy.splice(boundedIdx, 0, updatedMoving);
|
||||||
const normalized = ensureNonEmptyBlocks(docId, copy);
|
const normalized = ensureNonEmptyBlocks(docId, copy);
|
||||||
|
if (isLiveEnabled) {
|
||||||
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
|
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
|
||||||
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[docId, sendBlockMoved]
|
[docId, effectiveCanEdit, isLiveEnabled, sendBlockMoved]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTogglePin = async () => {
|
const handleTogglePin = async () => {
|
||||||
@@ -537,7 +872,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
const handleSelectIcon = async (icon: string) => {
|
const handleSelectIcon = async (icon: string) => {
|
||||||
setShowEmojiPicker(false);
|
setShowEmojiPicker(false);
|
||||||
if (!doc || !canEdit) return;
|
if (!doc || !effectiveCanEdit) return;
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateDocument(doc.id, { icon });
|
const updated = await api.updateDocument(doc.id, { icon });
|
||||||
setDoc(updated);
|
setDoc(updated);
|
||||||
@@ -790,6 +1125,16 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Main Content Area */}
|
||||||
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
|
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
|
||||||
{zoomedBlock ? (
|
{zoomedBlock ? (
|
||||||
@@ -830,7 +1175,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Ast-Titel…"
|
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"
|
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>
|
</div>
|
||||||
@@ -850,7 +1195,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
{childBlocks.length === 0 ? (
|
{childBlocks.length === 0 ? (
|
||||||
<div className="py-8 text-center text-xs text-faint border border-dashed border-border rounded-card my-2">
|
<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>
|
<p className="mb-2">Dieser Ast hat noch keine Unterpunkte.</p>
|
||||||
{canEdit && (
|
{effectiveCanEdit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -875,7 +1220,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
<OutlinerTree
|
<OutlinerTree
|
||||||
docId={docId}
|
docId={docId}
|
||||||
blocks={childBlocks}
|
blocks={childBlocks}
|
||||||
canEdit={canEdit}
|
canEdit={effectiveCanEdit}
|
||||||
levelOffset={levelOffset}
|
levelOffset={levelOffset}
|
||||||
onZoomBlock={setZoomedBlockId}
|
onZoomBlock={setZoomedBlockId}
|
||||||
remoteCursors={remoteCursors}
|
remoteCursors={remoteCursors}
|
||||||
@@ -887,6 +1232,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
initialFocusedBlockId={initialFocusedBlockId}
|
initialFocusedBlockId={initialFocusedBlockId}
|
||||||
|
activeBlockLocks={activeBlockLocks}
|
||||||
|
onFocusBlock={handleFocusBlock}
|
||||||
|
onBlurBlock={handleBlurBlock}
|
||||||
|
currentUserId={currentUser?.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -900,13 +1249,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
title="Icon ändern"
|
||||||
>
|
>
|
||||||
{doc?.icon || '📝'}
|
{doc?.icon || '📝'}
|
||||||
</button>
|
</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">
|
<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) => (
|
{EMOJI_PALETTE.map((emoji) => (
|
||||||
<button
|
<button
|
||||||
@@ -926,7 +1275,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
ref={titleInputRef}
|
ref={titleInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
readOnly={!canEdit}
|
readOnly={!effectiveCanEdit}
|
||||||
autoFocus={autoFocusTitle}
|
autoFocus={autoFocusTitle}
|
||||||
onChange={handleTitleChange}
|
onChange={handleTitleChange}
|
||||||
onBlur={handleTitleBlur}
|
onBlur={handleTitleBlur}
|
||||||
@@ -939,7 +1288,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
<OutlinerTree
|
<OutlinerTree
|
||||||
docId={docId}
|
docId={docId}
|
||||||
blocks={blocks}
|
blocks={blocks}
|
||||||
canEdit={canEdit}
|
canEdit={effectiveCanEdit}
|
||||||
levelOffset={0}
|
levelOffset={0}
|
||||||
onZoomBlock={setZoomedBlockId}
|
onZoomBlock={setZoomedBlockId}
|
||||||
remoteCursors={remoteCursors}
|
remoteCursors={remoteCursors}
|
||||||
@@ -951,6 +1300,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
initialFocusedBlockId={initialFocusedBlockId}
|
initialFocusedBlockId={initialFocusedBlockId}
|
||||||
|
activeBlockLocks={activeBlockLocks}
|
||||||
|
onFocusBlock={handleFocusBlock}
|
||||||
|
onBlurBlock={handleBlurBlock}
|
||||||
|
currentUserId={currentUser?.id}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Maximize2,
|
Maximize2,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
FileText,
|
FileText,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||||
@@ -65,6 +66,10 @@ export interface OutlinerBlockProps {
|
|||||||
isDragTarget?: boolean;
|
isDragTarget?: boolean;
|
||||||
isTargeted?: boolean;
|
isTargeted?: boolean;
|
||||||
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||||
|
isLockedByOther?: boolean;
|
||||||
|
lockedByName?: string;
|
||||||
|
lockedByColor?: string;
|
||||||
|
onBlurBlock?: (blockId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||||
@@ -91,6 +96,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
isDragTarget,
|
isDragTarget,
|
||||||
isTargeted,
|
isTargeted,
|
||||||
registerTextareaRef,
|
registerTextareaRef,
|
||||||
|
isLockedByOther,
|
||||||
|
lockedByName,
|
||||||
|
lockedByColor,
|
||||||
|
onBlurBlock,
|
||||||
}) => {
|
}) => {
|
||||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||||
@@ -109,6 +118,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onUpdateBlockRef.current = onUpdateBlock;
|
onUpdateBlockRef.current = onUpdateBlock;
|
||||||
const onFocusBlockRef = useRef(onFocusBlock);
|
const onFocusBlockRef = useRef(onFocusBlock);
|
||||||
onFocusBlockRef.current = onFocusBlock;
|
onFocusBlockRef.current = onFocusBlock;
|
||||||
|
const onBlurBlockRef = useRef(onBlurBlock);
|
||||||
|
onBlurBlockRef.current = onBlurBlock;
|
||||||
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
||||||
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
||||||
const onDeleteBlockRef = useRef(onDeleteBlock);
|
const onDeleteBlockRef = useRef(onDeleteBlock);
|
||||||
@@ -318,6 +329,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
flushDebounce(localContentRef.current);
|
flushDebounce(localContentRef.current);
|
||||||
}
|
}
|
||||||
sendCursorMovedRef.current?.(null);
|
sendCursorMovedRef.current?.(null);
|
||||||
|
onBlurBlockRef.current?.(block.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
|
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 primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit || isLockedByOther) return;
|
||||||
|
|
||||||
// Slash command navigation
|
// Slash command navigation
|
||||||
if (slashSearch !== null && filteredSlashCommands.length > 0) {
|
if (slashSearch !== null && filteredSlashCommands.length > 0) {
|
||||||
@@ -486,7 +498,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
|
|
||||||
{/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
|
{/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
|
||||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||||
{canEdit && (
|
{canEdit && !isLockedByOther && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
draggable
|
draggable
|
||||||
@@ -520,7 +532,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
{block.type === 'todo' ? (
|
{block.type === 'todo' ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit || isLockedByOther}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
flushDebounce(localContent, { checked: !block.checked })
|
flushDebounce(localContent, { checked: !block.checked })
|
||||||
}
|
}
|
||||||
@@ -555,6 +567,16 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
|
|
||||||
{/* Main Block Content */}
|
{/* Main Block Content */}
|
||||||
<div className="flex-1 min-w-0 pr-1 relative">
|
<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' ? (
|
{block.type === 'divider' ? (
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<hr className="border-t border-border" />
|
<hr className="border-t border-border" />
|
||||||
@@ -578,7 +600,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
value={localContent}
|
value={localContent}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit || isLockedByOther}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -598,7 +620,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
value={localContent}
|
value={localContent}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit || isLockedByOther}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -618,7 +640,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
value={localContent}
|
value={localContent}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit || isLockedByOther}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { DocumentBlock } from '../../lib/types';
|
import { DocumentBlock, BlockLockInfo } from '../../lib/types';
|
||||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||||
import { OutlinerBlock } from './OutlinerBlock';
|
import { OutlinerBlock } from './OutlinerBlock';
|
||||||
|
|
||||||
@@ -19,6 +19,10 @@ export interface OutlinerTreeProps {
|
|||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
initialFocusedBlockId?: string | null;
|
initialFocusedBlockId?: string | null;
|
||||||
|
activeBlockLocks?: Record<string, BlockLockInfo>;
|
||||||
|
onFocusBlock?: (blockId: string) => void;
|
||||||
|
onBlurBlock?: (blockId: string) => void;
|
||||||
|
currentUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||||
@@ -36,6 +40,10 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
onTagClick,
|
onTagClick,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
initialFocusedBlockId,
|
initialFocusedBlockId,
|
||||||
|
activeBlockLocks,
|
||||||
|
onFocusBlock,
|
||||||
|
onBlurBlock,
|
||||||
|
currentUserId,
|
||||||
}) => {
|
}) => {
|
||||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
||||||
@@ -319,7 +327,11 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
|
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => {
|
||||||
|
const lockInfo = activeBlockLocks?.[block.id];
|
||||||
|
const isLockedByOther = Boolean(lockInfo && lockInfo.userId !== currentUserId);
|
||||||
|
|
||||||
|
return (
|
||||||
<OutlinerBlock
|
<OutlinerBlock
|
||||||
key={block.id}
|
key={block.id}
|
||||||
block={block}
|
block={block}
|
||||||
@@ -332,7 +344,13 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
onZoomBlock={onZoomBlock}
|
onZoomBlock={onZoomBlock}
|
||||||
activeRemoteCursors={cursorsByBlock[block.id] || []}
|
activeRemoteCursors={cursorsByBlock[block.id] || []}
|
||||||
sendCursorMoved={sendCursorMoved}
|
sendCursorMoved={sendCursorMoved}
|
||||||
onFocusBlock={setFocusedBlockId}
|
onFocusBlock={(bId) => {
|
||||||
|
setFocusedBlockId(bId);
|
||||||
|
onFocusBlock?.(bId);
|
||||||
|
}}
|
||||||
|
onBlurBlock={(bId) => {
|
||||||
|
onBlurBlock?.(bId);
|
||||||
|
}}
|
||||||
onUpdateBlock={onUpdateBlock}
|
onUpdateBlock={onUpdateBlock}
|
||||||
onCreateBlockBelow={handleCreateBlockBelow}
|
onCreateBlockBelow={handleCreateBlockBelow}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
@@ -346,8 +364,12 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||||
isTargeted={highlightedBlockId === block.id}
|
isTargeted={highlightedBlockId === block.id}
|
||||||
registerTextareaRef={registerTextareaRef}
|
registerTextareaRef={registerTextareaRef}
|
||||||
|
isLockedByOther={isLockedByOther}
|
||||||
|
lockedByName={lockInfo?.userName}
|
||||||
|
lockedByColor={lockInfo?.userColor}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className="pt-2 pl-2">
|
<div className="pt-2 pl-2">
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Radio,
|
Radio,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Document,
|
Document,
|
||||||
@@ -47,7 +51,7 @@ import {
|
|||||||
import { DocSyncStatus } from '../../lib/useDocumentSync';
|
import { DocSyncStatus } from '../../lib/useDocumentSync';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
import { DocumentEditor } from './DocumentEditor';
|
import { DocumentEditor, DocSaveStatus, DocLiveState } from './DocumentEditor';
|
||||||
|
|
||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
@@ -303,6 +307,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
|
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
|
||||||
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
|
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
|
||||||
const [activeDocSyncStatus, setActiveDocSyncStatus] = useState<DocSyncStatus>('connected');
|
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(() => {
|
useEffect(() => {
|
||||||
const unsub = onRealtimeStatus((connected, transport) => {
|
const unsub = onRealtimeStatus((connected, transport) => {
|
||||||
@@ -1133,16 +1143,112 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
{(() => {
|
{subView === 'editor' && selectedDocId ? (
|
||||||
const isDocActive = subView === 'editor' && Boolean(selectedDocId);
|
<>
|
||||||
const isConnected = isDocActive
|
{/* Save Status Pill */}
|
||||||
? activeDocSyncStatus === 'connected'
|
<div
|
||||||
: realtimeConnected;
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
|
||||||
const isConnecting = isDocActive && (activeDocSyncStatus === 'connecting' || activeDocSyncStatus === 'reconnecting');
|
docSaveStatus === 'saved'
|
||||||
const isSSE = !isDocActive && realtimeTransport === 'sse';
|
? '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={
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
|
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
|
||||||
@@ -1150,28 +1256,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
? isSSE
|
? isSSE
|
||||||
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
||||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
: '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'
|
: 'bg-canvas border-border text-muted'
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
isConnected
|
isConnected
|
||||||
? isSSE
|
? isSSE
|
||||||
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
|
? 'Synchronisiert (Server-Sent Events / HTTPS)'
|
||||||
: 'Live synchronisiert (WebSocket online)'
|
: 'Synchronisiert (WebSocket online)'
|
||||||
: isConnecting
|
|
||||||
? 'Verbindung wird aufgebaut…'
|
|
||||||
: 'Offline'
|
: 'Offline'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Radio
|
<Radio
|
||||||
className={`w-3.5 h-3.5 ${
|
className={`w-3.5 h-3.5 ${isConnected ? 'animate-pulse' : ''}`}
|
||||||
isConnected ? 'animate-pulse' : isConnecting ? 'animate-spin' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1414,6 +1515,17 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
onWikiLinkClick={handleWikiLinkClick}
|
onWikiLinkClick={handleWikiLinkClick}
|
||||||
onBackMobile={() => setMobileShowDetail(false)}
|
onBackMobile={() => setMobileShowDetail(false)}
|
||||||
onSyncStatusChange={setActiveDocSyncStatus}
|
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' ? (
|
) : 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
DocumentBlock,
|
DocumentBlock,
|
||||||
DocShare,
|
DocShare,
|
||||||
DocLockInfo,
|
DocLockInfo,
|
||||||
|
BlockLockInfo,
|
||||||
|
LiveSessionInfo,
|
||||||
|
DocLocksResponse,
|
||||||
KanbanColumn,
|
KanbanColumn,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListShare,
|
ListShare,
|
||||||
@@ -625,6 +628,39 @@ export const api = {
|
|||||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getDocLocks(docId: string): Promise<DocLocksResponse> {
|
||||||
|
const res = await request<DocLocksResponse>(`/api/v1/documents/${docId}/locks`);
|
||||||
|
return {
|
||||||
|
liveSession: res.liveSession || null,
|
||||||
|
blockLocks: ensureArray(res.blockLocks),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async lockBlock(docId: string, blockId: string, userColor?: string, ttlSeconds: number = 30): Promise<BlockLockInfo> {
|
||||||
|
return request<BlockLockInfo>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/lock`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userColor, ttlSeconds }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async unlockBlock(docId: string, blockId: string): Promise<{ status: string }> {
|
||||||
|
return request<{ status: string }>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/unlock`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async startLiveSession(docId: string): Promise<LiveSessionInfo> {
|
||||||
|
return request<LiveSessionInfo>(`/api/v1/documents/${docId}/live/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async endLiveSession(docId: string): Promise<{ status: string }> {
|
||||||
|
return request<{ status: string }>(`/api/v1/documents/${docId}/live/end`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async moveDocument(id: string, folderPath: string): Promise<Document> {
|
async moveDocument(id: string, folderPath: string): Promise<Document> {
|
||||||
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
|
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -207,6 +207,28 @@ export interface DocLockInfo {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BlockLockInfo {
|
||||||
|
docId: string;
|
||||||
|
blockId: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
userColor?: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveSessionInfo {
|
||||||
|
docId: string;
|
||||||
|
initiatorId: string;
|
||||||
|
initiatorName: string;
|
||||||
|
startedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocLocksResponse {
|
||||||
|
liveSession?: LiveSessionInfo | null;
|
||||||
|
blockLocks: BlockLockInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Document {
|
export interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
|||||||
import { DocumentBlock } from './types';
|
import { DocumentBlock } from './types';
|
||||||
import { getToken, api } from './api';
|
import { getToken, api } from './api';
|
||||||
|
|
||||||
export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline';
|
export type DocSyncStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'blocked';
|
||||||
|
|
||||||
export interface RemoteCursor {
|
export interface RemoteCursor {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -73,9 +73,10 @@ export function useDocumentSync(
|
|||||||
docId: string | null,
|
docId: string | null,
|
||||||
onRemoteEvent: (evt: DocSyncEvent) => void,
|
onRemoteEvent: (evt: DocSyncEvent) => void,
|
||||||
canEdit: boolean = true,
|
canEdit: boolean = true,
|
||||||
currentUser?: { id: string; email?: string; displayName?: string } | null
|
currentUser?: { id: string; email?: string; displayName?: string } | null,
|
||||||
|
enabled: boolean = false
|
||||||
) {
|
) {
|
||||||
const [status, setStatus] = useState<DocSyncStatus>('connecting');
|
const [status, setStatus] = useState<DocSyncStatus>(enabled ? 'connecting' : 'idle');
|
||||||
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
|
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const clientIdRef = useRef<string>(
|
const clientIdRef = useRef<string>(
|
||||||
@@ -136,10 +137,16 @@ export function useDocumentSync(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!docId) {
|
if (!docId || !enabled) {
|
||||||
setStatus('offline');
|
setStatus('idle');
|
||||||
setRemoteCursors({});
|
setRemoteCursors({});
|
||||||
pendingMessagesQueueRef.current = [];
|
pendingMessagesQueueRef.current = [];
|
||||||
|
if (wsRef.current) {
|
||||||
|
try {
|
||||||
|
wsRef.current.close();
|
||||||
|
} catch {}
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,13 +180,24 @@ export function useDocumentSync(
|
|||||||
try {
|
try {
|
||||||
ws = new WebSocket(wsUrl);
|
ws = new WebSocket(wsUrl);
|
||||||
} catch {
|
} catch {
|
||||||
setStatus('offline');
|
setStatus('blocked');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
const handshakeTimer = window.setTimeout(() => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) {
|
||||||
|
console.warn('[DocSync] WebSocket handshake timed out (corporate firewall/proxy block detected).');
|
||||||
|
setStatus('blocked');
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, 4500);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
window.clearTimeout(handshakeTimer);
|
||||||
if (isDisposed) {
|
if (isDisposed) {
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
@@ -266,12 +284,17 @@ export function useDocumentSync(
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
window.clearTimeout(handshakeTimer);
|
||||||
if (isDisposed) return;
|
if (isDisposed) return;
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
setStatus(retryCount > 2 ? 'offline' : 'reconnecting');
|
if (retryCount >= 2) {
|
||||||
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 8000);
|
setStatus('blocked');
|
||||||
|
} else {
|
||||||
|
setStatus('reconnecting');
|
||||||
|
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 4000);
|
||||||
reconnectTimerRef.current = window.setTimeout(connect, delay);
|
reconnectTimerRef.current = window.setTimeout(connect, delay);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -304,7 +327,7 @@ export function useDocumentSync(
|
|||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [docId, localUserName, localUserColor]);
|
}, [docId, enabled, localUserName, localUserColor]);
|
||||||
|
|
||||||
const executeSendCursor = useCallback(
|
const executeSendCursor = useCallback(
|
||||||
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
||||||
@@ -377,165 +400,90 @@ export function useDocumentSync(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sendRawEvent = useCallback(
|
const sendRawEvent = useCallback(
|
||||||
(evt: DocSyncEvent, fallbackFn?: () => void) => {
|
(evt: DocSyncEvent) => {
|
||||||
const activeDocId = docIdRef.current;
|
const activeDocId = docIdRef.current;
|
||||||
const activeCanEdit = canEditRef.current;
|
const activeCanEdit = canEditRef.current;
|
||||||
if (!activeDocId || !activeCanEdit) return;
|
if (!activeDocId || !activeCanEdit) return;
|
||||||
|
|
||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
const payload: DocSyncEvent = {
|
const payload: DocSyncEvent = {
|
||||||
...evt,
|
...evt,
|
||||||
docId: activeDocId,
|
docId: activeDocId,
|
||||||
clientId: clientIdRef.current,
|
clientId: clientIdRef.current,
|
||||||
};
|
};
|
||||||
const jsonStr = JSON.stringify(payload);
|
|
||||||
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
try {
|
try {
|
||||||
ws.send(jsonStr);
|
ws.send(JSON.stringify(payload));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[DocSync] Failed to send WebSocket message, buffering:', err);
|
console.warn('[DocSync] Failed to send WebSocket message:', err);
|
||||||
pendingMessagesQueueRef.current.push(jsonStr);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
`[DocSync] WebSocket not OPEN (state=${ws ? ws.readyState : 'null'}). Buffering event:`,
|
|
||||||
evt.type
|
|
||||||
);
|
|
||||||
pendingMessagesQueueRef.current.push(jsonStr);
|
|
||||||
|
|
||||||
if (fallbackFn) {
|
|
||||||
if (fallbackTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(fallbackTimerRef.current);
|
|
||||||
}
|
|
||||||
fallbackTimerRef.current = window.setTimeout(() => {
|
|
||||||
fallbackFn();
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendBlockCreated = useCallback(
|
const sendBlockCreated = useCallback(
|
||||||
(block: DocumentBlock, afterBlockId?: string, allBlocks?: DocumentBlock[]) => {
|
(block: DocumentBlock, afterBlockId?: string, _allBlocks?: DocumentBlock[]) => {
|
||||||
sendRawEvent(
|
sendRawEvent({
|
||||||
{
|
|
||||||
type: 'block_created',
|
type: 'block_created',
|
||||||
block,
|
block,
|
||||||
afterBlockId,
|
afterBlockId,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
() => {
|
[sendRawEvent]
|
||||||
if (docId && allBlocks) {
|
|
||||||
api
|
|
||||||
.patchDocumentBlocks(docId, {
|
|
||||||
upserted: [block],
|
|
||||||
blockOrder: allBlocks.map((b) => b.id),
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[docId, sendRawEvent]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendBlockUpdated = useCallback(
|
const sendBlockUpdated = useCallback(
|
||||||
(block: DocumentBlock) => {
|
(block: DocumentBlock) => {
|
||||||
sendRawEvent(
|
sendRawEvent({
|
||||||
{
|
|
||||||
type: 'block_updated',
|
type: 'block_updated',
|
||||||
blockId: block.id,
|
blockId: block.id,
|
||||||
block,
|
block,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
() => {
|
[sendRawEvent]
|
||||||
if (docId) {
|
|
||||||
api.patchDocumentBlocks(docId, { upserted: [block] }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[docId, sendRawEvent]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendBlockDeleted = useCallback(
|
const sendBlockDeleted = useCallback(
|
||||||
(blockId: string, allBlocks?: DocumentBlock[]) => {
|
(blockId: string, _allBlocks?: DocumentBlock[]) => {
|
||||||
sendRawEvent(
|
sendRawEvent({
|
||||||
{
|
|
||||||
type: 'block_deleted',
|
type: 'block_deleted',
|
||||||
blockId,
|
blockId,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
() => {
|
[sendRawEvent]
|
||||||
if (docId) {
|
|
||||||
api
|
|
||||||
.patchDocumentBlocks(docId, {
|
|
||||||
deletedIds: [blockId],
|
|
||||||
blockOrder: allBlocks ? allBlocks.map((b) => b.id) : undefined,
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[docId, sendRawEvent]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendBlockMoved = useCallback(
|
const sendBlockMoved = useCallback(
|
||||||
(blockId: string, newIndex: number, level?: number, allBlocks?: DocumentBlock[]) => {
|
(blockId: string, newIndex: number, level?: number, _allBlocks?: DocumentBlock[]) => {
|
||||||
sendRawEvent(
|
sendRawEvent({
|
||||||
{
|
|
||||||
type: 'block_moved',
|
type: 'block_moved',
|
||||||
blockId,
|
blockId,
|
||||||
newIndex,
|
newIndex,
|
||||||
level,
|
level,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
() => {
|
[sendRawEvent]
|
||||||
if (docId && allBlocks) {
|
|
||||||
api
|
|
||||||
.patchDocumentBlocks(docId, {
|
|
||||||
upserted: allBlocks,
|
|
||||||
blockOrder: allBlocks.map((b) => b.id),
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[docId, sendRawEvent]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendTitleUpdated = useCallback(
|
const sendTitleUpdated = useCallback(
|
||||||
(title: string) => {
|
(title: string) => {
|
||||||
sendRawEvent(
|
sendRawEvent({
|
||||||
{
|
|
||||||
type: 'title_updated',
|
type: 'title_updated',
|
||||||
title,
|
title,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
() => {
|
[sendRawEvent]
|
||||||
if (docId) {
|
|
||||||
api.updateDocument(docId, { title }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[docId, sendRawEvent]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendBlocksReplaced = useCallback(
|
const sendBlocksReplaced = useCallback(
|
||||||
(blocks: DocumentBlock[]) => {
|
(blocks: DocumentBlock[]) => {
|
||||||
sendRawEvent(
|
sendRawEvent({
|
||||||
{
|
|
||||||
type: 'blocks_replaced',
|
type: 'blocks_replaced',
|
||||||
blocks,
|
blocks,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
() => {
|
[sendRawEvent]
|
||||||
if (docId) {
|
|
||||||
api.updateDocument(docId, { blocks }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[docId, sendRawEvent]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
|
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
|
||||||
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
|
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
|
||||||
|
|
||||||
|
// Document Locks & Live-Collaboration Sessions
|
||||||
|
r.Get("/api/v1/documents/{id}/locks", s.handleGetDocLocks)
|
||||||
|
r.Post("/api/v1/documents/{id}/blocks/{blockId}/lock", s.handleLockBlock)
|
||||||
|
r.Post("/api/v1/documents/{id}/blocks/{blockId}/unlock", s.handleUnlockBlock)
|
||||||
|
r.Post("/api/v1/documents/{id}/live/start", s.handleStartLiveSession)
|
||||||
|
r.Post("/api/v1/documents/{id}/live/end", s.handleEndLiveSession)
|
||||||
|
|
||||||
// Shared Lists & Realtime Collaboration
|
// Shared Lists & Realtime Collaboration
|
||||||
r.Get("/api/v1/lists", s.handleGetAllLists)
|
r.Get("/api/v1/lists", s.handleGetAllLists)
|
||||||
r.Post("/api/v1/lists", s.handleCreateList)
|
r.Post("/api/v1/lists", s.handleCreateList)
|
||||||
@@ -923,6 +930,19 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
if s.wsHub != nil {
|
||||||
|
if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusLocked)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet",
|
||||||
|
"lockedBy": ls.InitiatorName,
|
||||||
|
"isLocked": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Icon *string `json:"icon"`
|
Icon *string `json:"icon"`
|
||||||
@@ -954,6 +974,19 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
|
|||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
if s.wsHub != nil {
|
||||||
|
if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusLocked)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet",
|
||||||
|
"lockedBy": ls.InitiatorName,
|
||||||
|
"isLocked": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var req domain.DocBlocksPatchRequest
|
var req domain.DocBlocksPatchRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
@@ -1381,6 +1414,169 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, users)
|
writeJSON(w, http.StatusOK, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- DOCUMENT LOCKS & LIVE COLLABORATION SESSIONS ---
|
||||||
|
|
||||||
|
func (s *Server) handleGetDocLocks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
var blockLocks []domain.BlockLockInfo
|
||||||
|
var liveSession *domain.LiveSessionInfo
|
||||||
|
if s.wsHub != nil {
|
||||||
|
blockLocks = s.wsHub.GetDocBlockLocks(docID)
|
||||||
|
liveSession = s.wsHub.GetLiveSession(docID)
|
||||||
|
}
|
||||||
|
if blockLocks == nil {
|
||||||
|
blockLocks = []domain.BlockLockInfo{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, domain.DocLocksResponse{
|
||||||
|
DocID: docID,
|
||||||
|
BlockLocks: blockLocks,
|
||||||
|
LiveSession: liveSession,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLockBlock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
blockID := chi.URLParam(r, "blockId")
|
||||||
|
|
||||||
|
// If live session is active by someone else, reject
|
||||||
|
if s.wsHub != nil {
|
||||||
|
if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusLocked)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet",
|
||||||
|
"lockedBy": ls.InitiatorName,
|
||||||
|
"isLocked": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user, _ := s.store.GetUserByID(userID)
|
||||||
|
userName := "Anonym"
|
||||||
|
userColor := "#3b82f6"
|
||||||
|
if user != nil {
|
||||||
|
if user.DisplayName != "" {
|
||||||
|
userName = user.DisplayName
|
||||||
|
} else {
|
||||||
|
userName = user.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
UserColor string `json:"userColor"`
|
||||||
|
TTLSeconds int `json:"ttlSeconds"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if req.UserColor != "" {
|
||||||
|
userColor = req.UserColor
|
||||||
|
}
|
||||||
|
ttl := 30 * time.Second
|
||||||
|
if req.TTLSeconds > 5 && req.TTLSeconds <= 120 {
|
||||||
|
ttl = time.Duration(req.TTLSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.wsHub != nil {
|
||||||
|
lockInfo, ok, existing := s.wsHub.AcquireBlockLock(docID, blockID, userID, userName, userColor, ttl)
|
||||||
|
if !ok && existing != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": "Block ist von anderem Benutzer gesperrt",
|
||||||
|
"lockedBy": existing.UserName,
|
||||||
|
"lock": existing,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_LOCKED", lockInfo, nil)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"lock": lockInfo,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUnlockBlock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
blockID := chi.URLParam(r, "blockId")
|
||||||
|
|
||||||
|
if s.wsHub != nil {
|
||||||
|
s.wsHub.ReleaseBlockLock(docID, blockID, userID)
|
||||||
|
s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_UNLOCKED", map[string]string{
|
||||||
|
"docId": docID,
|
||||||
|
"blockId": blockID,
|
||||||
|
"userId": userID,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStartLiveSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
user, _ := s.store.GetUserByID(userID)
|
||||||
|
userName := "Anonym"
|
||||||
|
if user != nil {
|
||||||
|
if user.DisplayName != "" {
|
||||||
|
userName = user.DisplayName
|
||||||
|
} else {
|
||||||
|
userName = user.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := 120 * time.Second // 2 min lease with heartbeat while active
|
||||||
|
if s.wsHub != nil {
|
||||||
|
info, ok, existing := s.wsHub.StartLiveSession(docID, userID, userName, ttl)
|
||||||
|
if !ok && existing != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": "Live-Sitzung bereits von anderem Benutzer aktiv",
|
||||||
|
"session": existing,
|
||||||
|
"initiator": existing.InitiatorName,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastRoom("doc:"+docID, "DOC_LIVE_STARTED", info, nil)
|
||||||
|
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
|
||||||
|
for _, uid := range users {
|
||||||
|
s.wsHub.BroadcastToUser(uid, "DOC_LIVE_STARTED", info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"session": info,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEndLiveSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
if s.wsHub != nil {
|
||||||
|
s.wsHub.EndLiveSession(docID, userID)
|
||||||
|
evt := map[string]string{
|
||||||
|
"docId": docID,
|
||||||
|
"userId": userID,
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastRoom("doc:"+docID, "DOC_LIVE_ENDED", evt, nil)
|
||||||
|
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
|
||||||
|
for _, uid := range users {
|
||||||
|
s.wsHub.BroadcastToUser(uid, "DOC_LIVE_ENDED", evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
// --- DOCUMENT REALTIME WEBSOCKET ROOM ---
|
// --- DOCUMENT REALTIME WEBSOCKET ROOM ---
|
||||||
|
|
||||||
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||||
|
|||||||
@@ -1295,6 +1295,127 @@ func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBlockLockingAndLiveSessionGuards(t *testing.T) {
|
||||||
|
srv, store, _ := setupTestServer(t)
|
||||||
|
router := srv.Router()
|
||||||
|
|
||||||
|
userA, _ := store.CreateUser("user_a_lock@example.com", "hash", "User A", "member")
|
||||||
|
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion)
|
||||||
|
|
||||||
|
userB, _ := store.CreateUser("user_b_lock@example.com", "hash", "User B", "member")
|
||||||
|
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
||||||
|
|
||||||
|
// Create document owned by User A, shared with User B
|
||||||
|
blocks := []domain.DocumentBlock{
|
||||||
|
{ID: "b1", Type: "bullet", Content: "Row 1", Level: 0},
|
||||||
|
{ID: "b2", Type: "bullet", Content: "Row 2", Level: 0},
|
||||||
|
}
|
||||||
|
doc, err := store.CreateDocument(userA.ID, "", "Lock Test Doc", "📝", "", blocks, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDocument failed: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "write")
|
||||||
|
|
||||||
|
// 1. Initial locks should be empty
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/locks", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get locks failed: %d", rec.Code)
|
||||||
|
}
|
||||||
|
var locksResp domain.DocLocksResponse
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &locksResp)
|
||||||
|
if len(locksResp.BlockLocks) != 0 || locksResp.LiveSession != nil {
|
||||||
|
t.Fatalf("expected 0 locks initially, got %v", locksResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. User A locks block b1
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("user A lock b1 failed: %d, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. User B tries to lock b1 -> must receive 409 Conflict
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("expected 409 Conflict for user B on b1, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. User A unlocks b1
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/unlock", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("user A unlock b1 failed: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. User B can now lock b1
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("user B lock b1 should succeed now, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
// User B unlocks b1
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/unlock", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
// 6. User A starts a Live-Session on doc
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/live/start", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("user A start live session failed: %d, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. User B tries to patch document blocks while User A's live session is active -> must receive 423 Locked!
|
||||||
|
patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{
|
||||||
|
Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}},
|
||||||
|
})
|
||||||
|
req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusLocked {
|
||||||
|
t.Fatalf("expected 423 Locked for User B while live session is active, got %d, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. User A ends the Live-Session
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/live/end", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("user A end live session failed: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. User B can now edit again
|
||||||
|
req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("User B edit should succeed after live session ended, got %d, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -344,6 +344,29 @@ type DocLockInfo struct {
|
|||||||
ExpiresAt string `json:"expiresAt"`
|
ExpiresAt string `json:"expiresAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BlockLockInfo struct {
|
||||||
|
DocID string `json:"docId"`
|
||||||
|
BlockID string `json:"blockId"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
UserName string `json:"userName"`
|
||||||
|
UserColor string `json:"userColor,omitempty"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveSessionInfo struct {
|
||||||
|
DocID string `json:"docId"`
|
||||||
|
InitiatorID string `json:"initiatorId"`
|
||||||
|
InitiatorName string `json:"initiatorName"`
|
||||||
|
StartedAt string `json:"startedAt"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocLocksResponse struct {
|
||||||
|
DocID string `json:"docId"`
|
||||||
|
BlockLocks []BlockLockInfo `json:"blockLocks"`
|
||||||
|
LiveSession *LiveSessionInfo `json:"liveSession,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type Document struct {
|
type Document struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
|
|||||||
@@ -92,6 +92,24 @@ type docLockItem struct {
|
|||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type blockLockItem struct {
|
||||||
|
docID string
|
||||||
|
blockID string
|
||||||
|
userID string
|
||||||
|
userName string
|
||||||
|
userColor string
|
||||||
|
lockedAt time.Time
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type liveSessionItem struct {
|
||||||
|
docID string
|
||||||
|
initiatorID string
|
||||||
|
initiatorName string
|
||||||
|
startedAt time.Time
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type DocStore interface {
|
type DocStore interface {
|
||||||
LoadDocumentState(docID string) (string, []domain.DocumentBlock, error)
|
LoadDocumentState(docID string) (string, []domain.DocumentBlock, error)
|
||||||
FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error
|
FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error
|
||||||
@@ -106,6 +124,8 @@ type Hub struct {
|
|||||||
sseUserRooms map[string]map[*SSEClient]bool
|
sseUserRooms map[string]map[*SSEClient]bool
|
||||||
sseRooms map[string]map[*SSEClient]bool
|
sseRooms map[string]map[*SSEClient]bool
|
||||||
locks map[string]*docLockItem
|
locks map[string]*docLockItem
|
||||||
|
blockLocks map[string]*blockLockItem // key: docID + ":" + blockID
|
||||||
|
liveSessions map[string]*liveSessionItem // key: docID
|
||||||
docRooms map[string]*DocRoom
|
docRooms map[string]*DocRoom
|
||||||
docStore DocStore
|
docStore DocStore
|
||||||
stopCleanup chan struct{}
|
stopCleanup chan struct{}
|
||||||
@@ -121,6 +141,8 @@ func NewHub() *Hub {
|
|||||||
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
||||||
sseRooms: make(map[string]map[*SSEClient]bool),
|
sseRooms: make(map[string]map[*SSEClient]bool),
|
||||||
locks: make(map[string]*docLockItem),
|
locks: make(map[string]*docLockItem),
|
||||||
|
blockLocks: make(map[string]*blockLockItem),
|
||||||
|
liveSessions: make(map[string]*liveSessionItem),
|
||||||
docRooms: make(map[string]*DocRoom),
|
docRooms: make(map[string]*DocRoom),
|
||||||
stopCleanup: make(chan struct{}),
|
stopCleanup: make(chan struct{}),
|
||||||
}
|
}
|
||||||
@@ -459,6 +481,187 @@ func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block Locking Methods (Option B)
|
||||||
|
|
||||||
|
func (h *Hub) AcquireBlockLock(docID, blockID, userID, userName, userColor string, ttl time.Duration) (*domain.BlockLockInfo, bool, *domain.BlockLockInfo) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
key := docID + ":" + blockID
|
||||||
|
existing, exists := h.blockLocks[key]
|
||||||
|
if exists && existing.expiresAt.After(now) {
|
||||||
|
if existing.userID != userID {
|
||||||
|
info := &domain.BlockLockInfo{
|
||||||
|
DocID: existing.docID,
|
||||||
|
BlockID: existing.blockID,
|
||||||
|
UserID: existing.userID,
|
||||||
|
UserName: existing.userName,
|
||||||
|
UserColor: existing.userColor,
|
||||||
|
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
return nil, false, info
|
||||||
|
}
|
||||||
|
// Refresh lease
|
||||||
|
existing.expiresAt = now.Add(ttl)
|
||||||
|
if userName != "" {
|
||||||
|
existing.userName = userName
|
||||||
|
}
|
||||||
|
if userColor != "" {
|
||||||
|
existing.userColor = userColor
|
||||||
|
}
|
||||||
|
info := &domain.BlockLockInfo{
|
||||||
|
DocID: existing.docID,
|
||||||
|
BlockID: existing.blockID,
|
||||||
|
UserID: existing.userID,
|
||||||
|
UserName: existing.userName,
|
||||||
|
UserColor: existing.userColor,
|
||||||
|
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
return info, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
item := &blockLockItem{
|
||||||
|
docID: docID,
|
||||||
|
blockID: blockID,
|
||||||
|
userID: userID,
|
||||||
|
userName: userName,
|
||||||
|
userColor: userColor,
|
||||||
|
lockedAt: now,
|
||||||
|
expiresAt: now.Add(ttl),
|
||||||
|
}
|
||||||
|
h.blockLocks[key] = item
|
||||||
|
info := &domain.BlockLockInfo{
|
||||||
|
DocID: item.docID,
|
||||||
|
BlockID: item.blockID,
|
||||||
|
UserID: item.userID,
|
||||||
|
UserName: item.userName,
|
||||||
|
UserColor: item.userColor,
|
||||||
|
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
return info, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) ReleaseBlockLock(docID, blockID, userID string) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
key := docID + ":" + blockID
|
||||||
|
existing, exists := h.blockLocks[key]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if userID != "" && existing.userID != userID && userID != "admin" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(h.blockLocks, key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) GetDocBlockLocks(docID string) []domain.BlockLockInfo {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var result []domain.BlockLockInfo
|
||||||
|
prefix := docID + ":"
|
||||||
|
for k, l := range h.blockLocks {
|
||||||
|
if strings.HasPrefix(k, prefix) && l.expiresAt.After(now) {
|
||||||
|
result = append(result, domain.BlockLockInfo{
|
||||||
|
DocID: l.docID,
|
||||||
|
BlockID: l.blockID,
|
||||||
|
UserID: l.userID,
|
||||||
|
UserName: l.userName,
|
||||||
|
UserColor: l.userColor,
|
||||||
|
ExpiresAt: l.expiresAt.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live Session Methods (Opt-In Live Collaboration)
|
||||||
|
|
||||||
|
func (h *Hub) StartLiveSession(docID, userID, userName string, ttl time.Duration) (*domain.LiveSessionInfo, bool, *domain.LiveSessionInfo) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
existing, exists := h.liveSessions[docID]
|
||||||
|
if exists && existing.expiresAt.After(now) {
|
||||||
|
if existing.initiatorID != userID {
|
||||||
|
info := &domain.LiveSessionInfo{
|
||||||
|
DocID: existing.docID,
|
||||||
|
InitiatorID: existing.initiatorID,
|
||||||
|
InitiatorName: existing.initiatorName,
|
||||||
|
StartedAt: existing.startedAt.Format(time.RFC3339),
|
||||||
|
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
return nil, false, info
|
||||||
|
}
|
||||||
|
// Refresh lease
|
||||||
|
existing.expiresAt = now.Add(ttl)
|
||||||
|
info := &domain.LiveSessionInfo{
|
||||||
|
DocID: existing.docID,
|
||||||
|
InitiatorID: existing.initiatorID,
|
||||||
|
InitiatorName: existing.initiatorName,
|
||||||
|
StartedAt: existing.startedAt.Format(time.RFC3339),
|
||||||
|
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
return info, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
item := &liveSessionItem{
|
||||||
|
docID: docID,
|
||||||
|
initiatorID: userID,
|
||||||
|
initiatorName: userName,
|
||||||
|
startedAt: now,
|
||||||
|
expiresAt: now.Add(ttl),
|
||||||
|
}
|
||||||
|
h.liveSessions[docID] = item
|
||||||
|
info := &domain.LiveSessionInfo{
|
||||||
|
DocID: item.docID,
|
||||||
|
InitiatorID: item.initiatorID,
|
||||||
|
InitiatorName: item.initiatorName,
|
||||||
|
StartedAt: item.startedAt.Format(time.RFC3339),
|
||||||
|
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
return info, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) EndLiveSession(docID, userID string) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
existing, exists := h.liveSessions[docID]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if userID != "" && existing.initiatorID != userID && userID != "admin" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(h.liveSessions, docID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) GetLiveSession(docID string) *domain.LiveSessionInfo {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
existing, exists := h.liveSessions[docID]
|
||||||
|
if !exists || existing.expiresAt.Before(now) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &domain.LiveSessionInfo{
|
||||||
|
DocID: existing.docID,
|
||||||
|
InitiatorID: existing.initiatorID,
|
||||||
|
InitiatorName: existing.initiatorName,
|
||||||
|
StartedAt: existing.startedAt.Format(time.RFC3339),
|
||||||
|
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Hub) cleanupLocksLoop() {
|
func (h *Hub) cleanupLocksLoop() {
|
||||||
ticker := time.NewTicker(2 * time.Second)
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -470,6 +673,7 @@ func (h *Hub) cleanupLocksLoop() {
|
|||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
var expiredDocIDs []string
|
var expiredDocIDs []string
|
||||||
for docID, l := range h.locks {
|
for docID, l := range h.locks {
|
||||||
if l.expiresAt.Before(now) {
|
if l.expiresAt.Before(now) {
|
||||||
@@ -477,11 +681,42 @@ func (h *Hub) cleanupLocksLoop() {
|
|||||||
delete(h.locks, docID)
|
delete(h.locks, docID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type expBlock struct {
|
||||||
|
docID string
|
||||||
|
blockID string
|
||||||
|
}
|
||||||
|
var expiredBlocks []expBlock
|
||||||
|
for k, bl := range h.blockLocks {
|
||||||
|
if bl.expiresAt.Before(now) {
|
||||||
|
expiredBlocks = append(expiredBlocks, expBlock{docID: bl.docID, blockID: bl.blockID})
|
||||||
|
delete(h.blockLocks, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiredLiveSessions []string
|
||||||
|
for docID, ls := range h.liveSessions {
|
||||||
|
if ls.expiresAt.Before(now) {
|
||||||
|
expiredLiveSessions = append(expiredLiveSessions, docID)
|
||||||
|
delete(h.liveSessions, docID)
|
||||||
|
}
|
||||||
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
for _, docID := range expiredDocIDs {
|
for _, docID := range expiredDocIDs {
|
||||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||||
}
|
}
|
||||||
|
for _, b := range expiredBlocks {
|
||||||
|
h.BroadcastRoom("doc:"+b.docID, "DOC_BLOCK_UNLOCKED", map[string]string{
|
||||||
|
"docId": b.docID,
|
||||||
|
"blockId": b.blockID,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
for _, docID := range expiredLiveSessions {
|
||||||
|
h.BroadcastRoom("doc:"+docID, "DOC_LIVE_ENDED", map[string]string{
|
||||||
|
"docId": docID,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user