feat(mobile+ux): Vollbild-Button auf Karte + Standort-Vorschlag beim Einsatz (v1.4.14)
- Mobile Vollbild-Button schwebend oben links auf der Karte (versteckt Browser-Leiste, document.fullscreen); Icon wechselt zwischen Vollbild/Verlassen - Neuer Einsatz: aktueller Standort wird beim Öffnen automatisch als Einsatzort vorgeschlagen (Reverse-Geocoding via Nominatim), plus "Mein Standort"-Button - Setzt zugleich mapCenter → Karte startet direkt am Standort - Bei verweigertem/fehlendem Standort klarer Hinweis, manuelle Eingabe bleibt möglich - Adress-Anzeige in gemeinsamen buildDisplayName() ausgelagert Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.4.13",
|
"version": "1.4.14",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1021,6 +1021,8 @@ export default function AppPage() {
|
|||||||
undoDrawPointRef={undoDrawPointRef}
|
undoDrawPointRef={undoDrawPointRef}
|
||||||
pendingSymbol={pendingSymbol ? { iconId: pendingSymbol.id, imageUrl: pendingSymbol.imageUrl } : null}
|
pendingSymbol={pendingSymbol ? { iconId: pendingSymbol.id, imageUrl: pendingSymbol.imageUrl } : null}
|
||||||
onSymbolPlaced={() => setPendingSymbol(null)}
|
onSymbolPlaced={() => setPendingSymbol(null)}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onToggleFullscreen={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,23 @@ interface NominatimResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Baut aus einem Nominatim-Ergebnis eine kurze, lesbare Adresse (Strasse Nr, PLZ Ort)
|
||||||
|
function buildDisplayName(result: NominatimResult): string {
|
||||||
|
const addr = result.address
|
||||||
|
if (!addr) return result.display_name
|
||||||
|
const parts: string[] = []
|
||||||
|
if (addr.road) {
|
||||||
|
parts.push(addr.road + (addr.house_number ? ' ' + addr.house_number : ''))
|
||||||
|
}
|
||||||
|
const city = addr.city || addr.town || addr.village || addr.municipality
|
||||||
|
if (addr.postcode && city) {
|
||||||
|
parts.push(`${addr.postcode} ${city}`)
|
||||||
|
} else if (city) {
|
||||||
|
parts.push(city)
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts.join(', ') : result.display_name
|
||||||
|
}
|
||||||
|
|
||||||
interface ProjectDialogProps {
|
interface ProjectDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
@@ -65,6 +82,9 @@ export function ProjectDialog({
|
|||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||||
const [selectedCoords, setSelectedCoords] = useState<{ lat: number; lng: number } | null>(null)
|
const [selectedCoords, setSelectedCoords] = useState<{ lat: number; lng: number } | null>(null)
|
||||||
|
const [isLocating, setIsLocating] = useState(false)
|
||||||
|
const [locateError, setLocateError] = useState('')
|
||||||
|
const autoLocatedRef = useRef(false)
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const suggestionsRef = useRef<HTMLDivElement>(null)
|
const suggestionsRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -103,28 +123,64 @@ export function ProjectDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectSuggestion = (result: NominatimResult) => {
|
const handleSelectSuggestion = (result: NominatimResult) => {
|
||||||
// Build a clean display name
|
setLocation(buildDisplayName(result))
|
||||||
const addr = result.address
|
|
||||||
let displayName = result.display_name
|
|
||||||
if (addr) {
|
|
||||||
const parts: string[] = []
|
|
||||||
if (addr.road) {
|
|
||||||
parts.push(addr.road + (addr.house_number ? ' ' + addr.house_number : ''))
|
|
||||||
}
|
|
||||||
const city = addr.city || addr.town || addr.village || addr.municipality
|
|
||||||
if (addr.postcode && city) {
|
|
||||||
parts.push(`${addr.postcode} ${city}`)
|
|
||||||
} else if (city) {
|
|
||||||
parts.push(city)
|
|
||||||
}
|
|
||||||
if (parts.length > 0) displayName = parts.join(', ')
|
|
||||||
}
|
|
||||||
setLocation(displayName)
|
|
||||||
setSelectedCoords({ lat: parseFloat(result.lat), lng: parseFloat(result.lon) })
|
setSelectedCoords({ lat: parseFloat(result.lat), lng: parseFloat(result.lon) })
|
||||||
setSuggestions([])
|
setSuggestions([])
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aktuellen Standort ermitteln und als Einsatzort vorschlagen (Reverse-Geocoding)
|
||||||
|
const applyCurrentLocation = useCallback(() => {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||||
|
setLocateError('Standort auf diesem Gerät nicht verfügbar')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsLocating(true)
|
||||||
|
setLocateError('')
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
async (pos) => {
|
||||||
|
const { latitude, longitude } = pos.coords
|
||||||
|
setSelectedCoords({ lat: latitude, lng: longitude })
|
||||||
|
setShowSuggestions(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?lat=${latitude}&lon=${longitude}&format=json&addressdetails=1`
|
||||||
|
)
|
||||||
|
if (res.ok) {
|
||||||
|
const data: NominatimResult = await res.json()
|
||||||
|
setLocation(buildDisplayName(data))
|
||||||
|
} else {
|
||||||
|
setLocation(`${latitude.toFixed(5)}, ${longitude.toFixed(5)}`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setLocation(`${latitude.toFixed(5)}, ${longitude.toFixed(5)}`)
|
||||||
|
} finally {
|
||||||
|
setIsLocating(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setIsLocating(false)
|
||||||
|
setLocateError(
|
||||||
|
err.code === err.PERMISSION_DENIED
|
||||||
|
? 'Standortzugriff verweigert'
|
||||||
|
: 'Standort konnte nicht ermittelt werden'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 60000 }
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Beim Öffnen eines neuen Einsatzes den Standort automatisch als Ort vorschlagen
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && mode === 'EINSATZ' && !autoLocatedRef.current && !location && !selectedCoords) {
|
||||||
|
autoLocatedRef.current = true
|
||||||
|
applyCurrentLocation()
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
autoLocatedRef.current = false
|
||||||
|
}
|
||||||
|
}, [open, mode, location, selectedCoords, applyCurrentLocation])
|
||||||
|
|
||||||
// Close suggestions on click outside
|
// Close suggestions on click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
@@ -186,6 +242,8 @@ export function ProjectDialog({
|
|||||||
setJournalfuehrer('')
|
setJournalfuehrer('')
|
||||||
setSelectedCoords(null)
|
setSelectedCoords(null)
|
||||||
setSuggestions([])
|
setSuggestions([])
|
||||||
|
setLocateError('')
|
||||||
|
setIsLocating(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Fehler',
|
title: 'Fehler',
|
||||||
@@ -207,6 +265,8 @@ export function ProjectDialog({
|
|||||||
setJournalfuehrer('')
|
setJournalfuehrer('')
|
||||||
setSelectedCoords(null)
|
setSelectedCoords(null)
|
||||||
setSuggestions([])
|
setSuggestions([])
|
||||||
|
setLocateError('')
|
||||||
|
setIsLocating(false)
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,6 +329,7 @@ export function ProjectDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Label htmlFor="project-location">
|
<Label htmlFor="project-location">
|
||||||
Einsatzort
|
Einsatzort
|
||||||
{selectedCoords && (
|
{selectedCoords && (
|
||||||
@@ -277,6 +338,17 @@ export function ProjectDialog({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={applyCurrentLocation}
|
||||||
|
disabled={isCreating || isLocating}
|
||||||
|
className="shrink-0 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline disabled:opacity-50"
|
||||||
|
title="Aktuellen Standort als Einsatzort übernehmen"
|
||||||
|
>
|
||||||
|
{isLocating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <MapPin className="w-3.5 h-3.5" />}
|
||||||
|
{isLocating ? 'Ermittle…' : 'Mein Standort'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="relative" ref={suggestionsRef}>
|
<div className="relative" ref={suggestionsRef}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
@@ -336,9 +408,13 @@ export function ProjectDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{locateError ? (
|
||||||
|
<p className="text-xs text-amber-600">{locateError} — Adresse bitte manuell eingeben.</p>
|
||||||
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Adresse suchen — die Karte springt automatisch zum Einsatzort
|
Standort wird beim Öffnen vorgeschlagen — oder Adresse suchen. Die Karte springt automatisch zum Einsatzort.
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ interface MapViewProps {
|
|||||||
/** Tap-to-place: wenn gesetzt, platziert der nächste Karten-Tipp dieses Symbol (Mobil-freundlich). */
|
/** Tap-to-place: wenn gesetzt, platziert der nächste Karten-Tipp dieses Symbol (Mobil-freundlich). */
|
||||||
pendingSymbol?: { iconId: string; imageUrl?: string } | null
|
pendingSymbol?: { iconId: string; imageUrl?: string } | null
|
||||||
onSymbolPlaced?: () => void
|
onSymbolPlaced?: () => void
|
||||||
|
/** Vollbild-Umschaltung (mobil als schwebender Button über der Karte) */
|
||||||
|
isFullscreen?: boolean
|
||||||
|
onToggleFullscreen?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapView({
|
export function MapView({
|
||||||
@@ -105,6 +108,8 @@ export function MapView({
|
|||||||
undoDrawPointRef,
|
undoDrawPointRef,
|
||||||
pendingSymbol,
|
pendingSymbol,
|
||||||
onSymbolPlaced,
|
onSymbolPlaced,
|
||||||
|
isFullscreen,
|
||||||
|
onToggleFullscreen,
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const mapContainer = useRef<HTMLDivElement | null>(null)
|
const mapContainer = useRef<HTMLDivElement | null>(null)
|
||||||
const map = useRef<maplibregl.Map | null>(null)
|
const map = useRef<maplibregl.Map | null>(null)
|
||||||
@@ -2140,6 +2145,21 @@ export function MapView({
|
|||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Vollbild-Umschalter — mobil als schwebender Button (versteckt die Browser-Leiste) */}
|
||||||
|
{onToggleFullscreen && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
className="md:hidden absolute top-3 left-3 z-10 w-11 h-11 flex items-center justify-center rounded-lg bg-white/92 text-gray-800 shadow-md border border-black/10 active:scale-95 transition-transform"
|
||||||
|
title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Moveable controls for selected symbol */}
|
{/* Moveable controls for selected symbol */}
|
||||||
{isSymbolSelected && selectedSymbolRef.current && canEdit && (
|
{isSymbolSelected && selectedSymbolRef.current && canEdit && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user