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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
2f92e1f49a
commit
ed673b0707
7 files changed
+1353
-338
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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' });
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user