v1.3.2: SEO fixes + map bugfixes

SEO:
- Landing page converted to Server Component (SSR)
- Extracted NavAuthButtons + ContactForm as client islands
- Removed fake aggregateRating from JSON-LD
- Added FAQPage JSON-LD schema (7 questions)
- Extended sitemap: /datenschutz, /spenden, /demo

Map fixes:
- WebGL context lost recovery (black tiles after inactivity)
- Page visibility handler for tile reload on tab switch
- Arrow direction: geographic bearing instead of screen angle
- All markers rotationAlignment viewport->map (geographic orientation)
- DEL key now deletes selected lines/polygons/arrows (not just symbols)
- Default drawing color: black
This commit is contained in:
Pepe Ziberi
2026-03-03 23:33:04 +01:00
parent 708bdf6be0
commit 1f508bca74
8 changed files with 298 additions and 156 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.3.1", "version": "1.3.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lageplan", "name": "lageplan",
"version": "1.3.1", "version": "1.3.2",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.3.1", "version": "1.3.2",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -1,30 +1,16 @@
'use client'
import Link from 'next/link' import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/logo' import { Logo } from '@/components/ui/logo'
import { useAuth } from '@/components/providers/auth-provider' import { NavAuthButtons } from '@/components/landing/nav-auth-buttons'
import { useRouter } from 'next/navigation' import { ContactForm } from '@/components/landing/contact-form'
import { useEffect, useState } from 'react'
import { import {
Flame, Map, Shield, Users, Smartphone, FileText, Ruler, Clock, Map, Shield, Users, Smartphone, FileText, Ruler, Clock,
Check, ArrowRight, Lock, ChevronRight, MessageSquare, Loader2, Send, Check, ArrowRight, Lock, ChevronRight, MessageSquare,
Heart, Coffee, Rocket, Sparkles, Lightbulb, HelpCircle, Heart, Coffee, Rocket, Sparkles, Lightbulb, HelpCircle,
MousePointer2, Minus, Pentagon, Square, Circle, Pencil, MoveRight, Type, Eraser, MousePointer2, Minus, Pentagon, Square, Circle, Pencil, MoveRight, Type, Eraser,
} from 'lucide-react' } from 'lucide-react'
export default function LandingPage() { export default function LandingPage() {
const { user, loading } = useAuth()
const router = useRouter()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="animate-pulse text-muted-foreground">Laden...</div>
</div>
)
}
const jsonLd = { const jsonLd = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'SoftwareApplication', '@type': 'SoftwareApplication',
@@ -40,11 +26,6 @@ export default function LandingPage() {
priceCurrency: 'CHF', priceCurrency: 'CHF',
description: 'Kostenlos für Schweizer Feuerwehren', description: 'Kostenlos für Schweizer Feuerwehren',
}, },
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.8',
ratingCount: '12',
},
author: { author: {
'@type': 'Organization', '@type': 'Organization',
name: 'Lageplan.ch', name: 'Lageplan.ch',
@@ -62,12 +43,79 @@ export default function LandingPage() {
], ],
} }
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: 'Was kostet Lageplan?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Nichts. Lageplan ist kostenlos für alle Feuerwehren in der Schweiz. Die Entwicklung wird durch freiwillige Spenden finanziert.',
},
},
{
'@type': 'Question',
name: 'Brauche ich eine Installation?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Nein. Lageplan läuft komplett im Browser — auf Desktop, Tablet und Smartphone. Einfach registrieren und loslegen.',
},
},
{
'@type': 'Question',
name: 'Funktioniert es auf dem Tablet im Einsatz?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Ja. Die App ist für Touch-Bedienung optimiert und funktioniert auf allen modernen Tablets und Smartphones.',
},
},
{
'@type': 'Question',
name: 'Können mehrere Personen gleichzeitig arbeiten?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Ja. Über Echtzeit-Synchronisation (WebSocket) können mehrere Benutzer gleichzeitig am selben Lageplan zeichnen und das Journal führen.',
},
},
{
'@type': 'Question',
name: 'Wo werden meine Daten gespeichert?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Alle Daten werden auf Servern in der Schweiz gespeichert. Die Applikation ist DSG- und DSGVO-konform.',
},
},
{
'@type': 'Question',
name: 'Welche Symbole sind verfügbar?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Alle 117 offiziellen FKS/BABS-Signaturen sind integriert. Zusätzlich können eigene Symbole hochgeladen werden.',
},
},
{
'@type': 'Question',
name: 'Kann ich Lagepläne exportieren?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Ja. Lagepläne können als PNG oder PDF exportiert werden — inklusive Metadaten, Datum und Einsatzinformationen.',
},
},
],
}
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<main> <main>
{/* Navigation */} {/* Navigation */}
<nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-md border-b border-gray-100"> <nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
@@ -84,24 +132,7 @@ export default function LandingPage() {
<a href="#roadmap" className="hover:text-gray-900 transition">Roadmap</a> <a href="#roadmap" className="hover:text-gray-900 transition">Roadmap</a>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user ? ( <NavAuthButtons />
<Link href="/app">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Zur App
</Button>
</Link>
) : (
<>
<Link href="/login">
<Button variant="ghost" size="sm">Anmelden</Button>
</Link>
<Link href="/register">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Kostenlos starten
</Button>
</Link>
</>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -564,39 +595,6 @@ function SupportSection() {
} }
function ContactSection() { function ContactSection() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [sending, setSending] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSending(true)
setError('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message }),
})
if (res.ok) {
setSent(true)
setName('')
setEmail('')
setMessage('')
} else {
const data = await res.json()
setError(data.error || 'Senden fehlgeschlagen')
}
} catch {
setError('Verbindung fehlgeschlagen')
} finally {
setSending(false)
}
}
return ( return (
<section id="contact" className="py-20 px-4"> <section id="contact" className="py-20 px-4">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
@@ -607,67 +605,7 @@ function ContactSection() {
Fragen, Feature-Wünsche oder Feedback? Schreib mir ich freue mich über jede Nachricht. Fragen, Feature-Wünsche oder Feedback? Schreib mir ich freue mich über jede Nachricht.
</p> </p>
</div> </div>
<ContactForm />
{sent ? (
<div className="text-center bg-green-50 border border-green-200 rounded-xl p-8">
<Check className="w-10 h-10 text-green-600 mx-auto mb-3" />
<h3 className="font-semibold text-green-900 text-lg">Nachricht gesendet!</h3>
<p className="text-green-700 mt-2">Vielen Dank! Ich melde mich so schnell wie möglich.</p>
<Button variant="outline" className="mt-4" onClick={() => setSent(false)}>
Weitere Nachricht senden
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Dein Name"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="name@feuerwehr.ch"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nachricht</label>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
required
rows={5}
placeholder="Deine Nachricht..."
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="bg-red-600 hover:bg-red-700"
disabled={sending || !name || !email || !message}
>
{sending ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird gesendet...</>
) : (
<><Send className="w-4 h-4 mr-2" /> Nachricht senden</>
)}
</Button>
</form>
)}
</div> </div>
</section> </section>
) )

View File

@@ -23,10 +23,10 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 0.8, priority: 0.8,
}, },
{ {
url: `${baseUrl}/impressum`, url: `${baseUrl}/demo`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'yearly', changeFrequency: 'monthly',
priority: 0.3, priority: 0.7,
}, },
{ {
url: `${baseUrl}/spenden`, url: `${baseUrl}/spenden`,
@@ -34,5 +34,17 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.5, priority: 0.5,
}, },
{
url: `${baseUrl}/datenschutz`,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
{
url: `${baseUrl}/impressum`,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
] ]
} }

View File

@@ -0,0 +1,105 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Loader2, Send, Check } from 'lucide-react'
export function ContactForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [sending, setSending] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSending(true)
setError('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message }),
})
if (res.ok) {
setSent(true)
setName('')
setEmail('')
setMessage('')
} else {
const data = await res.json()
setError(data.error || 'Senden fehlgeschlagen')
}
} catch {
setError('Verbindung fehlgeschlagen')
} finally {
setSending(false)
}
}
if (sent) {
return (
<div className="text-center bg-green-50 border border-green-200 rounded-xl p-8">
<Check className="w-10 h-10 text-green-600 mx-auto mb-3" />
<h3 className="font-semibold text-green-900 text-lg">Nachricht gesendet!</h3>
<p className="text-green-700 mt-2">Vielen Dank! Ich melde mich so schnell wie möglich.</p>
<Button variant="outline" className="mt-4" onClick={() => setSent(false)}>
Weitere Nachricht senden
</Button>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Dein Name"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="name@feuerwehr.ch"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nachricht</label>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
required
rows={5}
placeholder="Deine Nachricht..."
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="bg-red-600 hover:bg-red-700"
disabled={sending || !name || !email || !message}
>
{sending ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird gesendet...</>
) : (
<><Send className="w-4 h-4 mr-2" /> Nachricht senden</>
)}
</Button>
</form>
)
}

View File

@@ -0,0 +1,36 @@
'use client'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/components/providers/auth-provider'
export function NavAuthButtons() {
const { user, loading } = useAuth()
if (loading) {
return <div className="w-24 h-9" /> // placeholder to avoid layout shift
}
if (user) {
return (
<Link href="/app">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Zur App
</Button>
</Link>
)
}
return (
<>
<Link href="/login">
<Button variant="ghost" size="sm">Anmelden</Button>
</Link>
<Link href="/register">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Kostenlos starten
</Button>
</Link>
</>
)
}

View File

@@ -728,6 +728,46 @@ export function MapView({
// Expose map instance to parent for export // Expose map instance to parent for export
if (externalMapRef) externalMapRef.current = map.current if (externalMapRef) externalMapRef.current = map.current
// --- WebGL context loss recovery ---
// When the browser reclaims GPU memory (background tab, memory pressure),
// the WebGL context is lost and tiles go black. This recovers automatically.
const canvas = map.current.getCanvas()
canvas.addEventListener('webglcontextlost', (e) => {
console.warn('[Map] WebGL context lost — will restore when possible')
e.preventDefault() // allows context to be restored
})
canvas.addEventListener('webglcontextrestored', () => {
console.info('[Map] WebGL context restored — reloading map style')
const m = map.current
if (m) {
// Force full tile reload by re-setting the style
const style = m.getStyle()
if (style) {
m.setStyle(style)
}
}
})
// --- Page visibility recovery ---
// When user switches back to this tab after a while, tiles may be stale/black.
// Force a resize + tile re-request on visibility change.
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && map.current) {
// Small delay to let browser finish tab switch
setTimeout(() => {
if (!map.current) return
map.current.resize()
// Nudge the map to force tile re-requests
const center = map.current.getCenter()
map.current.setCenter(center)
}, 100)
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
// Store cleanup reference
const cleanupVisibility = () => document.removeEventListener('visibilitychange', handleVisibilityChange)
map.current.addControl(new maplibregl.NavigationControl(), 'bottom-right') map.current.addControl(new maplibregl.NavigationControl(), 'bottom-right')
map.current.addControl(new maplibregl.ScaleControl(), 'bottom-left') map.current.addControl(new maplibregl.ScaleControl(), 'bottom-left')
@@ -1286,6 +1326,7 @@ export function MapView({
}) })
return () => { return () => {
cleanupVisibility()
map.current?.remove() map.current?.remove()
map.current = null map.current = null
} }
@@ -1421,26 +1462,27 @@ export function MapView({
const lineCoords = f.geometry.coordinates as number[][] const lineCoords = f.geometry.coordinates as number[][]
if (lineCoords.length < 2) return if (lineCoords.length < 2) return
// Get last two points to calculate arrow direction using screen-projected coords // Geographic bearing from p1 to p2 (works for short distances)
const p1 = lineCoords[lineCoords.length - 2] const p1 = lineCoords[lineCoords.length - 2]
const p2 = lineCoords[lineCoords.length - 1] const p2 = lineCoords[lineCoords.length - 1]
const px1 = map.current.project(p1 as [number, number]) const dLng = p2[0] - p1[0]
const px2 = map.current.project(p2 as [number, number]) const dLat = p2[1] - p1[1]
const screenAngle = Math.atan2(px2.y - px1.y, px2.x - px1.x) * (180 / Math.PI) + 90 // atan2(dLng, dLat) gives angle from north (up), clockwise — matches CSS triangle ▲ default
const geoBearing = Math.atan2(dLng, dLat) * (180 / Math.PI)
const color = (f.properties.color as string) || '#000000' const color = (f.properties.color as string) || '#000000'
const arrowEl = document.createElement('div') const arrowEl = document.createElement('div')
arrowEl.style.cssText = ` arrowEl.style.cssText = `
width: 0; height: 0; width: 0; height: 0;
border-left: 10px solid transparent; border-left: 12px solid transparent;
border-right: 10px solid transparent; border-right: 12px solid transparent;
border-bottom: 20px solid ${color}; border-bottom: 24px solid ${color};
transform: rotate(${screenAngle}deg); transform: rotate(${geoBearing}deg);
transform-origin: center center; transform-origin: center center;
pointer-events: none; pointer-events: none;
` `
const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center', rotationAlignment: 'viewport' }) const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center', rotationAlignment: 'map' })
.setLngLat(p2 as [number, number]) .setLngLat(p2 as [number, number])
.addTo(map.current) .addTo(map.current)
markersRef.current.push(marker) markersRef.current.push(marker)
@@ -1545,7 +1587,7 @@ export function MapView({
}) })
} }
const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: canEdit, rotationAlignment: 'viewport' }) const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: canEdit, rotationAlignment: 'map' })
.setLngLat(midpoint) .setLngLat(midpoint)
.addTo(map.current) .addTo(map.current)
@@ -1664,7 +1706,7 @@ export function MapView({
} }
try { try {
const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'viewport' }) const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'map' })
.setLngLat(coords) .setLngLat(coords)
.addTo(map.current) .addTo(map.current)
@@ -1730,7 +1772,7 @@ export function MapView({
el.textContent = (f.properties.text as string) || '' el.textContent = (f.properties.text as string) || ''
wrapper.appendChild(el) wrapper.appendChild(el)
const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'viewport' }) const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'map' })
.setLngLat(coords) .setLngLat(coords)
.addTo(map.current) .addTo(map.current)
@@ -1892,18 +1934,27 @@ export function MapView({
} }
}, [drawMode, deselectSymbol]) }, [drawMode, deselectSymbol])
// ESC to cancel drawing, DEL to delete selected symbol // ESC to cancel drawing, DEL to delete selected symbol/line/polygon
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// DEL / Backspace → delete selected symbol // DEL / Backspace → delete selected symbol or line/polygon
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
const tag = (e.target as HTMLElement)?.tagName const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
// Delete selected symbol/text
if (selectedSymbolRef.current) { if (selectedSymbolRef.current) {
e.preventDefault() e.preventDefault()
deleteSelectedSymbol() deleteSelectedSymbol()
return return
} }
// Delete selected line/polygon/arrow (vertex-editing selection)
if (selectedLineIdRef.current) {
e.preventDefault()
const updated = featuresRef.current.filter(f => f.id !== selectedLineIdRef.current)
onFeaturesChangeRef.current(updated)
showVertexMarkersRef.current(null)
return
}
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
// In measure mode: finalize (keep line + labels), just stop adding // In measure mode: finalize (keep line + labels), just stop adding

View File

@@ -21,7 +21,7 @@ interface ToolStore {
export const useToolStore = create<ToolStore>((set) => ({ export const useToolStore = create<ToolStore>((set) => ({
activeTool: 'select', activeTool: 'select',
activeColor: '#ff0000', // Default Rot activeColor: '#000000', // Default Schwarz
lineType: 'solid', lineType: 'solid',
lineWidth: 3, lineWidth: 3,
selectedFeatureId: null, selectedFeatureId: null,