feat(workspace): migrate notes, blocks, and folders to pure database-first architecture with atomic block patching and live sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s

This commit is contained in:
sandrodach committed 2026-10-26 11:05:09 +01:00
1 parent 2f92e1f49a
commit ed673b0707
7 files changed
+1353 -338

No files matched your search

+397 -97
View File
@@ -49,7 +49,7 @@ import {
User as UserIcon,
Radio,
} from 'lucide-react';
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types';
import { BlockType, Document, DocumentBlock, DocBlocksPatchEvent, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types';
import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected } from '../../lib/api';
import { CodeBlockItem } from './CodeBlockItem';
import { UserAutocomplete } from '../UserAutocomplete';
@@ -237,7 +237,130 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
const isLockedByOther = !!(activeLock && activeLock.userId !== currentUser?.id);
const isReadOnly = selectedDoc?.permission === 'read' || isLockedByOther;
const isReadOnly = selectedDoc?.permission === 'read';
// Track server-synced blocks & metadata per document, plus local unsaved block deltas
const syncedDocMetaRef = useRef<Map<string, { title: string; icon: string; tagsJson: string; isPinned: boolean }>>(new Map());
const syncedBlocksRef = useRef<Map<string, Map<string, DocumentBlock>>>(new Map());
const syncedOrderRef = useRef<Map<string, string[]>>(new Map());
const localDirtyBlocksRef = useRef<Map<string, Map<string, DocumentBlock>>>(new Map());
const localDeletedBlocksRef = useRef<Map<string, Set<string>>>(new Map());
const blocksAreEqual = (a?: DocumentBlock, b?: DocumentBlock): boolean => {
if (!a || !b) return false;
return (
a.id === b.id &&
(a.type || 'bullet') === (b.type || 'bullet') &&
(a.content || '') === (b.content || '') &&
(a.level || 0) === (b.level || 0) &&
!!a.checked === !!b.checked &&
!!a.collapsed === !!b.collapsed &&
(a.lang || '') === (b.lang || '')
);
};
const recordSyncedDoc = (doc: Document) => {
if (!doc || !doc.id) return;
syncedDocMetaRef.current.set(doc.id, {
title: doc.title || '',
icon: doc.icon || '📄',
tagsJson: JSON.stringify((doc.tags || []).filter((t) => !t.startsWith('folder:'))),
isPinned: !!doc.isPinned,
});
const bMap = new Map<string, DocumentBlock>();
const order: string[] = [];
(doc.blocks || []).forEach((b) => {
bMap.set(b.id, { ...b });
order.push(b.id);
});
syncedBlocksRef.current.set(doc.id, bMap);
syncedOrderRef.current.set(doc.id, order);
};
const mergeIncomingBlocks = (
docId: string,
currentBlocks: DocumentBlock[],
patch: { upserted?: DocumentBlock[]; deletedIds?: string[]; blockOrder?: string[] }
): DocumentBlock[] => {
const dirtyMap = localDirtyBlocksRef.current.get(docId);
const localDeleted = localDeletedBlocksRef.current.get(docId);
// Update server baseline refs
let srvMap = syncedBlocksRef.current.get(docId);
if (!srvMap) {
srvMap = new Map<string, DocumentBlock>();
currentBlocks.forEach((b) => srvMap!.set(b.id, { ...b }));
syncedBlocksRef.current.set(docId, srvMap);
}
const deletedSet = new Set<string>(patch.deletedIds || []);
deletedSet.forEach((delId) => srvMap!.delete(delId));
(patch.upserted || []).forEach((b) => srvMap!.set(b.id, { ...b }));
if (patch.blockOrder && patch.blockOrder.length > 0) {
syncedOrderRef.current.set(docId, [...patch.blockOrder]);
}
// Build merged block map preserving any block with unsaved local edits
const blockMap = new Map<string, DocumentBlock>();
currentBlocks.forEach((b) => {
if (!deletedSet.has(b.id) || dirtyMap?.has(b.id)) {
blockMap.set(b.id, b);
}
});
(patch.upserted || []).forEach((b) => {
if (localDeleted?.has(b.id)) {
return;
}
if (dirtyMap?.has(b.id)) {
blockMap.set(b.id, dirtyMap.get(b.id)!);
} else {
blockMap.set(b.id, b);
}
});
// Reconcile ordering
const mergedOrder: string[] = [];
const seen = new Set<string>();
if (patch.blockOrder && patch.blockOrder.length > 0) {
patch.blockOrder.forEach((id) => {
if (blockMap.has(id) && !seen.has(id) && !localDeleted?.has(id)) {
mergedOrder.push(id);
seen.add(id);
}
});
// Preserve any locally created unsaved blocks in their relative position
currentBlocks.forEach((b, idx) => {
if (!seen.has(b.id) && blockMap.has(b.id) && !deletedSet.has(b.id)) {
let insertIdx = 0;
for (let j = idx - 1; j >= 0; j--) {
const predPos = mergedOrder.indexOf(currentBlocks[j].id);
if (predPos !== -1) {
insertIdx = predPos + 1;
break;
}
}
mergedOrder.splice(insertIdx, 0, b.id);
seen.add(b.id);
}
});
} else {
currentBlocks.forEach((b) => {
if (blockMap.has(b.id) && !seen.has(b.id)) {
mergedOrder.push(b.id);
seen.add(b.id);
}
});
(patch.upserted || []).forEach((b) => {
if (blockMap.has(b.id) && !seen.has(b.id)) {
mergedOrder.push(b.id);
seen.add(b.id);
}
});
}
return mergedOrder.map((id) => blockMap.get(id)!).filter(Boolean);
};
const stopHeartbeat = () => {
if (heartbeatTimerRef.current) {
@@ -282,7 +405,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const ensureLock = async (force = false): Promise<boolean> => {
if (!selectedDoc || selectedDoc.permission === 'read') return false;
if (isLockedByOther) return false;
if (holdsLockRef.current && !force) {
return true;
}
@@ -296,8 +418,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return true;
} else if (res.lock) {
setActiveLock(res.lock);
holdsLockRef.current = false;
setHoldsLock(false);
return false;
}
} catch (e) {
@@ -312,7 +432,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null;
}
if (!isReadOnly && !isLockedByOther) {
if (!isReadOnly) {
ensureLock();
}
};
@@ -322,11 +442,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
}
// If a block is currently being dragged, do not schedule an unlock
if (isDraggingBlockRef.current) {
return;
}
// Generous grace period: if user clicked into another block in the doc or paused typing
idleUnlockTimerRef.current = setTimeout(async () => {
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
await flushPendingSave();
@@ -335,7 +453,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}, 8000);
};
// Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD)
// Debounced auto-save refs (500ms delay)
const debounceSaveTimerRef = useRef<any>(null);
const pendingSaveDocRef = useRef<Document | null>(null);
@@ -343,27 +461,86 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (isReadOnly) return;
let saveSucceeded = false;
try {
const saved = await api.updateDocument(docToSave.id, {
title: docToSave.title,
icon: docToSave.icon,
folderPath: docToSave.folderPath,
blocks: docToSave.blocks,
tags: docToSave.tags,
isPinned: docToSave.isPinned,
const docId = docToSave.id;
const dirtyMap = localDirtyBlocksRef.current.get(docId);
const deletedSet = localDeletedBlocksRef.current.get(docId);
const srvBlocks = syncedBlocksRef.current.get(docId);
const srvOrder = syncedOrderRef.current.get(docId) || [];
const upsertedMap = new Map<string, DocumentBlock>();
if (dirtyMap) {
dirtyMap.forEach((b, id) => upsertedMap.set(id, b));
}
(docToSave.blocks || []).forEach((b) => {
const srv = srvBlocks?.get(b.id);
if (!srv || !blocksAreEqual(b, srv)) {
upsertedMap.set(b.id, b);
}
});
const upsertedToSend = Array.from(upsertedMap.values());
const deletedToSend = Array.from(deletedSet?.values() || []);
const currentOrder = (docToSave.blocks || []).map((b) => b.id);
const orderChanged =
currentOrder.length !== srvOrder.length ||
currentOrder.some((id, idx) => id !== srvOrder[idx]);
const srvMeta = syncedDocMetaRef.current.get(docId);
const cleanTags = (docToSave.tags || []).filter((t) => !t.startsWith('folder:'));
const cleanTagsJson = JSON.stringify(cleanTags);
const metaChanged =
!srvMeta ||
srvMeta.title !== docToSave.title ||
srvMeta.icon !== docToSave.icon ||
srvMeta.tagsJson !== cleanTagsJson ||
srvMeta.isPinned !== !!docToSave.isPinned;
// Clear the sent snapshot from dirty/deleted maps before awaiting network call
if (dirtyMap) {
upsertedToSend.forEach((b) => {
const cur = dirtyMap.get(b.id);
if (cur && blocksAreEqual(cur, b)) {
dirtyMap.delete(b.id);
}
});
}
if (deletedSet) {
deletedToSend.forEach((id) => deletedSet.delete(id));
}
if (upsertedToSend.length > 0 || deletedToSend.length > 0 || orderChanged) {
const patchedDoc = await api.patchDocumentBlocks(docId, {
upserted: upsertedToSend,
deletedIds: deletedToSend,
blockOrder: currentOrder,
});
// Update server block baseline with patched result
const nextSrvMap = new Map<string, DocumentBlock>();
const nextSrvOrder: string[] = [];
(patchedDoc.blocks || []).forEach((b) => {
nextSrvMap.set(b.id, { ...b });
nextSrvOrder.push(b.id);
});
syncedBlocksRef.current.set(docId, nextSrvMap);
syncedOrderRef.current.set(docId, nextSrvOrder);
}
if (metaChanged) {
await api.updateDocument(docId, {
title: docToSave.title,
icon: docToSave.icon,
tags: cleanTags,
isPinned: docToSave.isPinned,
});
syncedDocMetaRef.current.set(docId, {
title: docToSave.title,
icon: docToSave.icon,
tagsJson: cleanTagsJson,
isPinned: !!docToSave.isPinned,
});
}
saveSucceeded = true;
setSelectedDoc((prev) =>
prev && prev.id === saved.id
? {
...prev,
...saved,
permission: prev.permission,
isOwner: prev.isOwner,
ownerName: prev.ownerName,
sharedWith: prev.sharedWith,
}
: prev
);
onStatsChange();
api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {});
if (selectedTag) {
@@ -371,11 +548,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
} catch (e: any) {
console.error('Failed to save document to backend', e);
if (e.message && e.message.includes('423')) {
api.getDocLock(docToSave.id).then((res) => {
if (res.lock) setActiveLock(res.lock);
}).catch(() => {});
}
} finally {
if (saveSucceeded) {
if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) {
@@ -383,7 +555,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setSaveState('saved');
}
} else {
// Keep pendingSaveDocRef so changes are preserved and not silently marked as saved!
setSaveState('saving');
}
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
@@ -413,7 +584,40 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const persistDocument = async (updatedDoc: Document, immediate = false) => {
if (isReadOnly) return;
await ensureLock();
ensureLock();
// Record block-level delta against previous local state and server baseline
const docId = updatedDoc.id;
let dirtyMap = localDirtyBlocksRef.current.get(docId);
if (!dirtyMap) {
dirtyMap = new Map<string, DocumentBlock>();
localDirtyBlocksRef.current.set(docId, dirtyMap);
}
let deletedSet = localDeletedBlocksRef.current.get(docId);
if (!deletedSet) {
deletedSet = new Set<string>();
localDeletedBlocksRef.current.set(docId, deletedSet);
}
const srvBlocks = syncedBlocksRef.current.get(docId);
const prevLocalBlocks =
selectedDoc && selectedDoc.id === docId ? selectedDoc.blocks || [] : [];
const nextIds = new Set<string>();
(updatedDoc.blocks || []).forEach((b) => {
nextIds.add(b.id);
deletedSet!.delete(b.id);
const srv = srvBlocks?.get(b.id);
if (!srv || !blocksAreEqual(b, srv)) {
dirtyMap!.set(b.id, { ...b });
}
});
prevLocalBlocks.forEach((prevB) => {
if (!nextIds.has(prevB.id)) {
dirtyMap!.delete(prevB.id);
deletedSet!.add(prevB.id);
}
});
// 1. Update local UI state immediately for responsive, instant typing
setSelectedDoc(updatedDoc);
@@ -430,7 +634,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (immediate) {
await flushPendingSave();
} else {
// 500 ms debounced delay after last keystroke so disk/SSD is only written after typing pauses
debounceSaveTimerRef.current = setTimeout(async () => {
debounceSaveTimerRef.current = null;
if (pendingSaveDocRef.current) {
@@ -438,7 +641,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
pendingSaveDocRef.current = null;
await executeSaveToBackend(doc);
}
}, 500);
}, 350);
}
};
@@ -450,6 +653,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
api.getWorkspaceTags().catch(() => []),
api.getSharedFolders().catch(() => []),
]);
list.forEach(recordSyncedDoc);
setDocs(list);
setCustomFolders(serverFolders);
setWorkspaceTags(tags);
@@ -616,7 +820,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
};
}, []);
// Real-time WebSocket listener for Document & Lock events
// Real-time WebSocket listener for Document, Block Delta & Folder events
useEffect(() => {
const handleWs = (e: any) => {
const msg = e.detail;
@@ -634,30 +838,85 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id;
if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) {
setActiveLock(null);
// Fetch fresh document from backend so updates made by the other user are immediately visible
api.getDocument(unlockedDocId).then((freshDoc) => {
if (freshDoc && freshDoc.id === selectedDocIdRef.current) {
setSelectedDoc((prev) => (prev && prev.id === freshDoc.id ? { ...prev, ...freshDoc, isPinned: prev.isPinned } : prev));
setDocs((prev) => prev.map((d) => (d.id === freshDoc.id ? { ...d, ...freshDoc, isPinned: d.isPinned } : d)));
}
} else if (msg.event === 'DOC_BLOCKS_PATCHED') {
const patch = msg.payload as DocBlocksPatchEvent;
if (!patch || !patch.docId) return;
setDocs((prev) =>
prev.map((d) => {
if (d.id !== patch.docId) return d;
const merged = mergeIncomingBlocks(d.id, d.blocks || [], {
upserted: patch.upserted,
deletedIds: patch.deletedIds,
blockOrder: patch.blockOrder,
});
return { ...d, blocks: merged, updatedAt: patch.updatedAt || d.updatedAt };
})
);
if (selectedDocIdRef.current === patch.docId) {
setSelectedDoc((prev) => {
if (!prev || prev.id !== patch.docId) return prev;
const merged = mergeIncomingBlocks(prev.id, prev.blocks || [], {
upserted: patch.upserted,
deletedIds: patch.deletedIds,
blockOrder: patch.blockOrder,
});
const nextDoc = { ...prev, blocks: merged, updatedAt: patch.updatedAt || prev.updatedAt };
if (pendingSaveDocRef.current && pendingSaveDocRef.current.id === patch.docId) {
pendingSaveDocRef.current = { ...pendingSaveDocRef.current, blocks: merged };
}
}).catch(() => {});
return nextDoc;
});
}
} else if (msg.event === 'DOC_CREATED') {
const created = msg.payload as Document;
if (created && created.id) {
recordSyncedDoc(created);
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
onStatsChange();
}
} else if (msg.event === 'DOC_UPDATED') {
const updated = msg.payload as Document;
if (updated && updated.id) {
const incomingOrder = (updated.blocks || []).map((b) => b.id);
setDocs((prev) =>
prev.map((d) => (d.id === updated.id ? { ...d, ...updated, isPinned: d.isPinned } : d))
prev.map((d) => {
if (d.id !== updated.id) return d;
const mergedBlocks = mergeIncomingBlocks(d.id, d.blocks || [], {
upserted: updated.blocks || [],
blockOrder: incomingOrder,
});
return {
...d,
...updated,
blocks: mergedBlocks,
isPinned: d.isPinned,
};
})
);
if (selectedDocIdRef.current === updated.id) {
if (!holdsLockRef.current) {
setSelectedDoc((prev) => (prev ? { ...prev, ...updated, isPinned: prev.isPinned } : updated));
}
setSelectedDoc((prev) => {
if (!prev || prev.id !== updated.id) return prev;
const mergedBlocks = mergeIncomingBlocks(prev.id, prev.blocks || [], {
upserted: updated.blocks || [],
blockOrder: incomingOrder,
});
const nextDoc = {
...prev,
...updated,
blocks: mergedBlocks,
isPinned: prev.isPinned,
};
if (pendingSaveDocRef.current && pendingSaveDocRef.current.id === updated.id) {
pendingSaveDocRef.current = {
...pendingSaveDocRef.current,
blocks: mergedBlocks,
};
}
return nextDoc;
});
}
onStatsChange();
}
@@ -673,7 +932,28 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
api.getFolders().then(setCustomFolders).catch(() => {});
api.getSharedFolders().then(setSharedFolders).catch(() => {});
api.getDocuments().then(setDocs).catch(() => {});
api.getDocuments().then((freshList) => {
freshList.forEach(recordSyncedDoc);
setDocs(freshList);
if (selectedDocIdRef.current) {
const found = freshList.find((d) => d.id === selectedDocIdRef.current);
if (found) {
setSelectedDoc((prev) =>
prev && prev.id === found.id
? {
...prev,
folderPath: found.folderPath,
sharedFolderId: found.sharedFolderId,
subFolderPath: found.subFolderPath,
isSharedFolder: found.isSharedFolder,
permission: found.permission,
isOwner: found.isOwner,
}
: prev
);
}
}
}).catch(() => {});
}
};
@@ -849,9 +1129,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const getDocFolder = (doc: Document): string => {
if (doc.folderPath) return doc.folderPath;
const tag = doc.tags?.find((t) => t.startsWith('folder:'));
return tag ? tag.replace('folder:', '') : '';
return doc.folderPath || '';
};
// Compute all unique folder paths
@@ -894,9 +1172,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
triggerHaptic(15);
const tags = ['notiz'];
const path = folderPath || '';
if (path) {
tags.push(`folder:${path}`);
}
const created = await api.createDocument({
title: 'Neue Notiz',
icon: '📄',
@@ -945,16 +1220,37 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const handleChangeDocFolder = async (folderPath: string) => {
if (!selectedDoc) return;
const cleanPath = folderPath.trim();
const updatedTags = (selectedDoc.tags || []).filter((t) => !t.startsWith('folder:'));
if (cleanPath) {
updatedTags.push(`folder:${cleanPath}`);
try {
const updatedDoc = await api.moveDocument(selectedDoc.id, cleanPath);
setDocs((prev) =>
prev.map((d) =>
d.id === selectedDoc.id
? {
...d,
folderPath: updatedDoc.folderPath,
sharedFolderId: updatedDoc.sharedFolderId,
subFolderPath: updatedDoc.subFolderPath,
isSharedFolder: updatedDoc.isSharedFolder,
}
: d
)
);
setSelectedDoc((prev) =>
prev && prev.id === selectedDoc.id
? {
...prev,
folderPath: updatedDoc.folderPath,
sharedFolderId: updatedDoc.sharedFolderId,
subFolderPath: updatedDoc.subFolderPath,
isSharedFolder: updatedDoc.isSharedFolder,
}
: prev
);
api.getFolders().then(setCustomFolders).catch(() => {});
} catch (err: any) {
console.error('Failed to change document folder', err);
alert(err.message || 'Fehler beim Verschieben der Notiz');
}
const updatedDoc = {
...selectedDoc,
folderPath: cleanPath,
tags: updatedTags,
};
await persistDocument(updatedDoc, true);
};
// Global drag cleanup so drag states never get stuck if dragend is lost during re-render
@@ -1020,16 +1316,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const updatedDocsList = docs.map((d) => {
const f = getDocFolder(d);
if (f === cleanSource) {
const nextFolder = newFolderPath;
const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:'));
if (nextFolder) updatedTags.push(`folder:${nextFolder}`);
return { ...d, folderPath: nextFolder, tags: updatedTags };
return { ...d, folderPath: newFolderPath };
} else if (f.startsWith(cleanSource + '/')) {
const suffix = f.slice(cleanSource.length);
const nextFolder = `${newFolderPath}${suffix}`;
const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:'));
if (nextFolder) updatedTags.push(`folder:${nextFolder}`);
return { ...d, folderPath: nextFolder, tags: updatedTags };
return { ...d, folderPath: `${newFolderPath}${suffix}` };
}
return d;
});
@@ -1040,25 +1330,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (found) setSelectedDoc(found);
}
// Persist all affected docs to backend using api.moveDocument
for (const d of docsToUpdate) {
const f = getDocFolder(d);
let nextFolder = newFolderPath;
if (f.startsWith(cleanSource + '/')) {
const suffix = f.slice(cleanSource.length);
nextFolder = `${newFolderPath}${suffix}`;
try {
await api.createFolder(newFolderPath);
const subFoldersToRecreate = customFolders.filter((cf) => cf.startsWith(cleanSource + '/'));
for (const sf of subFoldersToRecreate) {
const suffix = sf.slice(cleanSource.length);
await api.createFolder(`${newFolderPath}${suffix}`);
}
api.moveDocument(d.id, nextFolder).catch((err) => console.error('Failed to move doc', err));
// Persist all affected docs to backend BEFORE deleting the old empty folder
await Promise.all(
docsToUpdate.map((d) => {
const f = getDocFolder(d);
let nextFolder = newFolderPath;
if (f.startsWith(cleanSource + '/')) {
const suffix = f.slice(cleanSource.length);
nextFolder = `${newFolderPath}${suffix}`;
}
return api.moveDocument(d.id, nextFolder);
})
);
await api.deleteFolder(cleanSource);
const freshFolders = await api.getFolders();
setCustomFolders(freshFolders);
} catch (err) {
console.error('Error syncing moved folders with server', err);
}
// 2. Sync folder structure with server
api
.createFolder(newFolderPath)
.then(() => api.deleteFolder(cleanSource))
.then(() => api.getFolders())
.then(setCustomFolders)
.catch((err) => console.error('Error syncing moved folders with server', err));
// Expand the new folder path and its parent
setExpandedFolders((prev) => {
const next = new Set(prev);
@@ -2887,10 +3184,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
};
});
// Persist immediately to backend & disk
await api.updateDocument(docId, {
blocks: updatedBlocks,
});
// Persist immediately via atomic block patch
const toggledBlock = updatedBlocks.find((b) => b.id === blockId);
if (toggledBlock) {
await api.patchDocumentBlocks(docId, {
upserted: [toggledBlock],
});
}
};
// Jump directly to document from rollup
@@ -3916,16 +4216,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{isLockedByOther && (
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 rounded-card flex items-center gap-2.5 text-xs font-medium animate-pulse">
<Lock className="w-4 h-4 text-amber-500 flex-shrink-0" />
<div className="p-2.5 bg-emerald-500/10 border border-emerald-500/30 text-emerald-700 dark:text-emerald-300 rounded-card flex items-center gap-2.5 text-xs font-medium">
<Radio className="w-4 h-4 text-emerald-500 animate-pulse flex-shrink-0" />
<div>
<span>Diese Notiz wird aktuell von <strong>{activeLock?.userName || 'einem anderen Benutzer'}</strong> bearbeitet.</span>
<p className="text-[11px] opacity-80 mt-0.5">Die Bearbeitung ist vorübergehend gesperrt, um Zeilen- und Datenverlust zu vermeiden.</p>
<span><strong>{activeLock?.userName || 'Ein anderer Benutzer'}</strong> bearbeitet diese Notiz gerade ebenfalls.</span>
<p className="text-[11px] opacity-80 mt-0.5">Live-Zusammenarbeit aktiv – Änderungen werden automatisch Punkt für Punkt synchronisiert.</p>
</div>
</div>
)}
{!isLockedByOther && selectedDoc.permission === 'read' && (
{selectedDoc.permission === 'read' && (
<div className="p-2.5 bg-blue-500/10 border border-blue-500/30 text-blue-700 dark:text-blue-300 rounded-card flex items-center gap-2 text-xs">
<Eye className="w-4 h-4 text-blue-500 flex-shrink-0" />
<span>Du hast <strong>Nur-Lesezugriff</strong> auf diese Notiz. Änderungen können nicht vorgenommen werden.</span>
+19
View File
@@ -576,6 +576,25 @@ export const api = {
};
},
async patchDocumentBlocks(
id: string,
payload: {
upserted?: DocumentBlock[];
deletedIds?: string[];
blockOrder?: string[];
}
): Promise<Document> {
const d = await request<Document>(`/api/v1/documents/${id}/blocks`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
return {
...d,
blocks: ensureArray(d.blocks),
tags: ensureArray(d.tags),
};
},
async deleteDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
},
+15
View File
@@ -165,6 +165,21 @@ export interface DocumentBlock {
collapsed?: boolean;
}
export interface DocBlocksPatchRequest {
upserted?: DocumentBlock[];
deletedIds?: string[];
blockOrder?: string[];
}
export interface DocBlocksPatchEvent {
docId: string;
updatedBy: string;
updatedAt: string;
upserted?: DocumentBlock[];
deletedIds?: string[];
blockOrder?: string[];
}
export interface DocShare {
id: string;
docId: string;