- 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)
269 lines
9.6 KiB
TypeScript
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,
|
|
}
|
|
}
|