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:
35
src/app/admin/error.tsx
Normal file
35
src/app/admin/error.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { AlertTriangle, RotateCcw, Home } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AdminError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-background p-8 text-center">
|
||||
<AlertTriangle className="w-12 h-12 text-destructive mb-4" />
|
||||
<h2 className="text-xl font-bold mb-2">Fehler im Admin-Bereich</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||
{error.message || 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={reset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/app">
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Zur App
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,20 +6,6 @@ export async function GET() {
|
||||
try {
|
||||
const user = await getSession()
|
||||
|
||||
// Build icon filter: global icons (tenantId=null) + tenant-specific icons
|
||||
const iconFilter: any = { isActive: true }
|
||||
if (user?.tenantId) {
|
||||
iconFilter.OR = [
|
||||
{ tenantId: null },
|
||||
{ tenantId: user.tenantId },
|
||||
]
|
||||
delete iconFilter.isActive
|
||||
iconFilter.AND = [{ isActive: true }]
|
||||
} else {
|
||||
// Server admin or no tenant: show all global icons
|
||||
iconFilter.tenantId = null
|
||||
}
|
||||
|
||||
// Filter categories: global (tenantId=null) + tenant-specific
|
||||
const categoryWhere: any = user?.tenantId
|
||||
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
|
||||
@@ -38,35 +24,46 @@ export async function GET() {
|
||||
},
|
||||
})
|
||||
|
||||
// Get tenant's hidden icon IDs (legacy) + TenantSymbol overrides
|
||||
// Get tenant's hidden icon IDs (legacy)
|
||||
let hiddenIconIds: string[] = []
|
||||
let deactivatedIconIds = new Set<string>()
|
||||
if (user?.tenantId) {
|
||||
const [tenant, tenantSymbols] = await Promise.all([
|
||||
(prisma as any).tenant.findUnique({
|
||||
where: { id: user.tenantId },
|
||||
select: { hiddenIconIds: true },
|
||||
}),
|
||||
(prisma as any).tenantSymbol.findMany({
|
||||
where: { tenantId: user.tenantId, isActive: false },
|
||||
select: { iconId: true },
|
||||
}),
|
||||
])
|
||||
const tenant = await (prisma as any).tenant.findUnique({
|
||||
where: { id: user.tenantId },
|
||||
select: { hiddenIconIds: true },
|
||||
})
|
||||
hiddenIconIds = tenant?.hiddenIconIds || []
|
||||
deactivatedIconIds = new Set(tenantSymbols.map((ts: any) => ts.iconId))
|
||||
}
|
||||
|
||||
const categoriesWithUrls = categories.map((cat: any) => ({
|
||||
...cat,
|
||||
icons: cat.icons
|
||||
.filter((icon: any) => !hiddenIconIds.includes(icon.id) && !deactivatedIconIds.has(icon.id))
|
||||
.filter((icon: any) => !hiddenIconIds.includes(icon.id))
|
||||
.map((icon: any) => ({
|
||||
...icon,
|
||||
url: `/api/icons/${icon.id}/image`,
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ categories: categoriesWithUrls })
|
||||
// Get tenant's custom symbol collection (with custom names)
|
||||
let mySymbols: any[] = []
|
||||
if (user?.tenantId) {
|
||||
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
|
||||
where: { tenantId: user.tenantId },
|
||||
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
mySymbols = tenantSymbols.map((ts: any) => ({
|
||||
id: ts.icon.id,
|
||||
tenantSymbolId: ts.id,
|
||||
name: ts.customName || ts.icon.name,
|
||||
customName: ts.customName,
|
||||
mimeType: ts.icon.mimeType,
|
||||
iconType: ts.icon.iconType,
|
||||
url: `/api/icons/${ts.icon.id}/image`,
|
||||
}))
|
||||
}
|
||||
|
||||
return NextResponse.json({ categories: categoriesWithUrls, mySymbols })
|
||||
} catch (error) {
|
||||
console.error('Error fetching icons:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
|
||||
@@ -2,82 +2,151 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
// GET: List all icons with their tenant-specific active status
|
||||
async function getTenantId() {
|
||||
const user = await getSession()
|
||||
if (!user) return { error: 'Nicht autorisiert', status: 401 }
|
||||
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
||||
return { error: 'Keine Berechtigung', status: 403 }
|
||||
}
|
||||
if (!user.tenantId) return { error: 'Kein Mandant zugeordnet', status: 400 }
|
||||
return { tenantId: user.tenantId }
|
||||
}
|
||||
|
||||
// GET: Returns library (all system icons) + tenant's own symbol collection
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
const auth = await getTenantId()
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
const { tenantId } = auth
|
||||
|
||||
const tenantId = user.tenantId
|
||||
if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
|
||||
|
||||
// Get all system icons (active ones)
|
||||
// All system icons grouped by category (the library)
|
||||
const icons = await (prisma as any).iconAsset.findMany({
|
||||
where: { isActive: true },
|
||||
include: { category: { select: { id: true, name: true } } },
|
||||
orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }],
|
||||
})
|
||||
|
||||
// Get tenant-specific overrides
|
||||
const overrides = await (prisma as any).tenantSymbol.findMany({
|
||||
where: { tenantId },
|
||||
})
|
||||
|
||||
const overrideMap = new Map(overrides.map((o: any) => [o.iconId, o.isActive]))
|
||||
|
||||
// Merge: default is active (true) unless override says otherwise
|
||||
const symbols = icons.map((icon: any) => ({
|
||||
const library = icons.map((icon: any) => ({
|
||||
id: icon.id,
|
||||
name: icon.name,
|
||||
fileKey: icon.fileKey,
|
||||
mimeType: icon.mimeType,
|
||||
iconType: icon.iconType,
|
||||
categoryId: icon.categoryId,
|
||||
categoryName: icon.category?.name || 'Ohne Kategorie',
|
||||
isActive: overrideMap.has(icon.id) ? overrideMap.get(icon.id) : true,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ symbols })
|
||||
// Tenant's own symbol collection
|
||||
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
|
||||
where: { tenantId },
|
||||
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const mySymbols = tenantSymbols.map((ts: any) => ({
|
||||
id: ts.id,
|
||||
iconId: ts.iconId,
|
||||
name: ts.customName || ts.icon.name,
|
||||
customName: ts.customName,
|
||||
baseName: ts.icon.name,
|
||||
mimeType: ts.icon.mimeType,
|
||||
iconType: ts.icon.iconType,
|
||||
categoryName: ts.icon.category?.name || 'Ohne Kategorie',
|
||||
sortOrder: ts.sortOrder,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ library, mySymbols })
|
||||
} catch (error) {
|
||||
console.error('Error fetching tenant symbols:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: Update symbol visibility for the tenant (bulk)
|
||||
export async function PATCH(req: NextRequest) {
|
||||
// POST: Add a symbol from the library to "my symbols"
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
const auth = await getTenantId()
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
const { tenantId } = auth
|
||||
|
||||
const tenantId = user.tenantId
|
||||
if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
|
||||
const { iconId, customName } = await req.json()
|
||||
if (!iconId) return NextResponse.json({ error: 'iconId erforderlich' }, { status: 400 })
|
||||
|
||||
const { updates } = await req.json()
|
||||
if (!Array.isArray(updates)) {
|
||||
return NextResponse.json({ error: 'updates Array erforderlich' }, { status: 400 })
|
||||
}
|
||||
// Get max sortOrder for this tenant
|
||||
const maxSort = await (prisma as any).tenantSymbol.aggregate({
|
||||
where: { tenantId },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
|
||||
// Upsert each symbol override
|
||||
await Promise.all(
|
||||
updates.map((u: { iconId: string; isActive: boolean }) =>
|
||||
(prisma as any).tenantSymbol.upsert({
|
||||
where: { tenantId_iconId: { tenantId, iconId: u.iconId } },
|
||||
update: { isActive: u.isActive },
|
||||
create: { tenantId, iconId: u.iconId, isActive: u.isActive },
|
||||
})
|
||||
)
|
||||
)
|
||||
const symbol = await (prisma as any).tenantSymbol.create({
|
||||
data: {
|
||||
tenantId,
|
||||
iconId,
|
||||
customName: customName || null,
|
||||
sortOrder: (maxSort._max.sortOrder ?? -1) + 1,
|
||||
},
|
||||
include: { icon: { select: { name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
return NextResponse.json({
|
||||
id: symbol.id,
|
||||
iconId: symbol.iconId,
|
||||
name: symbol.customName || symbol.icon.name,
|
||||
customName: symbol.customName,
|
||||
baseName: symbol.icon.name,
|
||||
mimeType: symbol.icon.mimeType,
|
||||
iconType: symbol.icon.iconType,
|
||||
categoryName: symbol.icon.category?.name || 'Ohne Kategorie',
|
||||
sortOrder: symbol.sortOrder,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating tenant symbols:', error)
|
||||
console.error('Error adding tenant symbol:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: Rename a symbol or update sortOrder
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const auth = await getTenantId()
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
const { tenantId } = auth
|
||||
|
||||
const { id, customName, sortOrder } = await req.json()
|
||||
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
|
||||
|
||||
const data: any = {}
|
||||
if (customName !== undefined) data.customName = customName || null
|
||||
if (sortOrder !== undefined) data.sortOrder = sortOrder
|
||||
|
||||
await (prisma as any).tenantSymbol.updateMany({
|
||||
where: { id, tenantId },
|
||||
data,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error updating tenant symbol:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Remove a symbol from "my symbols"
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const auth = await getTenantId()
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
const { tenantId } = auth
|
||||
|
||||
const { id } = await req.json()
|
||||
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
|
||||
|
||||
await (prisma as any).tenantSymbol.deleteMany({
|
||||
where: { id, tenantId },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting tenant symbol:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
35
src/app/app/error.tsx
Normal file
35
src/app/app/error.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { AlertTriangle, RotateCcw, Home } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AppError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-background p-8 text-center">
|
||||
<AlertTriangle className="w-12 h-12 text-destructive mb-4" />
|
||||
<h2 className="text-xl font-bold mb-2">Fehler in der Krokier-App</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||
{error.message || 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={reset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/">
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Startseite
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,68 +18,32 @@ import { useAuth } from '@/components/providers/auth-provider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { JournalView } from '@/components/journal/journal-view'
|
||||
import { jsPDF } from 'jspdf'
|
||||
import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react'
|
||||
import { getSocket, setSocketRoom } from '@/lib/socket'
|
||||
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
|
||||
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
|
||||
import { addToSyncQueue, flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync'
|
||||
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
|
||||
import { useMapExport } from '@/hooks/use-map-export'
|
||||
import { useAutoSave } from '@/hooks/use-auto-save'
|
||||
import { useOfflineSync } from '@/hooks/use-offline-sync'
|
||||
import { useRealtimeSync } from '@/hooks/use-realtime-sync'
|
||||
import type { Project, DrawFeature, Feature, JournalEntry, DrawMode } from '@/types'
|
||||
import { useToolStore } from '@/stores/tool-store'
|
||||
import { useUIStore } from '@/stores/ui-store'
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
title: string
|
||||
location?: string
|
||||
description?: string
|
||||
einsatzleiter?: string
|
||||
journalfuehrer?: string
|
||||
mapCenter: { lng: number; lat: number }
|
||||
mapZoom: number
|
||||
isLocked: boolean
|
||||
editingById?: string | null
|
||||
editingUserName?: string | null
|
||||
editingStartedAt?: string | null
|
||||
planImageKey?: string | null
|
||||
planBounds?: { north: number; south: number; east: number; west: number } | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface DrawFeature {
|
||||
id: string
|
||||
type: string
|
||||
geometry: {
|
||||
type: string
|
||||
coordinates: number[] | number[][] | number[][][]
|
||||
}
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type DrawMode =
|
||||
| 'select'
|
||||
| 'point'
|
||||
| 'linestring'
|
||||
| 'polygon'
|
||||
| 'rectangle'
|
||||
| 'circle'
|
||||
| 'freehand'
|
||||
| 'text'
|
||||
| 'arrow'
|
||||
| 'measure'
|
||||
| 'dangerzone'
|
||||
| 'eraser'
|
||||
export type { DrawMode }
|
||||
|
||||
export default function AppPage() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { user, tenant, loading: authLoading, logout } = useAuth()
|
||||
|
||||
// Zustand Stores
|
||||
const { activeTool: drawMode, setActiveTool: setDrawMode, activeColor: selectedColor, setActiveColor: setSelectedColor, lineWidth: selectedWidth, setLineWidth: setSelectedWidth } = useToolStore()
|
||||
const { sidebarOpen: isSidebarOpen, setSidebarOpen: setIsSidebarOpen, sidebarTab: activeTab, setSidebarTab: setActiveTab } = useUIStore()
|
||||
|
||||
const [currentProject, setCurrentProject] = useState<Project | null>(null)
|
||||
const [features, setFeatures] = useState<DrawFeature[]>([])
|
||||
const [drawMode, setDrawModeRaw] = useState<DrawMode>('select')
|
||||
const setDrawMode = useCallback((mode: DrawMode) => {
|
||||
setDrawModeRaw(mode)
|
||||
}, [])
|
||||
const [selectedColor, setSelectedColor] = useState('#000000')
|
||||
const [selectedWidth, setSelectedWidth] = useState(3)
|
||||
|
||||
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
|
||||
@@ -87,25 +51,39 @@ export default function AppPage() {
|
||||
const [auditLog, setAuditLog] = useState<{ time: string; action: string }[]>([])
|
||||
const [isAuditOpen, setIsAuditOpen] = useState(false)
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'map' | 'journal'>('map')
|
||||
const [lastMapScreenshot, setLastMapScreenshot] = useState<string>('')
|
||||
const [defaultSymbolScale, setDefaultSymbolScale] = useState(1.5)
|
||||
|
||||
// Onboarding tour
|
||||
const [showTour, setShowTour] = useState(false)
|
||||
|
||||
// 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)
|
||||
// Ref to access the map for export
|
||||
const mapRef = useRef<any>(null)
|
||||
|
||||
// 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)}`
|
||||
}
|
||||
// Undo/Redo history
|
||||
const undoStackRef = useRef<DrawFeature[][]>([])
|
||||
const redoStackRef = useRef<DrawFeature[][]>([])
|
||||
|
||||
// Ref to always have latest features (avoids stale closures in callbacks called via refs)
|
||||
const featuresRef = useRef<DrawFeature[]>(features)
|
||||
useEffect(() => { featuresRef.current = features }, [features])
|
||||
|
||||
// Ref for undo-draw-point (removes last point during line drawing)
|
||||
const undoDrawPointRef = useRef<(() => boolean) | null>(null)
|
||||
|
||||
// Realtime sync: editing lock, socket.io, throttled broadcast
|
||||
const {
|
||||
editingBy, isEditingByMe, editingLoading,
|
||||
socketRef, broadcastFeatures,
|
||||
handleStartEditing, handleStopEditing,
|
||||
} = useRealtimeSync({
|
||||
currentProject,
|
||||
user: user ? { id: user.id, name: user.name, role: user.role } : null,
|
||||
featuresRef,
|
||||
setFeatures,
|
||||
toast: toast as any,
|
||||
})
|
||||
|
||||
// Capture map screenshot when switching to journal tab (coordinate-based rendering)
|
||||
const handleTabChange = useCallback(async (tab: 'map' | 'journal') => {
|
||||
@@ -368,67 +346,8 @@ export default function AppPage() {
|
||||
const [isLineLabelDialogOpen, setIsLineLabelDialogOpen] = useState(false)
|
||||
const [pendingLineFeature, setPendingLineFeature] = useState<DrawFeature | null>(null)
|
||||
|
||||
// Ref to access the map for export
|
||||
const mapRef = useRef<any>(null)
|
||||
|
||||
// Offline detection
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Undo/Redo history
|
||||
const undoStackRef = useRef<DrawFeature[][]>([])
|
||||
const redoStackRef = useRef<DrawFeature[][]>([])
|
||||
|
||||
// Ref to always have latest features (avoids stale closures in callbacks called via refs)
|
||||
const featuresRef = useRef<DrawFeature[]>(features)
|
||||
useEffect(() => { featuresRef.current = features }, [features])
|
||||
|
||||
// Ref for undo-draw-point (removes last point during line drawing)
|
||||
const undoDrawPointRef = useRef<(() => boolean) | null>(null)
|
||||
// Offline detection + sync queue management
|
||||
const { isOffline, syncQueueCount, setSyncQueueCount } = useOfflineSync({ toast: toast as any })
|
||||
|
||||
// Audit trail helper
|
||||
const addAudit = useCallback((action: string) => {
|
||||
@@ -447,8 +366,6 @@ export default function AppPage() {
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
@@ -461,314 +378,16 @@ export default function AppPage() {
|
||||
const canEdit = roleCanEdit && (isEditingByMe || !editingBy)
|
||||
const isReadOnly = !!editingBy && !isEditingByMe
|
||||
|
||||
// ─── 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 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))
|
||||
}
|
||||
}, [])
|
||||
const isEditingByMeRef = useRef(false)
|
||||
|
||||
// Keep ref in sync with state
|
||||
useEffect(() => {
|
||||
isEditingByMeRef.current = isEditingByMe
|
||||
}, [isEditingByMe])
|
||||
|
||||
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])
|
||||
|
||||
// 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])
|
||||
|
||||
// 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])
|
||||
|
||||
// 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])
|
||||
// Auto-save: localStorage persistence + debounced API save + beacon on unload
|
||||
useAutoSave({
|
||||
currentProject,
|
||||
features,
|
||||
featuresRef,
|
||||
mapRef,
|
||||
socketRef,
|
||||
isEditingByMe,
|
||||
setSyncQueueCount,
|
||||
})
|
||||
|
||||
// Fullscreen toggle
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
@@ -1066,57 +685,15 @@ export default function AppPage() {
|
||||
|
||||
// Keyboard shortcuts for tools
|
||||
const [isShortcutHelpOpen, setIsShortcutHelpOpen] = useState(false)
|
||||
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(); setIsShortcutHelpOpen(true); return }
|
||||
|
||||
// DEL / Backspace → delete selected feature(s)
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
// Remove all selected features
|
||||
const current = featuresRef.current
|
||||
const selected = current.filter(f => f.properties?._selected)
|
||||
if (selected.length > 0) {
|
||||
handleFeaturesChange(current.filter(f => !f.properties?._selected))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd shortcuts
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'z' && e.shiftKey) { e.preventDefault(); handleRedo(); return }
|
||||
if (e.key === 'z') { e.preventDefault(); handleUndo(); return }
|
||||
if (e.key === 'y') { e.preventDefault(); handleRedo(); return }
|
||||
if (e.key === 's') { e.preventDefault(); handleSaveProject(); return }
|
||||
return
|
||||
}
|
||||
|
||||
// Tool shortcuts (single key, no modifier)
|
||||
const 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',
|
||||
}
|
||||
const mode = shortcuts[e.key.toLowerCase()]
|
||||
if (mode) { e.preventDefault(); setDrawMode(mode); return }
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleUndo, handleRedo, handleSaveProject, setDrawMode, handleFeaturesChange])
|
||||
useKeyboardShortcuts({
|
||||
featuresRef,
|
||||
onUndo: handleUndo,
|
||||
onRedo: handleRedo,
|
||||
onSave: handleSaveProject,
|
||||
onDelete: handleFeaturesChange,
|
||||
onToolChange: setDrawMode,
|
||||
onHelpOpen: useCallback(() => setIsShortcutHelpOpen(true), []),
|
||||
})
|
||||
|
||||
const handlePlanUpload = useCallback(() => {
|
||||
if (!currentProject) return
|
||||
@@ -1181,311 +758,14 @@ export default function AppPage() {
|
||||
setIsDeleteAllConfirmOpen(false)
|
||||
}, [toast, addAudit])
|
||||
|
||||
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, toast])
|
||||
const { handleExport } = useMapExport({
|
||||
mapRef,
|
||||
featuresRef,
|
||||
currentProject,
|
||||
tenant: tenant ? { id: tenant.id, name: tenant.name } : null,
|
||||
addAudit,
|
||||
toast: toast as any,
|
||||
})
|
||||
|
||||
// Show loading state while checking auth
|
||||
if (authLoading || !user) {
|
||||
@@ -1603,7 +883,7 @@ export default function AppPage() {
|
||||
{/* Map view — always mounted, hidden via CSS to preserve state */}
|
||||
<div data-tour="toolbar" className={`contents ${activeTab !== 'map' ? 'hidden' : ''}`}>
|
||||
<LeftToolbar
|
||||
drawMode={drawMode}
|
||||
drawMode={drawMode || 'select'}
|
||||
onDrawModeChange={setDrawMode}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={setSelectedColor}
|
||||
@@ -1620,7 +900,7 @@ export default function AppPage() {
|
||||
<MapView
|
||||
project={currentProject}
|
||||
features={features}
|
||||
drawMode={drawMode}
|
||||
drawMode={drawMode || 'select'}
|
||||
selectedColor={selectedColor}
|
||||
selectedWidth={selectedWidth}
|
||||
onFeaturesChange={handleFeaturesChange}
|
||||
@@ -1634,8 +914,8 @@ export default function AppPage() {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Journal view — always mounted, hidden when map tab is active to preserve state */}
|
||||
<main className={`flex-1 relative overflow-auto ${activeTab !== 'journal' ? 'hidden' : ''}`}>
|
||||
{/* Journal view — always mounted, hidden via CSS */}
|
||||
<main className={`flex-1 flex flex-col min-h-0 bg-background ${activeTab !== 'journal' ? 'hidden' : ''}`}>
|
||||
<JournalView
|
||||
projectId={currentProject?.id || null}
|
||||
projectTitle={currentProject?.title || ''}
|
||||
|
||||
Reference in New Issue
Block a user