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:
Pepe Ziberi
2026-07-19 17:33:58 +02:00
parent 703265f47e
commit 2556a5c4a4
4 changed files with 127 additions and 29 deletions

View File

@@ -1021,6 +1021,8 @@ export default function AppPage() {
undoDrawPointRef={undoDrawPointRef}
pendingSymbol={pendingSymbol ? { iconId: pendingSymbol.id, imageUrl: pendingSymbol.imageUrl } : null}
onSymbolPlaced={() => setPendingSymbol(null)}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
/>
</main>
</div>

View File

@@ -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 {
open: boolean
onOpenChange: (open: boolean) => void
@@ -65,6 +82,9 @@ export function ProjectDialog({
const [isSearching, setIsSearching] = useState(false)
const [showSuggestions, setShowSuggestions] = useState(false)
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 suggestionsRef = useRef<HTMLDivElement>(null)
@@ -103,28 +123,64 @@ export function ProjectDialog({
}
const handleSelectSuggestion = (result: NominatimResult) => {
// Build a clean display name
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)
setLocation(buildDisplayName(result))
setSelectedCoords({ lat: parseFloat(result.lat), lng: parseFloat(result.lon) })
setSuggestions([])
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
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -186,6 +242,8 @@ export function ProjectDialog({
setJournalfuehrer('')
setSelectedCoords(null)
setSuggestions([])
setLocateError('')
setIsLocating(false)
} catch (error) {
toast({
title: 'Fehler',
@@ -207,6 +265,8 @@ export function ProjectDialog({
setJournalfuehrer('')
setSelectedCoords(null)
setSuggestions([])
setLocateError('')
setIsLocating(false)
onOpenChange(false)
}
}
@@ -269,14 +329,26 @@ export function ProjectDialog({
</div>
<div className="space-y-2">
<Label htmlFor="project-location">
Einsatzort
{selectedCoords && (
<span className="ml-2 text-xs text-green-600 font-normal inline-flex items-center gap-1">
<MapPin className="w-3 h-3" /> Koordinaten gesetzt
</span>
)}
</Label>
<div className="flex items-center justify-between gap-2">
<Label htmlFor="project-location">
Einsatzort
{selectedCoords && (
<span className="ml-2 text-xs text-green-600 font-normal inline-flex items-center gap-1">
<MapPin className="w-3 h-3" /> Koordinaten gesetzt
</span>
)}
</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">
<Input
@@ -336,9 +408,13 @@ export function ProjectDialog({
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
Adresse suchen die Karte springt automatisch zum Einsatzort
</p>
{locateError ? (
<p className="text-xs text-amber-600">{locateError} Adresse bitte manuell eingeben.</p>
) : (
<p className="text-xs text-muted-foreground">
Standort wird beim Öffnen vorgeschlagen oder Adresse suchen. Die Karte springt automatisch zum Einsatzort.
</p>
)}
</div>
<div className="space-y-2">

View File

@@ -88,6 +88,9 @@ interface MapViewProps {
/** Tap-to-place: wenn gesetzt, platziert der nächste Karten-Tipp dieses Symbol (Mobil-freundlich). */
pendingSymbol?: { iconId: string; imageUrl?: string } | null
onSymbolPlaced?: () => void
/** Vollbild-Umschaltung (mobil als schwebender Button über der Karte) */
isFullscreen?: boolean
onToggleFullscreen?: () => void
}
export function MapView({
@@ -105,6 +108,8 @@ export function MapView({
undoDrawPointRef,
pendingSymbol,
onSymbolPlaced,
isFullscreen,
onToggleFullscreen,
}: MapViewProps) {
const mapContainer = useRef<HTMLDivElement | null>(null)
const map = useRef<maplibregl.Map | null>(null)
@@ -2140,6 +2145,21 @@ export function MapView({
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 */}
{isSymbolSelected && selectedSymbolRef.current && canEdit && (
<>