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

This commit is contained in:
sandrodach committed 2026-10-27 14:02:38 +01:00
1 parent 50b017f387
commit 0e06cc5577
11 files changed
+1373 -239

No files matched your search

@@ -15,11 +15,14 @@ import {
Sparkles,
Plus,
} from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api';
import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus } from '../../lib/useDocumentSync';
import { Document, DocumentBlock, User, BlockLockInfo, LiveSessionInfo, WSMessage } from '../../lib/types';
import { api, getToken, onWebSocketMessage } from '../../lib/api';
import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus, getUserColor } from '../../lib/useDocumentSync';
import { OutlinerTree } from './OutlinerTree';
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
export type DocLiveState = 'idle' | 'connecting' | 'active' | 'blocked' | 'locked_by_other';
export interface DocumentEditorProps {
docId: string;
currentUser?: User | null;
@@ -34,6 +37,9 @@ export interface DocumentEditorProps {
onWikiLinkClick?: (pageTitle: string) => void;
onBackMobile?: () => void;
onSyncStatusChange?: (status: DocSyncStatus) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
onLiveStateChange?: (state: DocLiveState, initiatorName?: string) => void;
registerLiveToggle?: (handler: () => void) => void;
}
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
@@ -154,6 +160,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onWikiLinkClick,
onBackMobile,
onSyncStatusChange,
onSaveStatusChange,
onLiveStateChange,
registerLiveToggle,
}) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
@@ -165,11 +174,29 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
// Live Collaboration & Locks state
const [liveSession, setLiveSession] = useState<LiveSessionInfo | null>(null);
const [isLiveEnabled, setIsLiveEnabled] = useState<boolean>(false);
const [activeBlockLocks, setActiveBlockLocks] = useState<Record<string, BlockLockInfo>>({});
// AutoSave state
const [saveStatus, setSaveStatus] = useState<DocSaveStatus>('saved');
const [, setSaveErrorMessage] = useState<string | null>(null);
const saveTimeoutRef = useRef<number | null>(null);
const isSavingRef = useRef<boolean>(false);
const lastSavedSnapshotRef = useRef<string>('');
const isInitialLoadRef = useRef<boolean>(true);
const titleInputRef = useRef<HTMLInputElement | null>(null);
const titleDebounceRef = useRef<number | null>(null);
const docRef = useRef<Document | null>(doc);
docRef.current = doc;
const currentTitleRef = useRef<string>(title);
currentTitleRef.current = title;
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
currentBlocksRef.current = blocks;
useEffect(() => {
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
const focusTitle = () => {
@@ -209,6 +236,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
return doc.permission === 'owner' || doc.permission === 'write';
}, [doc]);
// Is this document completely locked because another user has an active live session?
const isLockedByOtherLive = Boolean(liveSession && liveSession.initiatorId !== currentUser?.id);
const effectiveCanEdit = canEdit && !isLockedByOtherLive;
// Handle incoming WebSocket events for THIS document room
const handleRemoteEvent = useCallback(
(evt: DocSyncEvent) => {
@@ -334,6 +365,137 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
[docId, onDocMetadataChange]
);
// Extract all #tags present across the current blocks
const extractedTags = useMemo(() => {
const tagSet = new Set<string>();
for (const b of blocks) {
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
if (matches) {
for (const m of matches) {
tagSet.add(m.slice(1).toLowerCase());
}
}
}
return Array.from(tagSet);
}, [blocks]);
const currentTagsRef = useRef<string[]>(extractedTags);
currentTagsRef.current = extractedTags;
// AutoSave implementation (authoritative persistence via HTTP REST)
const currentSnapshot = useMemo(() => {
return JSON.stringify({
title,
blocks,
tags: extractedTags,
});
}, [title, blocks, extractedTags]);
const currentSnapshotRef = useRef<string>(currentSnapshot);
currentSnapshotRef.current = currentSnapshot;
const executeAutoSave = useCallback(
async (immediate: boolean = false) => {
if (!docId || !canEdit || isLockedByOtherLive) return;
if (isSavingRef.current && !immediate) return;
const snapshot = currentSnapshotRef.current;
if (snapshot === lastSavedSnapshotRef.current) {
setSaveStatus('saved');
onSaveStatusChange?.('saved');
return;
}
isSavingRef.current = true;
setSaveStatus('saving');
onSaveStatusChange?.('saving');
try {
const updated = await api.updateDocument(docId, {
title: currentTitleRef.current,
blocks: currentBlocksRef.current,
tags: currentTagsRef.current,
});
isSavingRef.current = false;
lastSavedSnapshotRef.current = snapshot;
setSaveStatus('saved');
setSaveErrorMessage(null);
onSaveStatusChange?.('saved');
if (docRef.current) {
docRef.current = updated;
onDocMetadataChange(updated);
}
} catch (err: any) {
isSavingRef.current = false;
const msg = err?.message || 'Fehler beim Speichern';
setSaveStatus('error');
setSaveErrorMessage(msg);
onSaveStatusChange?.('error', msg);
}
},
[docId, canEdit, isLockedByOtherLive, onSaveStatusChange, onDocMetadataChange]
);
useEffect(() => {
if (isInitialLoadRef.current) return;
if (currentSnapshot === lastSavedSnapshotRef.current) return;
setSaveStatus('unsaved');
onSaveStatusChange?.('unsaved');
if (saveTimeoutRef.current !== null) {
window.clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = window.setTimeout(() => {
saveTimeoutRef.current = null;
executeAutoSave();
}, 800);
return () => {
if (saveTimeoutRef.current !== null) {
window.clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
};
}, [currentSnapshot, executeAutoSave, onSaveStatusChange]);
// Flush on page unload / pagehide via keepalive fetch
useEffect(() => {
const handleUnload = () => {
if (saveTimeoutRef.current !== null) {
window.clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
const snapshot = currentSnapshotRef.current;
if (snapshot !== lastSavedSnapshotRef.current && canEdit && !isLockedByOtherLive) {
const token = getToken();
if (token) {
fetch(`/api/v1/documents/${docId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: currentTitleRef.current,
blocks: currentBlocksRef.current,
tags: currentTagsRef.current,
}),
keepalive: true,
}).catch(() => {});
}
}
};
window.addEventListener('beforeunload', handleUnload);
window.addEventListener('pagehide', handleUnload);
return () => {
window.removeEventListener('beforeunload', handleUnload);
window.removeEventListener('pagehide', handleUnload);
handleUnload();
};
}, [docId, canEdit, isLockedByOtherLive]);
const {
status: syncStatus,
remoteCursors,
@@ -343,12 +505,158 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
sendBlockDeleted,
sendBlockMoved,
sendTitleUpdated,
} = useDocumentSync(docId, handleRemoteEvent, canEdit, currentUser);
} = useDocumentSync(docId, handleRemoteEvent, effectiveCanEdit, currentUser, isLiveEnabled);
useEffect(() => {
onSyncStatusChange?.(syncStatus);
}, [syncStatus, onSyncStatusChange]);
const computedLiveState: DocLiveState = useMemo(() => {
if (isLockedByOtherLive) return 'locked_by_other';
if (!isLiveEnabled) return 'idle';
if (syncStatus === 'connecting' || syncStatus === 'reconnecting') return 'connecting';
if (syncStatus === 'blocked') return 'blocked';
if (syncStatus === 'connected') return 'active';
return 'idle';
}, [isLockedByOtherLive, isLiveEnabled, syncStatus]);
useEffect(() => {
onLiveStateChange?.(computedLiveState, liveSession?.initiatorName);
}, [computedLiveState, liveSession?.initiatorName, onLiveStateChange]);
const handleToggleLive = useCallback(() => {
if (isLiveEnabled) {
api.endLiveSession(docId).catch(() => {});
setIsLiveEnabled(false);
setLiveSession(null);
} else {
if (isLockedByOtherLive) return;
api.startLiveSession(docId)
.then((session) => {
setLiveSession(session);
setIsLiveEnabled(true);
})
.catch((err) => {
console.error('[DocumentEditor] Failed to start live session:', err);
});
}
}, [docId, isLiveEnabled, isLockedByOtherLive]);
useEffect(() => {
if (registerLiveToggle) {
registerLiveToggle(handleToggleLive);
}
}, [registerLiveToggle, handleToggleLive]);
// Load initial locks on mount
useEffect(() => {
let cancelled = false;
api.getDocLocks(docId)
.then((res) => {
if (cancelled) return;
if (res.liveSession) {
setLiveSession(res.liveSession);
if (res.liveSession.initiatorId === currentUser?.id) {
setIsLiveEnabled(true);
}
}
if (Array.isArray(res.blockLocks)) {
const map: Record<string, BlockLockInfo> = {};
for (const l of res.blockLocks) {
map[l.blockId] = l;
}
setActiveBlockLocks(map);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [docId, currentUser?.id]);
// Subscribe to realtime lock & live session events
useEffect(() => {
const unsub = onWebSocketMessage((msg: WSMessage) => {
if (msg.event === 'DOC_BLOCK_LOCKED' && msg.payload?.docId === docId) {
setActiveBlockLocks((prev) => ({
...prev,
[msg.payload.blockId]: msg.payload,
}));
} else if (msg.event === 'DOC_BLOCK_UNLOCKED' && msg.payload?.docId === docId) {
setActiveBlockLocks((prev) => {
const next = { ...prev };
delete next[msg.payload.blockId];
return next;
});
} else if (msg.event === 'DOC_LIVE_STARTED' && msg.payload?.docId === docId) {
setLiveSession(msg.payload);
} else if (msg.event === 'DOC_LIVE_ENDED' && msg.payload?.docId === docId) {
setLiveSession(null);
if (liveSession?.initiatorId === currentUser?.id) {
setIsLiveEnabled(false);
}
}
});
return () => {
unsub();
};
}, [docId, currentUser?.id, liveSession?.initiatorId]);
// Block focus/blur locks
const localUserColor = useMemo(
() => getUserColor(currentUser?.id || 'anon'),
[currentUser?.id]
);
const focusedBlockIdRef = useRef<string | null>(null);
const lockKeepaliveTimerRef = useRef<number | null>(null);
const handleFocusBlock = useCallback(
(blockId: string) => {
if (!effectiveCanEdit) return;
focusedBlockIdRef.current = blockId;
api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {});
if (lockKeepaliveTimerRef.current !== null) {
window.clearInterval(lockKeepaliveTimerRef.current);
}
lockKeepaliveTimerRef.current = window.setInterval(() => {
if (focusedBlockIdRef.current === blockId) {
api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {});
}
}, 15000);
},
[docId, effectiveCanEdit, localUserColor]
);
const handleBlurBlock = useCallback(
(blockId: string) => {
if (lockKeepaliveTimerRef.current !== null) {
window.clearInterval(lockKeepaliveTimerRef.current);
lockKeepaliveTimerRef.current = null;
}
if (focusedBlockIdRef.current === blockId) {
focusedBlockIdRef.current = null;
}
api.unlockBlock(docId, blockId).catch(() => {});
executeAutoSave();
},
[docId, executeAutoSave]
);
useEffect(() => {
return () => {
if (lockKeepaliveTimerRef.current !== null) {
window.clearInterval(lockKeepaliveTimerRef.current);
lockKeepaliveTimerRef.current = null;
}
if (focusedBlockIdRef.current) {
api.unlockBlock(docId, focusedBlockIdRef.current).catch(() => {});
}
};
}, [docId]);
// Fetch isolated document state on mount
useEffect(() => {
let isCancelled = false;
@@ -361,11 +669,31 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
.getDocument(docId)
.then((fetched) => {
if (isCancelled) return;
const normalized = ensureNonEmptyBlocks(docId, fetched.blocks);
setDoc(fetched);
setTitle(fetched.title || 'Unbenannte Notiz');
setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks));
setBlocks(normalized);
setLoading(false);
onDocMetadataChange(fetched);
const initialTags: string[] = [];
for (const b of normalized) {
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
if (matches) {
for (const m of matches) {
const tag = m.slice(1).toLowerCase();
if (!initialTags.includes(tag)) initialTags.push(tag);
}
}
}
lastSavedSnapshotRef.current = JSON.stringify({
title: fetched.title || 'Unbenannte Notiz',
blocks: normalized,
tags: initialTags,
});
isInitialLoadRef.current = false;
setSaveStatus('saved');
onSaveStatusChange?.('saved');
})
.catch((err: any) => {
if (isCancelled) return;
@@ -382,20 +710,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
};
}, [docId]);
// Extract all #tags present across the current blocks
const extractedTags = useMemo(() => {
const tagSet = new Set<string>();
for (const b of blocks) {
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
if (matches) {
for (const m of matches) {
tagSet.add(m.slice(1).toLowerCase());
}
}
}
return Array.from(tagSet);
}, [blocks]);
// Keep parent sidebar metadata in sync when blocks/tags change
useEffect(() => {
if (!docRef.current) return;
@@ -425,7 +739,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}, [remoteCursors]);
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!canEdit) return;
if (!effectiveCanEdit) return;
const nextTitle = e.target.value;
setTitle(nextTitle);
@@ -434,7 +748,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}
titleDebounceRef.current = window.setTimeout(() => {
titleDebounceRef.current = null;
sendTitleUpdated(nextTitle);
if (isLiveEnabled) {
sendTitleUpdated(nextTitle);
}
}, 250);
};
@@ -442,8 +758,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
titleDebounceRef.current = null;
sendTitleUpdated(title);
if (isLiveEnabled) {
sendTitleUpdated(title);
}
}
executeAutoSave();
};
// Flush title debouncing on window blur so background window never holds stale pending title
@@ -452,25 +771,31 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
titleDebounceRef.current = null;
sendTitleUpdated(title);
if (isLiveEnabled) {
sendTitleUpdated(title);
}
}
};
window.addEventListener('blur', handleWindowBlur);
return () => window.removeEventListener('blur', handleWindowBlur);
}, [title, sendTitleUpdated]);
}, [title, isLiveEnabled, sendTitleUpdated]);
const handleUpdateBlock = useCallback(
(updatedBlock: DocumentBlock) => {
if (!effectiveCanEdit) return;
setBlocks((prev) =>
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
);
sendBlockUpdated(updatedBlock);
if (isLiveEnabled) {
sendBlockUpdated(updatedBlock);
}
},
[sendBlockUpdated]
[effectiveCanEdit, isLiveEnabled, sendBlockUpdated]
);
const handleCreateBlock = useCallback(
(newBlock: DocumentBlock, afterBlockId?: string) => {
if (!effectiveCanEdit) return;
setBlocks((prev) => {
const next = [...prev];
if (afterBlockId) {
@@ -478,33 +803,41 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
if (idx >= 0) {
next.splice(idx + 1, 0, newBlock);
const normalized = ensureNonEmptyBlocks(docId, next);
sendBlockCreated(newBlock, afterBlockId, normalized);
if (isLiveEnabled) {
sendBlockCreated(newBlock, afterBlockId, normalized);
}
return normalized;
}
}
next.push(newBlock);
const normalized = ensureNonEmptyBlocks(docId, next);
sendBlockCreated(newBlock, afterBlockId, normalized);
if (isLiveEnabled) {
sendBlockCreated(newBlock, afterBlockId, normalized);
}
return normalized;
});
},
[docId, sendBlockCreated]
[docId, effectiveCanEdit, isLiveEnabled, sendBlockCreated]
);
const handleDeleteBlock = useCallback(
(blockId: string) => {
if (!effectiveCanEdit) return;
setBlocks((prev) => {
const filtered = prev.filter((b) => b.id !== blockId);
const normalized = ensureNonEmptyBlocks(docId, filtered);
sendBlockDeleted(blockId, normalized);
if (isLiveEnabled) {
sendBlockDeleted(blockId, normalized);
}
return normalized;
});
},
[docId, sendBlockDeleted]
[docId, effectiveCanEdit, isLiveEnabled, sendBlockDeleted]
);
const handleMoveBlock = useCallback(
(blockId: string, newIndex: number, level?: number) => {
if (!effectiveCanEdit) return;
setBlocks((prev) => {
const fromIdx = prev.findIndex((b) => b.id === blockId);
if (fromIdx === -1) return prev;
@@ -517,11 +850,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length));
copy.splice(boundedIdx, 0, updatedMoving);
const normalized = ensureNonEmptyBlocks(docId, copy);
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
if (isLiveEnabled) {
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
}
return normalized;
});
},
[docId, sendBlockMoved]
[docId, effectiveCanEdit, isLiveEnabled, sendBlockMoved]
);
const handleTogglePin = async () => {
@@ -537,7 +872,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const handleSelectIcon = async (icon: string) => {
setShowEmojiPicker(false);
if (!doc || !canEdit) return;
if (!doc || !effectiveCanEdit) return;
try {
const updated = await api.updateDocument(doc.id, { icon });
setDoc(updated);
@@ -790,6 +1125,16 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</div>
</div>
{/* Live Session Active Banner (when another collaborator is live) */}
{isLockedByOtherLive && (
<div className="flex items-center gap-2 px-4 sm:px-6 py-2.5 bg-status-warn/15 border-b border-status-warn/30 text-status-warn text-xs font-medium">
<Lock className="w-4 h-4 shrink-0" />
<span>
Live-Sitzung aktiv (Bearbeitet von <strong>{liveSession?.initiatorName || 'anderer Nutzer'}</strong>) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden.
</span>
</div>
)}
{/* Main Content Area */}
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
{zoomedBlock ? (
@@ -830,7 +1175,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}
}}
placeholder="Ast-Titel…"
readOnly={!canEdit}
readOnly={!effectiveCanEdit}
className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
/>
</div>
@@ -850,7 +1195,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{childBlocks.length === 0 ? (
<div className="py-8 text-center text-xs text-faint border border-dashed border-border rounded-card my-2">
<p className="mb-2">Dieser Ast hat noch keine Unterpunkte.</p>
{canEdit && (
{effectiveCanEdit && (
<button
type="button"
onClick={() => {
@@ -875,7 +1220,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<OutlinerTree
docId={docId}
blocks={childBlocks}
canEdit={canEdit}
canEdit={effectiveCanEdit}
levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId}
remoteCursors={remoteCursors}
@@ -887,6 +1232,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
activeBlockLocks={activeBlockLocks}
onFocusBlock={handleFocusBlock}
onBlurBlock={handleBlurBlock}
currentUserId={currentUser?.id}
/>
)}
</div>
@@ -900,13 +1249,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<div className="relative">
<button
type="button"
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
onClick={() => effectiveCanEdit && setShowEmojiPicker((prev) => !prev)}
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none"
title="Icon ändern"
>
{doc?.icon || '📝'}
</button>
{showEmojiPicker && canEdit && (
{showEmojiPicker && effectiveCanEdit && (
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
{EMOJI_PALETTE.map((emoji) => (
<button
@@ -926,7 +1275,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
ref={titleInputRef}
type="text"
value={title}
readOnly={!canEdit}
readOnly={!effectiveCanEdit}
autoFocus={autoFocusTitle}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
@@ -939,7 +1288,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<OutlinerTree
docId={docId}
blocks={blocks}
canEdit={canEdit}
canEdit={effectiveCanEdit}
levelOffset={0}
onZoomBlock={setZoomedBlockId}
remoteCursors={remoteCursors}
@@ -951,6 +1300,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
activeBlockLocks={activeBlockLocks}
onFocusBlock={handleFocusBlock}
onBlurBlock={handleBlurBlock}
currentUserId={currentUser?.id}
/>
</>
)}