diff --git a/client/package-lock.json b/client/package-lock.json
index 36b7a2e0..abdae0d6 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -17,10 +17,12 @@
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"is-inside-container": "^1.0.0",
+ "leaflet": "^1.9.4",
"lucide-react": "^0.516.0",
"next": "15.5.10",
"react": "19.1.0",
"react-dom": "19.1.0",
+ "react-leaflet": "^5.0.0",
"react-social-icons": "^6.25.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
@@ -31,6 +33,7 @@
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.29.0",
"@tanstack/eslint-plugin-query": "^5.78.0",
+ "@types/leaflet": "^1.9.21",
"@types/node": "^24.0.3",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
@@ -1329,6 +1332,17 @@
}
}
},
+ "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/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1429,6 +1443,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",
@@ -1443,6 +1464,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/leaflet": {
+ "version": "1.9.21",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
+ "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "24.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz",
@@ -4694,6 +4725,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",
@@ -5799,6 +5836,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-social-icons": {
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/react-social-icons/-/react-social-icons-6.25.0.tgz",
diff --git a/client/package.json b/client/package.json
index 6a9324dd..f55bc577 100644
--- a/client/package.json
+++ b/client/package.json
@@ -27,10 +27,12 @@
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"is-inside-container": "^1.0.0",
+ "leaflet": "^1.9.4",
"lucide-react": "^0.516.0",
"next": "15.5.10",
"react": "19.1.0",
"react-dom": "19.1.0",
+ "react-leaflet": "^5.0.0",
"react-social-icons": "^6.25.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
@@ -41,6 +43,7 @@
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.29.0",
"@tanstack/eslint-plugin-query": "^5.78.0",
+ "@types/leaflet": "^1.9.21",
"@types/node": "^24.0.3",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
diff --git a/client/public/leaflet/marker-icon-2x.png b/client/public/leaflet/marker-icon-2x.png
new file mode 100644
index 00000000..88f9e501
Binary files /dev/null and b/client/public/leaflet/marker-icon-2x.png differ
diff --git a/client/public/leaflet/marker-icon.png b/client/public/leaflet/marker-icon.png
new file mode 100644
index 00000000..950edf24
Binary files /dev/null and b/client/public/leaflet/marker-icon.png differ
diff --git a/client/public/leaflet/marker-shadow.png b/client/public/leaflet/marker-shadow.png
new file mode 100644
index 00000000..9fd29795
Binary files /dev/null and b/client/public/leaflet/marker-shadow.png differ
diff --git a/client/src/components/map/EventMap.tsx b/client/src/components/map/EventMap.tsx
new file mode 100644
index 00000000..add6dfb4
--- /dev/null
+++ b/client/src/components/map/EventMap.tsx
@@ -0,0 +1,43 @@
+import "leaflet/dist/leaflet.css";
+
+import L from "leaflet";
+import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
+
+const iconProto = L.Icon.Default.prototype as unknown as {
+ _getIconUrl?: unknown;
+};
+
+delete iconProto._getIconUrl;
+
+L.Icon.Default.mergeOptions({
+ iconRetinaUrl: "/leaflet/marker-icon-2x.png",
+ iconUrl: "/leaflet/marker-icon.png",
+ shadowUrl: "/leaflet/marker-shadow.png",
+});
+
+type Props = {
+ lat: number;
+ lon: number;
+ name?: string;
+};
+
+export default function EventMap({ lat, lon, name }: Props) {
+ return (
+
+
+
+
+ {name ?? "Event location"}
+
+
+
+ );
+}
diff --git a/client/src/components/map/osm.ts b/client/src/components/map/osm.ts
new file mode 100644
index 00000000..a70f8cc4
--- /dev/null
+++ b/client/src/components/map/osm.ts
@@ -0,0 +1,28 @@
+export function parseOpenStreetMapUrl(
+ osmUrl: string,
+): { lat: number; lon: number } | null {
+ try {
+ const url = new URL(osmUrl);
+
+ // Pattern 1: ?mlat=..&mlon=..
+ const mlat = url.searchParams.get("mlat");
+ const mlon = url.searchParams.get("mlon");
+ if (mlat && mlon) {
+ const lat = Number(mlat);
+ const lon = Number(mlon);
+ if (Number.isFinite(lat) && Number.isFinite(lon)) return { lat, lon };
+ }
+
+ // Pattern 2: #map=zoom/lat/lon
+ const match = url.hash.match(/#map=\d+\/(-?\d+(\.\d+)?)\/(-?\d+(\.\d+)?)/);
+ if (match) {
+ const lat = Number(match[1]);
+ const lon = Number(match[3]);
+ if (Number.isFinite(lat) && Number.isFinite(lon)) return { lat, lon };
+ }
+
+ return null;
+ } catch {
+ return null;
+ }
+}
diff --git a/client/src/hooks/useEvent.ts b/client/src/hooks/useEvent.ts
index 27794c6b..10d48547 100644
--- a/client/src/hooks/useEvent.ts
+++ b/client/src/hooks/useEvent.ts
@@ -12,6 +12,7 @@ type ApiEvent = {
startTime: string | null;
location: string;
cover_image: string | null;
+ openstreetmap_url: string | null;
};
type UiEvent = Omit & {
diff --git a/client/src/pages/_app.tsx b/client/src/pages/_app.tsx
index ca3770d2..e1e19af9 100644
--- a/client/src/pages/_app.tsx
+++ b/client/src/pages/_app.tsx
@@ -1,4 +1,5 @@
import "@/styles/globals.css";
+import "leaflet/dist/leaflet.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
diff --git a/client/src/pages/events/[id].tsx b/client/src/pages/events/[id].tsx
index 8e8d8803..be475e91 100644
--- a/client/src/pages/events/[id].tsx
+++ b/client/src/pages/events/[id].tsx
@@ -1,8 +1,14 @@
+import dynamic from "next/dynamic";
import Image from "next/image";
import { useRouter } from "next/router";
+import { parseOpenStreetMapUrl } from "@/components/map/osm";
import { useEvent } from "@/hooks/useEvent";
+const EventMap = dynamic(() => import("@/components/map/EventMap"), {
+ ssr: false,
+});
+
function formatDateTime(dateString: string): string {
try {
const date = new Date(dateString);
@@ -59,6 +65,9 @@ export default function EventPage() {
);
}
+ const coords = event.openstreetmap_url
+ ? parseOpenStreetMapUrl(event.openstreetmap_url)
+ : null;
return (
@@ -89,6 +98,14 @@ export default function EventPage() {
/>
+ {coords && (
+
+ )}
);
}
diff --git a/server/game_dev/migrations/0010_event_openstreetmap_url.py b/server/game_dev/migrations/0010_event_openstreetmap_url.py
new file mode 100644
index 00000000..f0a52b48
--- /dev/null
+++ b/server/game_dev/migrations/0010_event_openstreetmap_url.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.14 on 2026-02-16 09:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0009_merge_20260131_1044"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="event",
+ name="openstreetmap_url",
+ field=models.URLField(blank=True, max_length=500),
+ ),
+ ]
diff --git a/server/game_dev/migrations/0014_merge_20260218_0101.py b/server/game_dev/migrations/0014_merge_20260218_0101.py
new file mode 100644
index 00000000..979db69f
--- /dev/null
+++ b/server/game_dev/migrations/0014_merge_20260218_0101.py
@@ -0,0 +1,13 @@
+# Generated by Django 5.1.14 on 2026-02-17 17:01
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game_dev", "0010_event_openstreetmap_url"),
+ ("game_dev", "0013_merge_20260214_1347"),
+ ]
+
+ operations = []
diff --git a/server/game_dev/models.py b/server/game_dev/models.py
index 2e4d5b28..e546d342 100644
--- a/server/game_dev/models.py
+++ b/server/game_dev/models.py
@@ -4,7 +4,8 @@
class Member(models.Model):
name = models.CharField(max_length=200)
active = models.BooleanField(default=True)
- profile_picture = models.ImageField(upload_to="profiles/", null=True, blank=True)
+ profile_picture = models.ImageField(
+ upload_to="profiles/", null=True, blank=True)
about = models.CharField(max_length=256, blank=True)
pronouns = models.CharField(max_length=20, blank=True)
@@ -19,6 +20,7 @@ class Event(models.Model):
publicationDate = models.DateField()
cover_image = models.ImageField(upload_to="events/", null=True)
location = models.CharField(max_length=256)
+ openstreetmap_url = models.URLField(max_length=500, blank=True)
def __str__(self):
return self.name
@@ -26,8 +28,10 @@ def __str__(self):
# GameContributor table: links Game, Member, and role (composite PK)
class GameContributor(models.Model):
- game = models.ForeignKey('Game', on_delete=models.CASCADE, related_name='game_contributors')
- member = models.ForeignKey('Member', on_delete=models.CASCADE, related_name='member_games')
+ game = models.ForeignKey(
+ 'Game', on_delete=models.CASCADE, related_name='game_contributors')
+ member = models.ForeignKey(
+ 'Member', on_delete=models.CASCADE, related_name='member_games')
role = models.CharField(max_length=100)
class Meta:
@@ -62,7 +66,8 @@ class CompletionStatus(models.IntegerChoices):
)
thumbnail = models.ImageField(upload_to="games/", null=True)
- event = models.ForeignKey(Event, on_delete=models.SET_NULL, null=True, blank=True)
+ event = models.ForeignKey(
+ Event, on_delete=models.SET_NULL, null=True, blank=True)
def __str__(self):
return str(self.name)
diff --git a/server/game_dev/serializers.py b/server/game_dev/serializers.py
index 4d278157..79273203 100644
--- a/server/game_dev/serializers.py
+++ b/server/game_dev/serializers.py
@@ -13,6 +13,7 @@ class Meta:
"publicationDate",
"cover_image",
"location",
+ "openstreetmap_url",
]