feat: add map-based web interface
This commit is contained in:
15
README.md
15
README.md
@@ -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
50
package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: '© 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());
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user