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:
1 parent
4b9f5c7f47
commit
aa3a777381
11 files changed
+238
-5
No files matched your search
Generated
+53
-2
@@ -1,22 +1,24 @@
|
||||
{
|
||||
"name": "apicore-frontend-tmp",
|
||||
"name": "apicore-frontend",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "apicore-frontend-tmp",
|
||||
"name": "apicore-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.33.0",
|
||||
"next": "16.3.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"shadcn": "^4.18.0",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
@@ -27,6 +29,7 @@
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.5",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -2690,6 +2693,17 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -3710,6 +3724,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -3724,6 +3745,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
|
||||
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
@@ -8325,6 +8356,12 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -9940,6 +9977,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -16,11 +16,13 @@
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.33.0",
|
||||
"next": "16.3.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"shadcn": "^4.18.0",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
@@ -31,6 +33,7 @@
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.5",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
@@ -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='© <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;
|
||||
}
|
||||
Reference in new issue
Block a user