feat(frontend): Kartenansicht für Bienenstände (Später-Liste)

Neue Seite /apiaries/map zeigt alle Bienenstände mit hinterlegten
Koordinaten auf einer Leaflet-Karte (OpenStreetMap-Kacheln), mit
Marker-Popup, das zum jeweiligen Bienenstand verlinkt. Karte wird
per next/dynamic mit ssr:false geladen, da Leaflet auf window/document
zugreift.

Breiten-/Längengrad waren zwar seit dem MVP im Backend-DTO vorhanden,
aber über kein Formular erreichbar – ohne diese Lücke wäre die Karte
dauerhaft leer geblieben. Daher Breitengrad-/Längengrad-Felder auf der
Bienenstand-Anlegen- und Bearbeiten-Seite ergänzt.

Marker-Icons werden aus public/leaflet/ bedient statt aus
node_modules-Bild-Imports, da Turbopack Bilder aus node_modules nicht
als StaticImageData auflöst (führte zu "iconUrl not set"-Laufzeitfehler,
live im Browser gefunden und behoben) – der von Leaflet empfohlene
Workaround für Bundler-Icon-Probleme.

Live verifiziert: Koordinaten speichern, Karte zeigt Marker an
korrekter Position, Popup verlinkt korrekt, Empty State bei fehlenden
Koordinaten, mobile Breite ohne Overflow. Zero Konsolenfehler.
This commit is contained in:
Nick Szaunig committed 2026-08-25 00:38:08 +02:00
1 parent 4b9f5c7f47
commit aa3a777381
11 files changed
+238 -5

No files matched your search

@@ -23,6 +23,8 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
const queryClient = useQueryClient();
const [name, setName] = useState(apiary.name);
const [location, setLocation] = useState(apiary.location ?? "");
const [latitude, setLatitude] = useState(apiary.latitude?.toString() ?? "");
const [longitude, setLongitude] = useState(apiary.longitude?.toString() ?? "");
const [address, setAddress] = useState(apiary.address ?? "");
const [notes, setNotes] = useState(apiary.notes ?? "");
@@ -31,6 +33,8 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
updateApiary(apiary.id, {
name,
location: location || undefined,
latitude: latitude ? Number(latitude) : undefined,
longitude: longitude ? Number(longitude) : undefined,
address: address || undefined,
notes: notes || undefined,
}),
@@ -99,6 +103,32 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
onChange={(event) => setLocation(event.target.value)}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="apiary-latitude">Breitengrad</Label>
<Input
id="apiary-latitude"
type="number"
step="0.000001"
inputMode="decimal"
placeholder="z. B. 52.5200"
value={latitude}
onChange={(event) => setLatitude(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="apiary-longitude">Längengrad</Label>
<Input
id="apiary-longitude"
type="number"
step="0.000001"
inputMode="decimal"
placeholder="z. B. 13.4050"
value={longitude}
onChange={(event) => setLongitude(event.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="apiary-address">Adresse</Label>
<Input
@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import { MapPin } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { Apiary } from "@/lib/apiaries/types";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { PageHeader } from "@/components/page-header";
const ApiaryMap = dynamic(
() => import("@/components/apiary-map").then((mod) => mod.ApiaryMap),
{ ssr: false, loading: () => <p className="text-sm text-muted-foreground">Karte lädt…</p> },
);
function hasCoordinates(
apiary: Apiary,
): apiary is Apiary & { latitude: number; longitude: number } {
return apiary.latitude !== null && apiary.longitude !== null;
}
export default function ApiaryMapPage() {
const { data: apiaries, isLoading, isError } = useQuery({
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
});
const located = apiaries?.filter(hasCoordinates) ?? [];
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={MapPin}
title="Kartenansicht"
description="Bienenstände mit hinterlegten Koordinaten"
actions={
<Button variant="outline" nativeButton={false} render={<Link href="/apiaries" />}>
Zur Liste
</Button>
}
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Bienenstände konnten nicht geladen werden.
</p>
) : null}
{apiaries && located.length === 0 ? (
<EmptyState
icon={MapPin}
title="Keine Koordinaten hinterlegt"
description="Trage Breiten- und Längengrad bei einem Bienenstand ein, um ihn hier zu sehen."
/>
) : null}
{located.length > 0 ? <ApiaryMap apiaries={located} /> : null}
</main>
);
}
@@ -17,6 +17,8 @@ export default function NewApiaryPage() {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [location, setLocation] = useState("");
const [latitude, setLatitude] = useState("");
const [longitude, setLongitude] = useState("");
const [address, setAddress] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -38,6 +40,8 @@ export default function NewApiaryPage() {
mutation.mutate({
name,
location: location || undefined,
latitude: latitude ? Number(latitude) : undefined,
longitude: longitude ? Number(longitude) : undefined,
address: address || undefined,
notes: notes || undefined,
});
@@ -70,6 +74,32 @@ export default function NewApiaryPage() {
onChange={(event) => setLocation(event.target.value)}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="apiary-latitude">Breitengrad</Label>
<Input
id="apiary-latitude"
type="number"
step="0.000001"
inputMode="decimal"
placeholder="z. B. 52.5200"
value={latitude}
onChange={(event) => setLatitude(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="apiary-longitude">Längengrad</Label>
<Input
id="apiary-longitude"
type="number"
step="0.000001"
inputMode="decimal"
placeholder="z. B. 13.4050"
value={longitude}
onChange={(event) => setLongitude(event.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="apiary-address">Adresse</Label>
<Input
@@ -43,9 +43,14 @@ export default function ApiariesPage() {
title="Bienenstände"
description="Standorte deiner Völker"
actions={
<Button nativeButton={false} render={<Link href="/apiaries/new" />}>
+ Neuer Bienenstand
</Button>
<>
<Button variant="outline" nativeButton={false} render={<Link href="/apiaries/map" />}>
Karte
</Button>
<Button nativeButton={false} render={<Link href="/apiaries/new" />}>
+ Neuer Bienenstand
</Button>
</>
}
/>
@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
import { Apiary } from "@/lib/apiaries/types";
const markerIcon = L.icon({
iconUrl: "/leaflet/marker-icon.png",
iconRetinaUrl: "/leaflet/marker-icon-2x.png",
shadowUrl: "/leaflet/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
interface ApiaryMapProps {
apiaries: (Apiary & { latitude: number; longitude: number })[];
}
export function ApiaryMap({ apiaries }: ApiaryMapProps) {
const center: [number, number] = [apiaries[0].latitude, apiaries[0].longitude];
return (
<MapContainer
center={center}
zoom={9}
scrollWheelZoom
className="h-[60vh] w-full rounded-2xl"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{apiaries.map((apiary) => (
<Marker key={apiary.id} position={[apiary.latitude, apiary.longitude]} icon={markerIcon}>
<Popup>
<Link href={`/apiaries/${apiary.id}`} className="font-medium text-primary underline">
{apiary.name}
</Link>
{apiary.location ? <p className="text-sm text-muted-foreground">{apiary.location}</p> : null}
</Popup>
</Marker>
))}
</MapContainer>
);
}
@@ -19,6 +19,8 @@ export interface ApiaryInput {
name: string;
description?: string;
location?: string;
latitude?: number;
longitude?: number;
address?: string;
notes?: string;
}