Dev #2
No files matched your search
@@ -47,7 +47,7 @@ import {
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
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 {
|
||||
currentUser?: User | null;
|
||||
@@ -155,6 +155,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const heartbeatTimerRef = useRef<any>(null);
|
||||
const idleUnlockTimerRef = useRef<any>(null);
|
||||
const currentDocIdRef = useRef<string | null>(null);
|
||||
const isEditorFocusedRef = useRef(false);
|
||||
|
||||
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
|
||||
|
||||
@@ -181,12 +182,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setHoldsLock(false);
|
||||
stopHeartbeat();
|
||||
}
|
||||
}, 10000);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const releaseMyLock = async (docId?: string) => {
|
||||
const targetDocId = docId || currentDocIdRef.current;
|
||||
if (!targetDocId || !holdsLockRef.current) return;
|
||||
if (!targetDocId) return;
|
||||
const wasHolding = holdsLockRef.current;
|
||||
stopHeartbeat();
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
@@ -194,24 +196,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
holdsLockRef.current = false;
|
||||
setHoldsLock(false);
|
||||
if (wasHolding || docId) {
|
||||
try {
|
||||
await api.unlockDocument(targetDocId);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const resetIdleTimer = () => {
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
}
|
||||
idleUnlockTimerRef.current = setTimeout(() => {
|
||||
releaseMyLock();
|
||||
}, 25000);
|
||||
};
|
||||
|
||||
const ensureLock = async (): Promise<boolean> => {
|
||||
if (!selectedDoc || selectedDoc.permission === 'read') return false;
|
||||
if (holdsLockRef.current) {
|
||||
resetIdleTimer();
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
@@ -221,7 +215,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setHoldsLock(true);
|
||||
setActiveLock(null);
|
||||
startHeartbeat(selectedDoc.id);
|
||||
resetIdleTimer();
|
||||
return true;
|
||||
} else if (res.lock) {
|
||||
setActiveLock(res.lock);
|
||||
@@ -235,6 +228,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
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)
|
||||
const debounceSaveTimerRef = useRef<any>(null);
|
||||
const pendingSaveDocRef = useRef<Document | null>(null);
|
||||
@@ -274,6 +293,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
pendingSaveDocRef.current = null;
|
||||
setSaveState('saved');
|
||||
}
|
||||
if (!isEditorFocusedRef.current && holdsLockRef.current) {
|
||||
releaseMyLock();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -342,6 +364,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
loadDocs(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
|
||||
useEffect(() => {
|
||||
const prevId = currentDocIdRef.current;
|
||||
@@ -374,13 +401,36 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
};
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
flushPendingSave();
|
||||
if (currentDocIdRef.current && holdsLockRef.current) {
|
||||
try {
|
||||
const token = localStorage.getItem('mindsift_token') || sessionStorage.getItem('mindsift_token');
|
||||
const token = getToken();
|
||||
fetch(`/api/v1/documents/${currentDocIdRef.current}/lock`, {
|
||||
method: 'DELETE',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
@@ -407,16 +457,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
if (msg.event === 'DOC_LOCKED') {
|
||||
const payload = msg.payload as DocLockInfo;
|
||||
if (payload && payload.docId === selectedDoc?.id) {
|
||||
if (payload.userId !== currentUser?.id) {
|
||||
if (payload && payload.docId === selectedDocIdRef.current) {
|
||||
if (payload.userId !== currentUserIdRef.current) {
|
||||
setActiveLock(payload);
|
||||
}
|
||||
}
|
||||
} else if (msg.event === 'DOC_UNLOCKED') {
|
||||
const payload = msg.payload as { docId?: string; doc_id?: string; id?: string };
|
||||
const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id;
|
||||
if (unlockedDocId && unlockedDocId === selectedDoc?.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)));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else if (msg.event === 'DOC_CREATED') {
|
||||
const created = msg.payload as Document;
|
||||
@@ -430,7 +487,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setDocs((prev) =>
|
||||
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) {
|
||||
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;
|
||||
if (deletedId) {
|
||||
setDocs((prev) => prev.filter((d) => d.id !== deletedId));
|
||||
if (selectedDoc?.id === deletedId) {
|
||||
if (selectedDocIdRef.current === deletedId) {
|
||||
setSelectedDoc(null);
|
||||
}
|
||||
onStatsChange();
|
||||
@@ -451,7 +508,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
window.addEventListener('mindsift:ws', handleWs);
|
||||
return () => window.removeEventListener('mindsift:ws', handleWs);
|
||||
}, [selectedDoc?.id, currentUser?.id, onStatsChange]);
|
||||
}, [onStatsChange]);
|
||||
|
||||
const openShareModal = async () => {
|
||||
if (!selectedDoc) return;
|
||||
@@ -1904,14 +1961,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
{selectedDoc.isOwner === false && (
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-semibold border flex items-center gap-1 ${
|
||||
selectedDoc.permission === 'read'
|
||||
? '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'
|
||||
}`}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle"
|
||||
title={`Geteilt von ${selectedDoc.ownerName || 'Eigentümer'}`}
|
||||
>
|
||||
{selectedDoc.permission === 'read' ? 'Nur Lesezugriff' : 'Schreibzugriff'}
|
||||
{selectedDoc.ownerName ? ` (von ${selectedDoc.ownerName})` : ' (Geteilt)'}
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -2064,10 +2139,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
type="text"
|
||||
readOnly={isReadOnly}
|
||||
value={selectedDoc.title}
|
||||
onFocus={() => {
|
||||
if (!isReadOnly) ensureLock();
|
||||
}}
|
||||
onBlur={() => resetIdleTimer()}
|
||||
onFocus={handleBlockFocus}
|
||||
onBlur={handleBlockBlur}
|
||||
onChange={(e) => {
|
||||
if (!isReadOnly) {
|
||||
ensureLock();
|
||||
@@ -2218,10 +2291,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
type="text"
|
||||
readOnly={isReadOnly}
|
||||
value={block.content}
|
||||
onFocus={() => {
|
||||
if (!isReadOnly) ensureLock();
|
||||
}}
|
||||
onBlur={() => resetIdleTimer()}
|
||||
onFocus={handleBlockFocus}
|
||||
onBlur={handleBlockBlur}
|
||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||
className={`w-full bg-transparent text-sm focus:outline-none ${
|
||||
@@ -2281,10 +2352,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
type="text"
|
||||
readOnly={isReadOnly}
|
||||
value={block.content}
|
||||
onFocus={() => {
|
||||
if (!isReadOnly) ensureLock();
|
||||
}}
|
||||
onBlur={() => resetIdleTimer()}
|
||||
onFocus={handleBlockFocus}
|
||||
onBlur={handleBlockBlur}
|
||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||
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"
|
||||
readOnly={isReadOnly}
|
||||
value={block.content}
|
||||
onFocus={() => {
|
||||
if (!isReadOnly) ensureLock();
|
||||
}}
|
||||
onBlur={() => resetIdleTimer()}
|
||||
onFocus={handleBlockFocus}
|
||||
onBlur={handleBlockBlur}
|
||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||
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"
|
||||
readOnly={isReadOnly}
|
||||
value={block.content}
|
||||
onFocus={() => {
|
||||
if (!isReadOnly) ensureLock();
|
||||
}}
|
||||
onBlur={() => resetIdleTimer()}
|
||||
onFocus={handleBlockFocus}
|
||||
onBlur={handleBlockBlur}
|
||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||
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}
|
||||
rows={Math.max(1, Math.ceil((block.content || '').length / 70))}
|
||||
value={block.content}
|
||||
onFocus={() => {
|
||||
if (!isReadOnly) ensureLock();
|
||||
}}
|
||||
onBlur={() => resetIdleTimer()}
|
||||
onFocus={handleBlockFocus}
|
||||
onBlur={handleBlockBlur}
|
||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||
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}
|
||||
rows={Math.max(2, (block.content || '').split('\n').length)}
|
||||
value={block.content}
|
||||
onFocus={() => {
|
||||
if (!isReadOnly) ensureLock();
|
||||
}}
|
||||
onBlur={() => resetIdleTimer()}
|
||||
onFocus={handleBlockFocus}
|
||||
onBlur={handleBlockBlur}
|
||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||
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}
|
||||
rows={Math.max(3, (block.content || '').split('\n').length)}
|
||||
value={block.content}
|
||||
onFocus={() => {
|
||||
if (!isReadOnly) ensureLock();
|
||||
}}
|
||||
onBlur={() => resetIdleTimer()}
|
||||
onFocus={handleBlockFocus}
|
||||
onBlur={handleBlockBlur}
|
||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
||||
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}
|
||||
rows={Math.max(1, Math.ceil((block.content || '').length / 80))}
|
||||
value={block.content}
|
||||
onFocus={() => {
|
||||
if (!isReadOnly) ensureLock();
|
||||
}}
|
||||
onBlur={() => resetIdleTimer()}
|
||||
onFocus={handleBlockFocus}
|
||||
onBlur={handleBlockBlur}
|
||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
||||
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 ${
|
||||
|
||||
@@ -899,7 +899,7 @@ func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
w.WriteHeader(http.StatusLocked)
|
||||
_ = 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) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
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})
|
||||
}
|
||||
|
||||
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
func (h *Hub) ReleaseDocLock(docID, userID string) bool {
|
||||
func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
@@ -193,7 +193,7 @@ func (h *Hub) ReleaseDocLock(docID, userID string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if item.userID == userID || userID == "admin" {
|
||||
if item.userID == userID || isAdmin || userID == "admin" {
|
||||
delete(h.locks, docID)
|
||||
return true
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
|
||||
}
|
||||
|
||||
func (h *Hub) cleanupLocksLoop() {
|
||||
ticker := time.NewTicker(4 * time.Second)
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
@@ -387,7 +387,7 @@ func (c *Client) readPump() {
|
||||
case "HEARTBEAT_DOC":
|
||||
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user