import { useState, useCallback, useEffect, useRef } from 'react' import { getSocket, setSocketRoom } from '@/lib/socket' import type { DrawFeature, Project } from '@/types' interface UseRealtimeSyncOptions { currentProject: Project | null user: { id: string; name: string; role: string } | null featuresRef: React.MutableRefObject setFeatures: (features: DrawFeature[] | ((prev: DrawFeature[]) => DrawFeature[])) => void toast: (opts: { title: string; description?: string; variant?: string }) => void } export function useRealtimeSync({ currentProject, user, featuresRef, setFeatures, toast, }: UseRealtimeSyncOptions) { // Live editing lock state const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null) const [isEditingByMe, setIsEditingByMe] = useState(false) const [editingLoading, setEditingLoading] = useState(false) // Unique session ID per browser tab (survives re-renders, not page reload) const sessionIdRef = useRef('') if (!sessionIdRef.current) { sessionIdRef.current = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` } // ─── Editing Lock: Check status + Heartbeat + Polling ───────── const checkEditingStatus = useCallback(async (projectId: string) => { try { const res = await fetch(`/api/projects/${projectId}/editing?sessionId=${sessionIdRef.current}`) if (!res.ok) return const data = await res.json() if (data.editing) { setEditingBy(data.editingBy) setIsEditingByMe(data.isMe) } else { setEditingBy(null) setIsEditingByMe(false) } } catch (e) { console.warn('[Editing] Status check failed:', e) } }, []) // Check editing status when project changes useEffect(() => { if (!currentProject?.id) { setEditingBy(null) setIsEditingByMe(false) return } checkEditingStatus(currentProject.id) }, [currentProject?.id, checkEditingStatus]) // Heartbeat: keep lock alive every 30s while I'm editing useEffect(() => { if (!currentProject?.id || !isEditingByMe) return const interval = setInterval(async () => { try { await fetch(`/api/projects/${currentProject.id}/editing`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'heartbeat', sessionId: sessionIdRef.current }), }) } catch (e) { console.warn('[Heartbeat] Failed:', e) } }, 30000) return () => clearInterval(interval) }, [currentProject?.id, isEditingByMe]) // Socket.io: real-time sync for features, editing status, journal const socketRef = useRef(null) const prevProjectIdRef = useRef(null) // Throttled socket broadcast for near-real-time sync const lastEmitRef = useRef(0) const emitTimerRef = useRef | null>(null) const currentProjectRef = useRef(currentProject) useEffect(() => { currentProjectRef.current = currentProject }, [currentProject]) const isEditingByMeRef = useRef(false) useEffect(() => { isEditingByMeRef.current = isEditingByMe }, [isEditingByMe]) const broadcastFeatures = useCallback((feats: DrawFeature[]) => { const proj = currentProjectRef.current if (!socketRef.current || !proj?.id || !isEditingByMeRef.current) return const now = Date.now() const emit = () => { socketRef.current?.emit('features-updated', { projectId: proj!.id, features: feats, }) lastEmitRef.current = Date.now() } // Throttle: emit at most every 800ms for snappier sync if (now - lastEmitRef.current > 800) { emit() } else { if (emitTimerRef.current) clearTimeout(emitTimerRef.current) emitTimerRef.current = setTimeout(emit, 800 - (now - lastEmitRef.current)) } }, []) useEffect(() => { if (!currentProject?.id) return const socket = getSocket() socketRef.current = socket // Leave old room, join new room if (prevProjectIdRef.current && prevProjectIdRef.current !== currentProject.id) { socket.emit('leave-project', prevProjectIdRef.current) } socket.emit('join-project', currentProject.id) setSocketRoom(currentProject.id) prevProjectIdRef.current = currentProject.id // Listen for features changes from other clients (only apply if NOT the editor) const onFeaturesChanged = (data: { features: any[] }) => { // Skip if I'm the one editing — my local state is the source of truth if (isEditingByMeRef.current) { console.log('[Socket.io] Ignoring features-changed (I am the editor)') return } if (data.features && Array.isArray(data.features)) { console.log('[Socket.io] Features updated from another client') setFeatures(data.features) } } // Listen for editing status changes from other clients const onEditingStatus = (data: { editing: boolean; editingBy: any; sessionId: string }) => { if (data.sessionId === sessionIdRef.current) return // ignore own events if (data.editing && data.editingBy) { setEditingBy(data.editingBy) setIsEditingByMe(false) } else { setEditingBy(null) setIsEditingByMe(false) } } // Listen for journal changes — trigger a re-fetch in JournalView const onJournalChanged = () => { console.log('[Socket.io] Journal updated from another client') window.dispatchEvent(new CustomEvent('journal-refresh')) } socket.on('features-changed', onFeaturesChanged) socket.on('editing-status', onEditingStatus) socket.on('journal-changed', onJournalChanged) return () => { socket.off('features-changed', onFeaturesChanged) socket.off('editing-status', onEditingStatus) socket.off('journal-changed', onJournalChanged) } }, [currentProject?.id, setFeatures]) // Fallback: check editing status on initial load and every 30s useEffect(() => { if (!currentProject?.id) return checkEditingStatus(currentProject.id) const interval = setInterval(() => checkEditingStatus(currentProject.id), 30000) return () => clearInterval(interval) }, [currentProject?.id, checkEditingStatus]) // Release lock on unmount / page close useEffect(() => { const release = () => { if (currentProject?.id && isEditingByMe) { const blob = new Blob([JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current })], { type: 'application/json' }) navigator.sendBeacon(`/api/projects/${currentProject.id}/editing`, blob) } } window.addEventListener('beforeunload', release) return () => { window.removeEventListener('beforeunload', release) release() } }, [currentProject?.id, isEditingByMe]) const handleStartEditing = useCallback(async () => { if (!currentProject?.id) return setEditingLoading(true) try { const res = await fetch(`/api/projects/${currentProject.id}/editing`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'start', sessionId: sessionIdRef.current }), }) if (!res.ok) { const data = await res.json() toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' }) return } setIsEditingByMe(true) const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() } setEditingBy(editingInfo) // Notify other clients socketRef.current?.emit('editing-changed', { projectId: currentProject.id, editing: true, editingBy: editingInfo, sessionId: sessionIdRef.current, }) toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' }) } catch (e) { toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' }) } finally { setEditingLoading(false) } }, [currentProject?.id, user, toast]) const handleStopEditing = useCallback(async () => { if (!currentProject?.id) return setEditingLoading(true) try { // Save features before releasing lock const currentFeatures = featuresRef.current await fetch(`/api/projects/${currentProject.id}/features`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ features: currentFeatures }), }) // Release lock await fetch(`/api/projects/${currentProject.id}/editing`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current }), }) setIsEditingByMe(false) setEditingBy(null) // Notify other clients: editing stopped + send final features socketRef.current?.emit('editing-changed', { projectId: currentProject.id, editing: false, editingBy: null, sessionId: sessionIdRef.current, }) socketRef.current?.emit('features-updated', { projectId: currentProject.id, features: currentFeatures, }) toast({ title: 'Bearbeitung beendet', description: 'Änderungen gespeichert. Andere können jetzt bearbeiten.' }) } catch (e) { toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht beenden.', variant: 'destructive' }) } finally { setEditingLoading(false) } }, [currentProject?.id, toast, featuresRef]) return { editingBy, isEditingByMe, editingLoading, socketRef, broadcastFeatures, handleStartEditing, handleStopEditing, } }