Files
Lageplan/src/hooks/use-realtime-sync.ts
Pepe Ziberi 5917fa88ad v1.3.0: Refactoring Phase 3+4, Symbol-Verwaltung Redesign, Schlauch-Labels Fix
- Refactoring: Error Boundaries, apiFetch Wrapper, Socket Status-Tracking
- Refactoring: UI Kontrast (theme-aware colors), unused imports bereinigt
- Symbol-Verwaltung: Neues Split-Panel (Meine Symbole + Bibliothek)
- Symbol-Verwaltung: Umbenennen (TLF rot/blau), Duplikate erlaubt
- Symbol-Verwaltung: Karten-Sidebar zeigt eigene Symbole bevorzugt
- Schlauch-Labels: Groessere Schrift (13px/10px), verschiebbar (Drag)
- Schema: TenantSymbol customName, sortOrder, unique constraint entfernt
- Open Source Referenz entfernt (kostenloses Projekt)
2026-02-25 00:06:39 +01:00

269 lines
9.6 KiB
TypeScript

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<DrawFeature[]>
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<string>('')
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<any>(null)
const prevProjectIdRef = useRef<string | null>(null)
// Throttled socket broadcast for near-real-time sync
const lastEmitRef = useRef(0)
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
}
}