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)
This commit is contained in:
Pepe Ziberi
2026-02-25 00:06:39 +01:00
parent 8ddeb7b377
commit 5917fa88ad
30 changed files with 3110 additions and 2120 deletions

101
src/hooks/use-auto-save.ts Normal file
View File

@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef } from 'react'
import type { DrawFeature, Project } from '@/types'
import { addToSyncQueue, getSyncQueue } from '@/lib/offline-sync'
interface UseAutoSaveOptions {
currentProject: Project | null
features: DrawFeature[]
featuresRef: React.MutableRefObject<DrawFeature[]>
mapRef: React.MutableRefObject<any>
socketRef: React.MutableRefObject<any>
isEditingByMe: boolean
setSyncQueueCount: (count: number) => void
}
export function useAutoSave({
currentProject,
features,
featuresRef,
mapRef,
socketRef,
isEditingByMe,
setSyncQueueCount,
}: UseAutoSaveOptions) {
// Persist features to localStorage on change (including empty array to reflect deletions)
useEffect(() => {
localStorage.setItem('lageplan-features', JSON.stringify(features))
}, [features])
// Auto-save to API — debounced 2s after every feature change + fallback interval
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveFeaturesToApi = useCallback(async () => {
if (!currentProject?.id) return
const url = `/api/projects/${currentProject.id}/features`
const mapInstance = mapRef.current
const body: any = { features: featuresRef.current }
if (mapInstance) {
const c = mapInstance.getCenter()
body.mapCenter = { lng: c.lng, lat: c.lat }
body.mapZoom = mapInstance.getZoom()
}
// If offline, queue the save for later sync
if (!navigator.onLine) {
addToSyncQueue(url, 'PUT', body)
setSyncQueueCount(getSyncQueue().length)
console.log('[Auto-Save] Offline — in Sync-Queue gespeichert')
return
}
try {
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
console.log('[Auto-Save] Features gespeichert')
socketRef.current?.emit('features-updated', {
projectId: currentProject.id,
features: featuresRef.current,
})
} else if (res.status === 404) {
console.warn('[Auto-Save] Projekt nicht in DB')
}
} catch (e) {
// Network error — queue for later
addToSyncQueue(url, 'PUT', body)
setSyncQueueCount(getSyncQueue().length)
console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e)
}
}, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount])
// Debounced save on every feature change (2s delay)
useEffect(() => {
if (!currentProject || !isEditingByMe) return
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => saveFeaturesToApi(), 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
}, [features, currentProject, isEditingByMe, saveFeaturesToApi])
// Also save on page unload / tab switch
useEffect(() => {
const handleBeforeUnload = () => {
if (currentProject?.id && featuresRef.current.length > 0) {
const payload = JSON.stringify({ features: featuresRef.current })
navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' }))
}
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden' && currentProject?.id && isEditingByMe) {
saveFeaturesToApi()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [currentProject, isEditingByMe, saveFeaturesToApi, featuresRef])
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useCallback } from 'react'
import type { DrawMode, DrawFeature } from '@/types'
interface UseKeyboardShortcutsOptions {
featuresRef: React.MutableRefObject<DrawFeature[]>
onUndo: () => void
onRedo: () => void
onSave: () => void
onDelete: (newFeatures: DrawFeature[]) => void
onToolChange: (mode: DrawMode) => void
onHelpOpen: () => void
}
const TOOL_SHORTCUTS: Record<string, DrawMode> = {
'v': 'select', 's': 'select',
'p': 'point',
'l': 'linestring',
'g': 'polygon',
'r': 'rectangle',
'c': 'circle',
'f': 'freehand',
'a': 'arrow',
't': 'text',
'e': 'eraser',
'm': 'measure',
'd': 'dangerzone',
}
export function useKeyboardShortcuts({
featuresRef,
onUndo,
onRedo,
onSave,
onDelete,
onToolChange,
onHelpOpen,
}: UseKeyboardShortcutsOptions) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore when typing in inputs/textareas
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
// ? or F1 → help
if (e.key === '?' || e.key === 'F1') { e.preventDefault(); onHelpOpen(); return }
// DEL / Backspace → delete selected feature(s)
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault()
const current = featuresRef.current
const selected = current.filter(f => f.properties?._selected)
if (selected.length > 0) {
onDelete(current.filter(f => !f.properties?._selected))
}
return
}
// Ctrl/Cmd shortcuts
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z' && e.shiftKey) { e.preventDefault(); onRedo(); return }
if (e.key === 'z') { e.preventDefault(); onUndo(); return }
if (e.key === 'y') { e.preventDefault(); onRedo(); return }
if (e.key === 's') { e.preventDefault(); onSave(); return }
return
}
// Tool shortcuts (single key, no modifier)
const mode = TOOL_SHORTCUTS[e.key.toLowerCase()]
if (mode) { e.preventDefault(); onToolChange(mode); return }
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [featuresRef, onUndo, onRedo, onSave, onDelete, onToolChange, onHelpOpen])
}

329
src/hooks/use-map-export.ts Normal file
View File

@@ -0,0 +1,329 @@
import { useCallback } from 'react'
import { jsPDF } from 'jspdf'
import type { DrawFeature, Project } from '@/types'
interface UseMapExportOptions {
mapRef: React.MutableRefObject<any>
featuresRef: React.MutableRefObject<DrawFeature[]>
currentProject: Project | null
tenant: { id: string; name: string } | null
addAudit: (action: string) => void
toast: (opts: { title: string; description?: string; variant?: string }) => void
}
export function useMapExport({
mapRef,
featuresRef,
currentProject,
tenant,
addAudit,
toast,
}: UseMapExportOptions) {
const handleExport = useCallback(async (format: 'png' | 'pdf') => {
const mapInstance = mapRef.current
if (!mapInstance) {
toast({ title: 'Fehler', description: 'Karte nicht bereit.', variant: 'destructive' })
return
}
try {
// 1. Get the MapLibre canvas (tiles + vector drawings)
const mapCanvas = mapInstance.getCanvas() as HTMLCanvasElement
const w = mapCanvas.width
const h = mapCanvas.height
// 2. Create composite canvas
const exportCanvas = document.createElement('canvas')
exportCanvas.width = w
exportCanvas.height = h
const ctx = exportCanvas.getContext('2d')!
ctx.drawImage(mapCanvas, 0, 0)
// 3. Draw symbols manually at correct size/rotation
const currentFeatures = featuresRef.current
// Derive actual pixel ratio from canvas vs container (more reliable than window.devicePixelRatio)
const container = mapInstance.getContainer()
const dpr = mapCanvas.width / container.offsetWidth
const zoom = mapInstance.getZoom()
// Symbol sizing: match the map rendering logic exactly
// In map-view.tsx: size = baseSize * scale * Math.pow(2, currentZoom - placementZoom)
const currentZoom = zoom
// Helper: load image as promise
const loadImage = (src: string): Promise<HTMLImageElement> => new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
// Draw symbol features
for (const f of currentFeatures.filter(f => f.type === 'symbol')) {
if (f.geometry.type !== 'Point') continue
const coords = f.geometry.coordinates as [number, number]
const pixel = mapInstance.project(coords)
const px = pixel.x * dpr
const py = pixel.y * dpr
const scale = (f.properties.scale as number) || 1
const rotation = (f.properties.rotation as number) || 0
const baseSize = 32
const placementZoom = (f.properties.placementZoom as number) || 17
const zoomFactor = Math.pow(2, currentZoom - placementZoom)
const size = Math.max(8, Math.min(400, baseSize * scale * zoomFactor)) * dpr
// Determine image source
const iconId = f.properties.iconId as string
const imageUrl = f.properties.imageUrl as string
let imgSrc = imageUrl || ''
if (!imgSrc && iconId) {
const { getSymbolById, getSymbolDataUri } = await import('@/lib/fw-symbols')
const sym = getSymbolById(iconId)
if (sym) imgSrc = getSymbolDataUri(sym)
}
if (imgSrc) {
try {
const img = await loadImage(imgSrc)
// Replicate CSS background-size: contain (preserve aspect ratio)
const imgAspect = img.naturalWidth / img.naturalHeight
let drawW = size
let drawH = size
if (imgAspect > 1) {
drawH = size / imgAspect
} else {
drawW = size * imgAspect
}
ctx.save()
ctx.translate(px, py)
ctx.rotate((rotation * Math.PI) / 180)
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH)
ctx.restore()
} catch (e) {
console.warn('[Export] Failed to load symbol image:', iconId, e)
}
}
}
// Draw arrowheads for arrow features
for (const f of currentFeatures.filter(f => f.type === 'arrow')) {
if (f.geometry.type !== 'LineString') continue
const lineCoords = f.geometry.coordinates as number[][]
if (lineCoords.length < 2) continue
const p1 = lineCoords[lineCoords.length - 2]
const p2 = lineCoords[lineCoords.length - 1]
const px1 = mapInstance.project(p1 as [number, number])
const px2 = mapInstance.project(p2 as [number, number])
const angle = Math.atan2(px2.y - px1.y, px2.x - px1.x)
const color = (f.properties.color as string) || '#000000'
const arrowSize = 14 * dpr
ctx.save()
ctx.translate(px2.x * dpr, px2.y * dpr)
ctx.rotate(angle + Math.PI / 2)
ctx.beginPath()
ctx.moveTo(0, -arrowSize)
ctx.lineTo(-arrowSize * 0.7, arrowSize * 0.3)
ctx.lineTo(arrowSize * 0.7, arrowSize * 0.3)
ctx.closePath()
ctx.fillStyle = color
ctx.fill()
ctx.restore()
}
// Draw line/polygon label markers at midpoints
for (const f of currentFeatures.filter(f => f.properties.label && (f.geometry.type === 'LineString' || f.geometry.type === 'Polygon'))) {
const label = f.properties.label as string
let midpoint: [number, number]
if (f.geometry.type === 'LineString') {
const coords = f.geometry.coordinates as number[][]
const midIdx = Math.floor(coords.length / 2)
if (coords.length === 2) {
midpoint = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
} else {
midpoint = coords[midIdx] as [number, number]
}
} else {
// Polygon: centroid of first ring
const ring = (f.geometry.coordinates as number[][][])[0]
const len = ring.length - 1
let cx = 0, cy = 0
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
midpoint = [cx / len, cy / len]
}
const pixel = mapInstance.project(midpoint)
const px = pixel.x * dpr
const py = pixel.y * dpr
const fontSize = 13 * dpr
const isDanger = f.type === 'dangerzone'
const bgColor = isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'
const borderColor = isDanger ? '#dc2626' : 'rgba(255,255,255,0.5)'
ctx.save()
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const metrics = ctx.measureText(label)
const padX = 7 * dpr
const padY = 3 * dpr
const boxW = metrics.width + padX * 2
const boxH = fontSize + padY * 2
const radius = 4 * dpr
// Background pill
ctx.fillStyle = bgColor
ctx.beginPath()
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
ctx.fill()
// Border
ctx.strokeStyle = borderColor
ctx.lineWidth = 1.5 * dpr
ctx.beginPath()
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
ctx.stroke()
// Text
ctx.fillStyle = '#ffffff'
ctx.fillText(label, px, py)
ctx.restore()
}
// Draw text features
for (const f of currentFeatures.filter(f => f.type === 'text')) {
if (f.geometry.type !== 'Point') continue
const coords = f.geometry.coordinates as [number, number]
const pixel = mapInstance.project(coords)
const px = pixel.x * dpr
const py = pixel.y * dpr
const text = (f.properties.text as string) || ''
const fontSize = ((f.properties.fontSize as number) || 14) * dpr
const color = (f.properties.color as string) || '#000000'
ctx.save()
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// White outline
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 3 * dpr
ctx.lineJoin = 'round'
ctx.strokeText(text, px, py)
// Fill
ctx.fillStyle = color
ctx.fillText(text, px, py)
ctx.restore()
}
const title = currentProject?.title || 'Lageplan'
const safeName = title.replace(/[^a-z0-9äöüÄÖÜß]/gi, '_')
if (format === 'png') {
const link = document.createElement('a')
link.download = `${safeName}.png`
link.href = exportCanvas.toDataURL('image/png')
link.click()
addAudit(`Export: ${safeName}.png`)
toast({ title: 'Exportiert', description: `${safeName}.png wurde heruntergeladen.` })
} else {
// PDF Export — rapport-style clean layout
const imgData = exportCanvas.toDataURL('image/png')
const now = new Date()
const dateStr = now.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
const timeStr = now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
const locationStr = currentProject?.location || ''
const einsatzNr = (currentProject as any)?.einsatzNr || ''
const tenantLabel = tenant?.name || ''
// A4 landscape (mm)
const pdf = new jsPDF('l', 'mm', 'a4')
const pageW = pdf.internal.pageSize.getWidth() // 297
const pageH = pdf.internal.pageSize.getHeight() // 210
const m = 10 // margin
// ── Header section ──
const headerY = m
pdf.setFontSize(18)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(26, 26, 26)
pdf.text('Einsatz-Lageplan', m, headerY + 6)
pdf.setFontSize(9)
pdf.setFont('helvetica', 'normal')
pdf.setTextColor(107, 114, 128) // gray-500
pdf.text(`${tenantLabel}${tenantLabel ? ' · ' : ''}${title}`, m, headerY + 12)
// Right side: Einsatz-Nr + date
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(185, 28, 28) // red-700
if (einsatzNr) {
const nrW = pdf.getTextWidth(einsatzNr)
pdf.text(einsatzNr, pageW - m - nrW, headerY + 6)
}
pdf.setFontSize(9)
pdf.setFont('helvetica', 'normal')
pdf.setTextColor(107, 114, 128)
const dateLabel = `${dateStr} · ${timeStr}`
const dlW = pdf.getTextWidth(dateLabel)
pdf.text(dateLabel, pageW - m - dlW, headerY + 12)
// Divider line + red accent
const divY = headerY + 15
pdf.setDrawColor(26, 26, 26)
pdf.setLineWidth(0.8)
pdf.line(m, divY, pageW - m, divY)
pdf.setFillColor(185, 28, 28)
pdf.rect(m, divY, (pageW - 2 * m) * 0.3, 1, 'F')
// ── Map image ──
const mapTop = divY + 3
const mapBottom = pageH - m - 12 // leave space for footer
const mapAreaW = pageW - 2 * m
const mapAreaH = mapBottom - mapTop
// Fit map image into area while preserving aspect ratio
const imgAspect = w / h
const areaAspect = mapAreaW / mapAreaH
let drawW = mapAreaW
let drawH = mapAreaH
if (imgAspect > areaAspect) {
drawH = mapAreaW / imgAspect
} else {
drawW = mapAreaH * imgAspect
}
const mapX = m + (mapAreaW - drawW) / 2
const mapY = mapTop + (mapAreaH - drawH) / 2
// Light border around map
pdf.setDrawColor(229, 231, 235)
pdf.setLineWidth(0.3)
pdf.rect(mapX, mapY, drawW, drawH)
pdf.addImage(imgData, 'PNG', mapX, mapY, drawW, drawH)
// ── Footer ──
const footerY = pageH - m - 4
pdf.setFontSize(7)
pdf.setFont('helvetica', 'normal')
pdf.setTextColor(156, 163, 175) // gray-400
pdf.text(`Erstellt: ${dateStr} ${timeStr}${locationStr ? ' · Standort: ' + locationStr : ''} · Projekt: ${title}`, m, footerY)
const footerR = 'app.lageplan.ch'
const frW = pdf.getTextWidth(footerR)
pdf.text(footerR, pageW - m - frW, footerY)
pdf.save(`${safeName}.pdf`)
addAudit(`Export: ${safeName}.pdf`)
toast({ title: 'Exportiert', description: `${safeName}.pdf wurde heruntergeladen.` })
}
} catch (error) {
console.error('Export error:', error)
toast({ title: 'Fehler', description: 'Export fehlgeschlagen.', variant: 'destructive' })
}
}, [currentProject, tenant, toast, addAudit, mapRef, featuresRef])
return { handleExport }
}

View File

@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react'
import { flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync'
interface UseOfflineSyncOptions {
toast: (opts: { title: string; description?: string; variant?: string }) => void
}
export function useOfflineSync({ toast }: UseOfflineSyncOptions) {
const [isOffline, setIsOffline] = useState(false)
const [syncQueueCount, setSyncQueueCount] = useState(0)
useEffect(() => {
setIsOffline(!checkOnline())
setSyncQueueCount(getSyncQueue().length)
const goOffline = () => {
setIsOffline(true)
toast({ title: 'Offline-Modus', description: 'Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.' })
}
const goOnline = async () => {
setIsOffline(false)
const queue = getSyncQueue()
if (queue.length > 0) {
toast({ title: 'Verbindung wiederhergestellt', description: `${queue.length} Änderung(en) werden synchronisiert...` })
const result = await flushSyncQueue()
setSyncQueueCount(getSyncQueue().length)
if (result.success > 0) {
toast({ title: 'Synchronisiert', description: `${result.success} Änderung(en) erfolgreich gespeichert.` })
}
if (result.failed > 0) {
toast({ title: 'Sync-Fehler', description: `${result.failed} Änderung(en) konnten nicht gespeichert werden.`, variant: 'destructive' })
}
} else {
toast({ title: 'Wieder online' })
}
}
window.addEventListener('offline', goOffline)
window.addEventListener('online', goOnline)
// Listen for SW sync messages
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'FLUSH_SYNC_QUEUE') {
flushSyncQueue().then(() => setSyncQueueCount(getSyncQueue().length))
}
})
}
return () => {
window.removeEventListener('offline', goOffline)
window.removeEventListener('online', goOnline)
}
}, [toast])
return { isOffline, syncQueueCount, setSyncQueueCount }
}

View File

@@ -0,0 +1,268 @@
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,
}
}