feat: add map-based web interface

This commit is contained in:
2026-06-07 12:26:16 +02:00
parent caea56697c
commit 4ef8ae37b0
3 changed files with 491 additions and 17 deletions

View File

@@ -2,6 +2,14 @@
Web-App für Foto-Uploads, EXIF-Positionen und Kartenanzeige.
## Aktueller Prototyp
- Startseite mit Kartenansicht auf OpenStreetMap
- Marker für Beispieldaten
- Hover-Popup mit Thumbnail
- Klick öffnet Vollbildansicht
- Upload-Bereich als Platzhalter für den nächsten Schritt
## Zielbild
- Fotos per Webinterface hochladen
@@ -18,3 +26,10 @@ Web-App für Foto-Uploads, EXIF-Positionen und Kartenanzeige.
- `src/domain/` fachliche Modelle
- `src/features/` Anwendungslogik nach Bereichen
## Start
```bash
npm install
npm run build
npm start
```

50
package-lock.json generated Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "mapy-mg",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mapy-mg",
"version": "0.1.0",
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/node": {
"version": "22.19.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz",
"integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -1,21 +1,430 @@
import type { IncomingMessage, ServerResponse } from "node:http";
export function createRequestHandler() {
return async function handleRequest(_req: IncomingMessage, res: ServerResponse) {
res.statusCode = 200;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(
JSON.stringify({
name: "mapy-mg",
status: "ok",
features: [
"photo upload",
"EXIF location extraction",
"map markers",
"route preview"
]
})
);
};
const demoPhotos = [
{
id: "1",
name: "berlin-brandenburg-gate.jpg",
lat: 52.516275,
lon: 13.377704,
capturedAt: "2026-06-07T08:20:00Z",
thumb:
"https://images.unsplash.com/photo-1467269204594-9661b134dd2b?auto=format&fit=crop&w=200&q=60"
},
{
id: "2",
name: "museum-island.jpg",
lat: 52.5169,
lon: 13.4015,
capturedAt: "2026-06-07T08:42:00Z",
thumb:
"https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=200&q=60"
},
{
id: "3",
name: "alexanderplatz.jpg",
lat: 52.521918,
lon: 13.413215,
capturedAt: "2026-06-07T09:05:00Z",
thumb:
"https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=200&q=60"
}
] as const;
function htmlPage(): string {
const points = JSON.stringify(demoPhotos);
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mapy-mg</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<style>
:root {
color-scheme: light;
--bg: #f3efe6;
--panel: rgba(255, 255, 255, 0.82);
--panel-border: rgba(33, 37, 41, 0.12);
--text: #1f2937;
--muted: #5b6472;
--accent: #2d6cdf;
--shadow: 0 18px 50px rgba(23, 31, 45, 0.14);
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(45, 108, 223, 0.16), transparent 30%),
radial-gradient(circle at bottom right, rgba(220, 167, 47, 0.18), transparent 28%),
var(--bg);
}
body {
display: grid;
grid-template-rows: auto 1fr;
}
header {
padding: 20px 24px 12px;
display: flex;
justify-content: space-between;
gap: 16px;
align-items: end;
}
.brand h1 {
margin: 0;
font-size: clamp(1.5rem, 2.4vw, 2.2rem);
}
.brand p,
.meta {
margin: 6px 0 0;
color: var(--muted);
}
.layout {
min-height: 0;
display: grid;
grid-template-columns: 340px 1fr;
gap: 16px;
padding: 0 16px 16px;
}
aside,
main,
.overlay-card {
background: var(--panel);
backdrop-filter: blur(14px);
border: 1px solid var(--panel-border);
border-radius: 20px;
box-shadow: var(--shadow);
}
aside {
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.dropzone {
border: 1.5px dashed rgba(45, 108, 223, 0.35);
border-radius: 16px;
padding: 18px;
background: rgba(255, 255, 255, 0.7);
}
.dropzone strong {
display: block;
margin-bottom: 6px;
}
.dropzone small,
.section small {
color: var(--muted);
}
.section {
display: grid;
gap: 10px;
}
.list {
display: grid;
gap: 10px;
}
.photo {
display: grid;
grid-template-columns: 52px 1fr;
gap: 12px;
align-items: center;
padding: 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.72);
}
.photo img {
width: 52px;
height: 52px;
object-fit: cover;
border-radius: 10px;
}
.photo strong {
display: block;
font-size: 0.95rem;
}
.photo span {
color: var(--muted);
font-size: 0.82rem;
}
main {
position: relative;
min-height: 0;
overflow: hidden;
}
#map {
width: 100%;
height: 100%;
min-height: 560px;
}
.status {
position: absolute;
left: 16px;
top: 16px;
z-index: 500;
padding: 10px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.08);
font-size: 0.88rem;
}
.overlay {
position: fixed;
inset: 0;
display: none;
place-items: center;
background: rgba(8, 12, 18, 0.78);
z-index: 1000;
padding: 24px;
}
.overlay.open {
display: grid;
}
.overlay-card {
max-width: min(1100px, 100%);
width: 100%;
overflow: hidden;
}
.overlay-card header {
padding: 16px 18px;
border-bottom: 1px solid var(--panel-border);
}
.overlay-card img {
width: 100%;
max-height: 78vh;
object-fit: contain;
display: block;
background: #111;
}
.close {
appearance: none;
border: 0;
background: #111827;
color: white;
border-radius: 999px;
padding: 8px 12px;
cursor: pointer;
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: rgba(255, 255, 255, 0.96);
}
.leaflet-popup-content {
margin: 10px 12px;
}
.thumb {
width: 180px;
display: grid;
gap: 8px;
}
.thumb img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 12px;
}
.thumb button {
border: 0;
background: var(--accent);
color: white;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
}
@media (max-width: 920px) {
.layout {
grid-template-columns: 1fr;
}
#map {
min-height: 460px;
}
}
</style>
</head>
<body>
<header>
<div class="brand">
<h1>mapy-mg</h1>
<p>Fotos hochladen, Positionen sichtbar machen und Wege grob nachzeichnen.</p>
</div>
<div class="meta">OpenStreetMap + Leaflet · Prototyp</div>
</header>
<div class="layout">
<aside>
<div class="dropzone">
<strong>Fotos hochladen</strong>
<small>Der Upload ist im ersten Schritt nur als Oberfläche vorbereitet. Später lesen wir EXIF und Positionen daraus aus.</small>
</div>
<div class="section">
<strong>Beispieldaten</strong>
<small>${demoPhotos.length} Marker zur Vorschau</small>
<div class="list" id="photo-list"></div>
</div>
</aside>
<main>
<div class="status">Marker per Hover -> Thumbnail, Klick -> Vollbild</div>
<div id="map"></div>
</main>
</div>
<div class="overlay" id="overlay" aria-hidden="true">
<div class="overlay-card">
<header>
<strong id="overlay-title">Foto</strong>
<button class="close" id="close-overlay" type="button">Schließen</button>
</header>
<img id="overlay-image" alt="" />
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
const photos = ${points};
const map = L.map("map", { zoomControl: true }).setView([52.5208, 13.4095], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; OpenStreetMap-Mitwirkende'
}).addTo(map);
const overlay = document.getElementById("overlay");
const overlayImage = document.getElementById("overlay-image");
const overlayTitle = document.getElementById("overlay-title");
const closeOverlay = document.getElementById("close-overlay");
const photoList = document.getElementById("photo-list");
if (!overlay || !overlayImage || !overlayTitle || !closeOverlay || !photoList) {
throw new Error("UI elements missing");
}
function openOverlay(photo) {
overlay.classList.add("open");
overlay.setAttribute("aria-hidden", "false");
overlayTitle.textContent = photo.name;
overlayImage.src = photo.thumb;
overlayImage.alt = photo.name;
}
function close() {
overlay.classList.remove("open");
overlay.setAttribute("aria-hidden", "true");
}
closeOverlay.addEventListener("click", close);
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
close();
}
});
const bounds = [];
photos.forEach((photo) => {
const marker = L.marker([photo.lat, photo.lon]).addTo(map);
bounds.push([photo.lat, photo.lon]);
marker.bindPopup(
'<div class="thumb">' +
'<img src="' + photo.thumb + '" alt="' + photo.name + '" />' +
'<strong>' + photo.name + '</strong>' +
'<small>' + new Date(photo.capturedAt).toLocaleString("de-DE") + '</small>' +
'<button type="button" data-open="' + photo.id + '">Vollbild</button>' +
'</div>'
);
marker.on("mouseover", () => marker.openPopup());
marker.on("click", () => openOverlay(photo));
});
document.addEventListener("click", (event) => {
const target = event.target;
if (target instanceof HTMLElement && target.dataset.open) {
const photo = photos.find((entry) => entry.id === target.dataset.open);
if (photo) openOverlay(photo);
}
});
photos.forEach((photo) => {
const item = document.createElement("article");
item.className = "photo";
item.innerHTML =
'<img src="' + photo.thumb + '" alt="' + photo.name + '">' +
'<div><strong>' + photo.name + '</strong><span>' +
new Date(photo.capturedAt).toLocaleString("de-DE") +
'</span></div>';
item.addEventListener("click", () => openOverlay(photo));
photoList.appendChild(item);
});
if (bounds.length) {
map.fitBounds(bounds, { padding: [40, 40] });
}
</script>
</body>
</html>`;
}
export function createRequestHandler() {
return async function handleRequest(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname === "/health") {
res.statusCode = 200;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.statusCode = 200;
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(htmlPage());
};
}