fix(workspace): streamline write lock release, sync, and active editor badge
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
This commit is contained in:
1 parent
51244c736c
commit
920716be25
3 files changed
+132
-73
No files matched your search
@@ -47,7 +47,7 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, User } from '../../lib/types';
|
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, User } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, getToken, triggerHaptic } from '../../lib/api';
|
||||||
|
|
||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
@@ -155,6 +155,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const heartbeatTimerRef = useRef<any>(null);
|
const heartbeatTimerRef = useRef<any>(null);
|
||||||
const idleUnlockTimerRef = useRef<any>(null);
|
const idleUnlockTimerRef = useRef<any>(null);
|
||||||
const currentDocIdRef = useRef<string | null>(null);
|
const currentDocIdRef = useRef<string | null>(null);
|
||||||
|
const isEditorFocusedRef = useRef(false);
|
||||||
|
|
||||||
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
|
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
|
||||||
|
|
||||||
@@ -181,12 +182,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setHoldsLock(false);
|
setHoldsLock(false);
|
||||||
stopHeartbeat();
|
stopHeartbeat();
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 4000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const releaseMyLock = async (docId?: string) => {
|
const releaseMyLock = async (docId?: string) => {
|
||||||
const targetDocId = docId || currentDocIdRef.current;
|
const targetDocId = docId || currentDocIdRef.current;
|
||||||
if (!targetDocId || !holdsLockRef.current) return;
|
if (!targetDocId) return;
|
||||||
|
const wasHolding = holdsLockRef.current;
|
||||||
stopHeartbeat();
|
stopHeartbeat();
|
||||||
if (idleUnlockTimerRef.current) {
|
if (idleUnlockTimerRef.current) {
|
||||||
clearTimeout(idleUnlockTimerRef.current);
|
clearTimeout(idleUnlockTimerRef.current);
|
||||||
@@ -194,24 +196,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
holdsLockRef.current = false;
|
holdsLockRef.current = false;
|
||||||
setHoldsLock(false);
|
setHoldsLock(false);
|
||||||
try {
|
if (wasHolding || docId) {
|
||||||
await api.unlockDocument(targetDocId);
|
try {
|
||||||
} catch {}
|
await api.unlockDocument(targetDocId);
|
||||||
};
|
} catch {}
|
||||||
|
|
||||||
const resetIdleTimer = () => {
|
|
||||||
if (idleUnlockTimerRef.current) {
|
|
||||||
clearTimeout(idleUnlockTimerRef.current);
|
|
||||||
}
|
}
|
||||||
idleUnlockTimerRef.current = setTimeout(() => {
|
|
||||||
releaseMyLock();
|
|
||||||
}, 25000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureLock = async (): Promise<boolean> => {
|
const ensureLock = async (): Promise<boolean> => {
|
||||||
if (!selectedDoc || selectedDoc.permission === 'read') return false;
|
if (!selectedDoc || selectedDoc.permission === 'read') return false;
|
||||||
if (holdsLockRef.current) {
|
if (holdsLockRef.current) {
|
||||||
resetIdleTimer();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -221,7 +215,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setHoldsLock(true);
|
setHoldsLock(true);
|
||||||
setActiveLock(null);
|
setActiveLock(null);
|
||||||
startHeartbeat(selectedDoc.id);
|
startHeartbeat(selectedDoc.id);
|
||||||
resetIdleTimer();
|
|
||||||
return true;
|
return true;
|
||||||
} else if (res.lock) {
|
} else if (res.lock) {
|
||||||
setActiveLock(res.lock);
|
setActiveLock(res.lock);
|
||||||
@@ -235,6 +228,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBlockFocus = () => {
|
||||||
|
isEditorFocusedRef.current = true;
|
||||||
|
if (idleUnlockTimerRef.current) {
|
||||||
|
clearTimeout(idleUnlockTimerRef.current);
|
||||||
|
idleUnlockTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (!isReadOnly) {
|
||||||
|
ensureLock();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlockBlur = () => {
|
||||||
|
isEditorFocusedRef.current = false;
|
||||||
|
if (idleUnlockTimerRef.current) {
|
||||||
|
clearTimeout(idleUnlockTimerRef.current);
|
||||||
|
}
|
||||||
|
// Short grace period: if user clicked into another block in the doc, focus will cancel this.
|
||||||
|
// If user clicked outside or stopped editing, save and release the lock immediately!
|
||||||
|
idleUnlockTimerRef.current = setTimeout(async () => {
|
||||||
|
if (!isEditorFocusedRef.current && holdsLockRef.current) {
|
||||||
|
await flushPendingSave();
|
||||||
|
await releaseMyLock();
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
// Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD)
|
// Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD)
|
||||||
const debounceSaveTimerRef = useRef<any>(null);
|
const debounceSaveTimerRef = useRef<any>(null);
|
||||||
const pendingSaveDocRef = useRef<Document | null>(null);
|
const pendingSaveDocRef = useRef<Document | null>(null);
|
||||||
@@ -274,6 +293,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
pendingSaveDocRef.current = null;
|
pendingSaveDocRef.current = null;
|
||||||
setSaveState('saved');
|
setSaveState('saved');
|
||||||
}
|
}
|
||||||
|
if (!isEditorFocusedRef.current && holdsLockRef.current) {
|
||||||
|
releaseMyLock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -342,6 +364,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
loadDocs(initialDocId);
|
loadDocs(initialDocId);
|
||||||
}, [initialDocId]);
|
}, [initialDocId]);
|
||||||
|
|
||||||
|
const selectedDocIdRef = useRef<string | null>(null);
|
||||||
|
selectedDocIdRef.current = selectedDoc?.id || null;
|
||||||
|
const currentUserIdRef = useRef<string | null>(null);
|
||||||
|
currentUserIdRef.current = currentUser?.id || null;
|
||||||
|
|
||||||
// Handle document switch and lock status checks
|
// Handle document switch and lock status checks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevId = currentDocIdRef.current;
|
const prevId = currentDocIdRef.current;
|
||||||
@@ -374,13 +401,36 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
};
|
};
|
||||||
}, [selectedDoc?.id]);
|
}, [selectedDoc?.id]);
|
||||||
|
|
||||||
|
// Window blur / visibility change listener: release lock when user leaves tab/window
|
||||||
|
useEffect(() => {
|
||||||
|
const handleWindowBlur = () => {
|
||||||
|
if (!isEditorFocusedRef.current && holdsLockRef.current) {
|
||||||
|
flushPendingSave();
|
||||||
|
releaseMyLock();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
isEditorFocusedRef.current = false;
|
||||||
|
flushPendingSave();
|
||||||
|
releaseMyLock();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('blur', handleWindowBlur);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('blur', handleWindowBlur);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Flush pending save and release lock before tab closes or unmounts
|
// Flush pending save and release lock before tab closes or unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeUnload = () => {
|
const handleBeforeUnload = () => {
|
||||||
flushPendingSave();
|
flushPendingSave();
|
||||||
if (currentDocIdRef.current && holdsLockRef.current) {
|
if (currentDocIdRef.current && holdsLockRef.current) {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('mindsift_token') || sessionStorage.getItem('mindsift_token');
|
const token = getToken();
|
||||||
fetch(`/api/v1/documents/${currentDocIdRef.current}/lock`, {
|
fetch(`/api/v1/documents/${currentDocIdRef.current}/lock`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
@@ -407,16 +457,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
if (msg.event === 'DOC_LOCKED') {
|
if (msg.event === 'DOC_LOCKED') {
|
||||||
const payload = msg.payload as DocLockInfo;
|
const payload = msg.payload as DocLockInfo;
|
||||||
if (payload && payload.docId === selectedDoc?.id) {
|
if (payload && payload.docId === selectedDocIdRef.current) {
|
||||||
if (payload.userId !== currentUser?.id) {
|
if (payload.userId !== currentUserIdRef.current) {
|
||||||
setActiveLock(payload);
|
setActiveLock(payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.event === 'DOC_UNLOCKED') {
|
} else if (msg.event === 'DOC_UNLOCKED') {
|
||||||
const payload = msg.payload as { docId?: string; doc_id?: string; id?: string };
|
const payload = msg.payload as { docId?: string; doc_id?: string; id?: string };
|
||||||
const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id;
|
const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id;
|
||||||
if (unlockedDocId && unlockedDocId === selectedDoc?.id) {
|
if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) {
|
||||||
setActiveLock(null);
|
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)));
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} else if (msg.event === 'DOC_CREATED') {
|
} else if (msg.event === 'DOC_CREATED') {
|
||||||
const created = msg.payload as Document;
|
const created = msg.payload as Document;
|
||||||
@@ -430,7 +487,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setDocs((prev) =>
|
setDocs((prev) =>
|
||||||
prev.map((d) => (d.id === updated.id ? { ...d, ...updated, isPinned: d.isPinned } : d))
|
prev.map((d) => (d.id === updated.id ? { ...d, ...updated, isPinned: d.isPinned } : d))
|
||||||
);
|
);
|
||||||
if (selectedDoc?.id === updated.id) {
|
if (selectedDocIdRef.current === updated.id) {
|
||||||
if (!holdsLockRef.current) {
|
if (!holdsLockRef.current) {
|
||||||
setSelectedDoc((prev) => (prev ? { ...prev, ...updated, isPinned: prev.isPinned } : updated));
|
setSelectedDoc((prev) => (prev ? { ...prev, ...updated, isPinned: prev.isPinned } : updated));
|
||||||
}
|
}
|
||||||
@@ -441,7 +498,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
|
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
|
||||||
if (deletedId) {
|
if (deletedId) {
|
||||||
setDocs((prev) => prev.filter((d) => d.id !== deletedId));
|
setDocs((prev) => prev.filter((d) => d.id !== deletedId));
|
||||||
if (selectedDoc?.id === deletedId) {
|
if (selectedDocIdRef.current === deletedId) {
|
||||||
setSelectedDoc(null);
|
setSelectedDoc(null);
|
||||||
}
|
}
|
||||||
onStatsChange();
|
onStatsChange();
|
||||||
@@ -451,7 +508,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
window.addEventListener('mindsift:ws', handleWs);
|
window.addEventListener('mindsift:ws', handleWs);
|
||||||
return () => window.removeEventListener('mindsift:ws', handleWs);
|
return () => window.removeEventListener('mindsift:ws', handleWs);
|
||||||
}, [selectedDoc?.id, currentUser?.id, onStatsChange]);
|
}, [onStatsChange]);
|
||||||
|
|
||||||
const openShareModal = async () => {
|
const openShareModal = async () => {
|
||||||
if (!selectedDoc) return;
|
if (!selectedDoc) return;
|
||||||
@@ -1904,14 +1961,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
{selectedDoc.isOwner === false && (
|
{selectedDoc.isOwner === false && (
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 rounded text-[10px] font-semibold border flex items-center gap-1 ${
|
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle"
|
||||||
selectedDoc.permission === 'read'
|
title={`Geteilt von ${selectedDoc.ownerName || 'Eigentümer'}`}
|
||||||
? 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30'
|
|
||||||
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{selectedDoc.permission === 'read' ? 'Nur Lesezugriff' : 'Schreibzugriff'}
|
<Users className="w-2.5 h-2.5" />
|
||||||
{selectedDoc.ownerName ? ` (von ${selectedDoc.ownerName})` : ' (Geteilt)'}
|
<span>Geteilt von {selectedDoc.ownerName || 'Eigentümer'}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedDoc.permission === 'read' && (
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-semibold border border-amber-500/30 bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
<Lock className="w-2.5 h-2.5" />
|
||||||
|
<span>Nur Lesezugriff</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{holdsLock && (
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||||
|
<span>Schreibzugriff aktiv</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLockedByOther && (
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-semibold border border-amber-500/30 bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center gap-1 animate-pulse">
|
||||||
|
<Lock className="w-2.5 h-2.5" />
|
||||||
|
<span>Wird bearbeitet ({activeLock?.userName || 'anderer Nutzer'})</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2064,10 +2139,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
value={selectedDoc.title}
|
value={selectedDoc.title}
|
||||||
onFocus={() => {
|
onFocus={handleBlockFocus}
|
||||||
if (!isReadOnly) ensureLock();
|
onBlur={handleBlockBlur}
|
||||||
}}
|
|
||||||
onBlur={() => resetIdleTimer()}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (!isReadOnly) {
|
if (!isReadOnly) {
|
||||||
ensureLock();
|
ensureLock();
|
||||||
@@ -2218,10 +2291,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
value={block.content}
|
value={block.content}
|
||||||
onFocus={() => {
|
onFocus={handleBlockFocus}
|
||||||
if (!isReadOnly) ensureLock();
|
onBlur={handleBlockBlur}
|
||||||
}}
|
|
||||||
onBlur={() => resetIdleTimer()}
|
|
||||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||||
className={`w-full bg-transparent text-sm focus:outline-none ${
|
className={`w-full bg-transparent text-sm focus:outline-none ${
|
||||||
@@ -2281,10 +2352,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
value={block.content}
|
value={block.content}
|
||||||
onFocus={() => {
|
onFocus={handleBlockFocus}
|
||||||
if (!isReadOnly) ensureLock();
|
onBlur={handleBlockBlur}
|
||||||
}}
|
|
||||||
onBlur={() => resetIdleTimer()}
|
|
||||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||||
className={`w-full bg-transparent text-lg font-bold text-main focus:outline-none ${
|
className={`w-full bg-transparent text-lg font-bold text-main focus:outline-none ${
|
||||||
@@ -2300,10 +2369,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
value={block.content}
|
value={block.content}
|
||||||
onFocus={() => {
|
onFocus={handleBlockFocus}
|
||||||
if (!isReadOnly) ensureLock();
|
onBlur={handleBlockBlur}
|
||||||
}}
|
|
||||||
onBlur={() => resetIdleTimer()}
|
|
||||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||||
className={`w-full bg-transparent text-base font-semibold text-accent focus:outline-none ${
|
className={`w-full bg-transparent text-base font-semibold text-accent focus:outline-none ${
|
||||||
@@ -2319,10 +2386,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
value={block.content}
|
value={block.content}
|
||||||
onFocus={() => {
|
onFocus={handleBlockFocus}
|
||||||
if (!isReadOnly) ensureLock();
|
onBlur={handleBlockBlur}
|
||||||
}}
|
|
||||||
onBlur={() => resetIdleTimer()}
|
|
||||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||||
className={`w-full bg-transparent text-sm font-semibold text-main focus:outline-none ${
|
className={`w-full bg-transparent text-sm font-semibold text-main focus:outline-none ${
|
||||||
@@ -2340,10 +2405,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
rows={Math.max(1, Math.ceil((block.content || '').length / 70))}
|
rows={Math.max(1, Math.ceil((block.content || '').length / 70))}
|
||||||
value={block.content}
|
value={block.content}
|
||||||
onFocus={() => {
|
onFocus={handleBlockFocus}
|
||||||
if (!isReadOnly) ensureLock();
|
onBlur={handleBlockBlur}
|
||||||
}}
|
|
||||||
onBlur={() => resetIdleTimer()}
|
|
||||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||||
className={`w-full bg-transparent text-xs text-main focus:outline-none resize-none ${
|
className={`w-full bg-transparent text-xs text-main focus:outline-none resize-none ${
|
||||||
@@ -2361,10 +2424,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
rows={Math.max(2, (block.content || '').split('\n').length)}
|
rows={Math.max(2, (block.content || '').split('\n').length)}
|
||||||
value={block.content}
|
value={block.content}
|
||||||
onFocus={() => {
|
onFocus={handleBlockFocus}
|
||||||
if (!isReadOnly) ensureLock();
|
onBlur={handleBlockBlur}
|
||||||
}}
|
|
||||||
onBlur={() => resetIdleTimer()}
|
|
||||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||||
className={`w-full bg-transparent text-xs font-mono text-main focus:outline-none resize-none ${
|
className={`w-full bg-transparent text-xs font-mono text-main focus:outline-none resize-none ${
|
||||||
@@ -2385,10 +2446,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
rows={Math.max(3, (block.content || '').split('\n').length)}
|
rows={Math.max(3, (block.content || '').split('\n').length)}
|
||||||
value={block.content}
|
value={block.content}
|
||||||
onFocus={() => {
|
onFocus={handleBlockFocus}
|
||||||
if (!isReadOnly) ensureLock();
|
onBlur={handleBlockBlur}
|
||||||
}}
|
|
||||||
onBlur={() => resetIdleTimer()}
|
|
||||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||||
className={`w-full bg-transparent font-mono text-xs text-main focus:outline-none resize-none ${
|
className={`w-full bg-transparent font-mono text-xs text-main focus:outline-none resize-none ${
|
||||||
@@ -2444,10 +2503,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
rows={Math.max(1, Math.ceil((block.content || '').length / 80))}
|
rows={Math.max(1, Math.ceil((block.content || '').length / 80))}
|
||||||
value={block.content}
|
value={block.content}
|
||||||
onFocus={() => {
|
onFocus={handleBlockFocus}
|
||||||
if (!isReadOnly) ensureLock();
|
onBlur={handleBlockBlur}
|
||||||
}}
|
|
||||||
onBlur={() => resetIdleTimer()}
|
|
||||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||||
className={`w-full bg-transparent text-sm text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint ${
|
className={`w-full bg-transparent text-sm text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint ${
|
||||||
|
|||||||
@@ -899,7 +899,7 @@ func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
|
|||||||
userName = user.DisplayName
|
userName = user.DisplayName
|
||||||
}
|
}
|
||||||
|
|
||||||
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 30*time.Second)
|
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 12*time.Second)
|
||||||
if !acquired {
|
if !acquired {
|
||||||
w.WriteHeader(http.StatusLocked)
|
w.WriteHeader(http.StatusLocked)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
@@ -922,14 +922,16 @@ func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHeartbeatDocLock(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")
|
||||||
ok := s.wsHub.HeartbeatDocLock(docID, userID, 30*time.Second)
|
ok := s.wsHub.HeartbeatDocLock(docID, userID, 12*time.Second)
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"extended": ok})
|
writeJSON(w, http.StatusOK, map[string]bool{"extended": ok})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUnlockDoc(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")
|
||||||
released := s.wsHub.ReleaseDocLock(docID, userID)
|
user, _ := s.store.GetUserByID(userID)
|
||||||
|
isAdmin := user != nil && user.Role == "admin"
|
||||||
|
released := s.wsHub.ReleaseDocLock(docID, userID, isAdmin)
|
||||||
if released {
|
if released {
|
||||||
s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -185,7 +185,7 @@ func (h *Hub) HeartbeatDocLock(docID, userID string, ttl time.Duration) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) ReleaseDocLock(docID, userID string) bool {
|
func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ func (h *Hub) ReleaseDocLock(docID, userID string) bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if item.userID == userID || userID == "admin" {
|
if item.userID == userID || isAdmin || userID == "admin" {
|
||||||
delete(h.locks, docID)
|
delete(h.locks, docID)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -217,7 +217,7 @@ func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) cleanupLocksLoop() {
|
func (h *Hub) cleanupLocksLoop() {
|
||||||
ticker := time.NewTicker(4 * time.Second)
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
@@ -387,7 +387,7 @@ func (c *Client) readPump() {
|
|||||||
case "HEARTBEAT_DOC":
|
case "HEARTBEAT_DOC":
|
||||||
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
|
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
|
||||||
case "UNLOCK_DOC":
|
case "UNLOCK_DOC":
|
||||||
if c.hub.ReleaseDocLock(docID, c.userID) {
|
if c.hub.ReleaseDocLock(docID, c.userID, false) {
|
||||||
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user