From aa3a777381299302bdd2c9cf9083836802f6bcea Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 25 Aug 2026 00:38:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Kartenansicht=20f=C3=BCr=20Bi?= =?UTF-8?q?enenst=C3=A4nde=20(Sp=C3=A4ter-Liste)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ApiCore-Frontend/package-lock.json | 55 ++++++++++++++- ApiCore-Frontend/package.json | 3 + .../public/leaflet/marker-icon-2x.png | Bin 0 -> 2464 bytes .../public/leaflet/marker-icon.png | Bin 0 -> 1466 bytes .../public/leaflet/marker-shadow.png | Bin 0 -> 618 bytes .../(app)/apiaries/[id]/apiary-edit-card.tsx | 30 +++++++++ .../src/app/(app)/apiaries/map/page.tsx | 63 ++++++++++++++++++ .../src/app/(app)/apiaries/new/page.tsx | 30 +++++++++ .../src/app/(app)/apiaries/page.tsx | 11 ++- .../src/components/apiary-map.tsx | 49 ++++++++++++++ ApiCore-Frontend/src/lib/apiaries/types.ts | 2 + 11 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 ApiCore-Frontend/public/leaflet/marker-icon-2x.png create mode 100644 ApiCore-Frontend/public/leaflet/marker-icon.png create mode 100644 ApiCore-Frontend/public/leaflet/marker-shadow.png create mode 100644 ApiCore-Frontend/src/app/(app)/apiaries/map/page.tsx create mode 100644 ApiCore-Frontend/src/components/apiary-map.tsx diff --git a/ApiCore-Frontend/package-lock.json b/ApiCore-Frontend/package-lock.json index 306995c..43f032d 100644 --- a/ApiCore-Frontend/package-lock.json +++ b/ApiCore-Frontend/package-lock.json @@ -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", diff --git a/ApiCore-Frontend/package.json b/ApiCore-Frontend/package.json index 3bb6051..27ecfb0 100644 --- a/ApiCore-Frontend/package.json +++ b/ApiCore-Frontend/package.json @@ -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", diff --git a/ApiCore-Frontend/public/leaflet/marker-icon-2x.png b/ApiCore-Frontend/public/leaflet/marker-icon-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..88f9e501888c9c6cb29ad340d9a888627dd1b6d8 GIT binary patch literal 2464 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 e-I|>x8~*?ZhF3snGbtJX0000cOl4 literal 0 HcmV?d00001 diff --git a/ApiCore-Frontend/public/leaflet/marker-icon.png b/ApiCore-Frontend/public/leaflet/marker-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..950edf24677ded147df13b26f91baa2b0fa70513 GIT binary patch literal 1466 zcmV;r1x5OaP)P001cn1^@s6z>|W`000GnNklGNuHDcIX17Zdjl&3`L?0sTjIws<{((Dh&g-s0<@jYQyl?D*X^?%13;ml^gy> ziMrY_^1WI=(g@LMizu=zCoA>C`6|QEq1eV92k*7m>G65*&@&6)aC&e}G zI)pf-Za|N`DT&Cn1J|o`19mumxW~hiKiKyc-P`S@q)rdTo84@QI@;0yXrG%9uhI>A zG5QHb6s4=<6xy{1 z@NMxEkryp{LS44%z$3lP^cX!9+2-;CTt3wM4(k*#C{aiIiLuB>jJj;KPhPzIC00bL zU3a#;aJld94lCW=`4&aAy8M7PY=HQ>O%$YEP4c4UY#CRxfgbE~(|uiI=YS8q;O9y6 zmIkXzR`}p7ti|PrM3a}WMnR=3NVnWdAAR>b9X@)DKL6=YsvmH%?I24wdq?Gh54_;# z$?_LvgjEdspdQlft#4CQ z`2Zyvy?*)N1Ftw|{_hakhG9WjS?Az@I@+IZ8JbWewR!XUK4&6346+d#~gsE0SY(LX8&JfY>Aj)RxGy96nwhs2rv zzW6pTnMpFkDSkT*a*6Dx|u@ds6ISVn0@^RmIsKZ5Y;bazbc;tTSq(kg(=481ODrPyNB6n z-$+U}(w$m6U6H$w17Bw+wDaFIe~GvNMYvnw31MpY0eQKT9l>SU``8k7w4)z!GZKMI z#_cEKq7k~i%nlK@6c-K?+R;B#5$?T#YpKD`t_4bAs^#E+@5QW$@OX3*`;(#{U^d-vY)&xEE>n5lYl&T?Amke9$Lam@{1K@O ze*LXqlKQHiv=gx+V^Cbb2?z@ISBQ*3amF;9UJ3SBg(N|710TLamQmYZ&Qjn2LuO<* zCZlB4n%@pc&7NNnY1}x+NWpHlq`OJEo|`aYN9<`RBUB+79g;>dgb6YlfN#kGL?lO_ z!6~M^7sOnbsUkKk<@Ysie&`G>ruxH&Mgy&8;i=A zB9OO!xR{AyODw>DS-q5YM{0ExFEAzt zm>RdS+ssW(-8|?xr0(?$vBVB*%(xDLtq3Hf0I5yFm<_g=W2`QWAax{1rWVH=I!VrP zs(rTFX@W#t$hXNvbgX`gK&^w_YD;CQ!B@e0QbLIWaKAXQe2-kkloo;{iF#6}z!4=W zi$giRj1{ zt;2w`VSCF#WE&*ev7jpsC=6175@(~nTE2;7M-L((0bH@yG}-TB$R~WXd?tA$s3|%y zA`9$sA(>F%J3ioz<-LJl*^o1|w84l>HBR`>3l9c8$5Xr@xCiIQ7{x$fMCzOk_-M=% z+{a_Q#;42`#KfUte@$NT77uaTz?b-fBe)1s5XE$yA79fm?KqM^VgLXD07*qoM6N<$ Ef<_J(9smFU literal 0 HcmV?d00001 diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx index f27489e..3f6fe63 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx @@ -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)} /> +
+
+ + setLatitude(event.target.value)} + /> +
+
+ + setLongitude(event.target.value)} + /> +
+
import("@/components/apiary-map").then((mod) => mod.ApiaryMap), + { ssr: false, loading: () =>

Karte lädt…

}, +); + +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 ( +
+ }> + Zur Liste + + } + /> + + {isLoading ?

Lädt…

: null} + {isError ? ( +

+ Bienenstände konnten nicht geladen werden. +

+ ) : null} + + {apiaries && located.length === 0 ? ( + + ) : null} + + {located.length > 0 ? : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/new/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/new/page.tsx index 92ae764..a8f1ece 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/new/page.tsx @@ -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(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)} />
+
+
+ + setLatitude(event.target.value)} + /> +
+
+ + setLongitude(event.target.value)} + /> +
+
}> - + Neuer Bienenstand - + <> + + + } /> diff --git a/ApiCore-Frontend/src/components/apiary-map.tsx b/ApiCore-Frontend/src/components/apiary-map.tsx new file mode 100644 index 0000000..9bcb92d --- /dev/null +++ b/ApiCore-Frontend/src/components/apiary-map.tsx @@ -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 ( + + + {apiaries.map((apiary) => ( + + + + {apiary.name} + + {apiary.location ?

{apiary.location}

: null} +
+
+ ))} +
+ ); +} diff --git a/ApiCore-Frontend/src/lib/apiaries/types.ts b/ApiCore-Frontend/src/lib/apiaries/types.ts index ad782a6..fdf9735 100644 --- a/ApiCore-Frontend/src/lib/apiaries/types.ts +++ b/ApiCore-Frontend/src/lib/apiaries/types.ts @@ -19,6 +19,8 @@ export interface ApiaryInput { name: string; description?: string; location?: string; + latitude?: number; + longitude?: number; address?: string; notes?: string; }