useEditLock.js (3937B)
1 import { useCallback, useEffect, useRef, useState } from 'react'; 2 import { 3 acquireEditLock, 4 ApiError, 5 refreshEditLock, 6 releaseEditLock, 7 } from '../lib/api.js'; 8 import { getOrCreateClientId } from '../lib/clientId.js'; 9 10 const REFRESH_INTERVAL_MS = 30 * 1000; 11 12 export function useEditLock(editToken, enabled = true) { 13 const [lockState, setLockState] = useState({ 14 status: 'idle', 15 lockToken: null, 16 lockExpiresAt: null, 17 blockedExpiresAt: null, 18 }); 19 const clientIdRef = useRef(getOrCreateClientId()); 20 const lockTokenRef = useRef(null); 21 22 const acquire = useCallback(async () => { 23 if (!editToken || !enabled) return; 24 setLockState((s) => ({ ...s, status: 'acquiring' })); 25 26 try { 27 const result = await acquireEditLock(editToken, clientIdRef.current); 28 lockTokenRef.current = result.lockToken; 29 setLockState({ 30 status: 'held', 31 lockToken: result.lockToken, 32 lockExpiresAt: result.expiresAt, 33 blockedExpiresAt: null, 34 }); 35 return { locked: true, lockToken: result.lockToken }; 36 } catch (error) { 37 if (error instanceof ApiError && error.status === 423) { 38 setLockState({ 39 status: 'blocked', 40 lockToken: null, 41 lockExpiresAt: null, 42 blockedExpiresAt: error.payload?.lockExpiresAt ?? null, 43 }); 44 return { locked: false, lockExpiresAt: error.payload?.lockExpiresAt }; 45 } 46 setLockState({ 47 status: 'error', 48 lockToken: null, 49 lockExpiresAt: null, 50 blockedExpiresAt: null, 51 }); 52 throw error; 53 } 54 }, [editToken, enabled]); 55 56 const refresh = useCallback(async () => { 57 if (!editToken || !lockTokenRef.current) return false; 58 try { 59 const result = await refreshEditLock( 60 editToken, 61 clientIdRef.current, 62 lockTokenRef.current, 63 ); 64 lockTokenRef.current = result.lockToken; 65 setLockState((s) => ({ 66 ...s, 67 status: 'held', 68 lockExpiresAt: result.expiresAt, 69 })); 70 return true; 71 } catch { 72 lockTokenRef.current = null; 73 setLockState({ 74 status: 'lost', 75 lockToken: null, 76 lockExpiresAt: null, 77 blockedExpiresAt: null, 78 }); 79 return false; 80 } 81 }, [editToken]); 82 83 const release = useCallback(async () => { 84 if (!editToken || !lockTokenRef.current) return; 85 const token = lockTokenRef.current; 86 lockTokenRef.current = null; 87 try { 88 await releaseEditLock(editToken, clientIdRef.current, token); 89 } catch { 90 /* best effort */ 91 } 92 setLockState({ 93 status: 'released', 94 lockToken: null, 95 lockExpiresAt: null, 96 blockedExpiresAt: null, 97 }); 98 }, [editToken]); 99 100 useEffect(() => { 101 if (!enabled || lockState.status !== 'held' || !editToken) return; 102 103 const interval = window.setInterval(() => { 104 refresh(); 105 }, REFRESH_INTERVAL_MS); 106 107 return () => window.clearInterval(interval); 108 }, [enabled, editToken, lockState.status, refresh]); 109 110 useEffect(() => { 111 if (!enabled || !editToken) return; 112 113 const onUnload = () => { 114 if (!lockTokenRef.current) return; 115 const body = JSON.stringify({ 116 clientId: clientIdRef.current, 117 lockToken: lockTokenRef.current, 118 }); 119 const url = `${import.meta.env.VITE_API_BASE ?? ''}/api/documents/edit/${encodeURIComponent(editToken)}/lock`; 120 fetch(url, { 121 method: 'DELETE', 122 body, 123 keepalive: true, 124 headers: { 'Content-Type': 'application/json' }, 125 }).catch(() => {}); 126 }; 127 128 window.addEventListener('beforeunload', onUnload); 129 return () => window.removeEventListener('beforeunload', onUnload); 130 }, [enabled, editToken]); 131 132 return { 133 clientId: clientIdRef.current, 134 lockState, 135 acquire, 136 refresh, 137 release, 138 hasLock: lockState.status === 'held' && !!lockTokenRef.current, 139 lockToken: lockTokenRef.current, 140 }; 141 }