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:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
208
src/app/page.tsx
208
src/app/page.tsx
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/components/landing/contact-form.tsx
Normal file
105
src/components/landing/contact-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/landing/nav-auth-buttons.tsx
Normal file
36
src/components/landing/nav-auth-buttons.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user