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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user