Compare commits
No commits in common. "e60debd10c929a89698158d9f0c21aa8c856417e" and "8e323a8ef2234873528ecdfcac157bf47de4bb59" have entirely different histories.
e60debd10c
...
8e323a8ef2
2 changed files with 471 additions and 322 deletions
|
|
@ -1,50 +1,192 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import type { GeofabrikFeature, GeofabrikIndex } from "@transportationer/shared";
|
import type { GeofabrikFeature, GeofabrikIndex } from "@transportationer/shared";
|
||||||
import { CityIngestProgress } from "@/components/city-ingest-progress";
|
import { CityIngestProgress } from "@/components/city-ingest-progress";
|
||||||
|
|
||||||
// ─── Geofabrik region matching ────────────────────────────────────────────────
|
type Step = "browse" | "confirm" | "ingest";
|
||||||
|
|
||||||
function pointInRing(point: [number, number], ring: number[][]): boolean {
|
// ─── Step indicator ───────────────────────────────────────────────────────────
|
||||||
const [px, py] = point;
|
|
||||||
let inside = false;
|
|
||||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
||||||
const xi = ring[i][0], yi = ring[i][1];
|
|
||||||
const xj = ring[j][0], yj = ring[j][1];
|
|
||||||
if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside;
|
|
||||||
}
|
|
||||||
return inside;
|
|
||||||
}
|
|
||||||
|
|
||||||
function containsPoint(geometry: GeofabrikFeature["geometry"], point: [number, number]): boolean {
|
function StepIndicator({ current }: { current: Step }) {
|
||||||
if (!geometry) return false;
|
const steps: { key: Step; label: string }[] = [
|
||||||
if (geometry.type === "Polygon") return pointInRing(point, geometry.coordinates[0] as number[][]);
|
{ key: "browse", label: "Select Region" },
|
||||||
return (geometry.coordinates as number[][][][]).some((poly) => pointInRing(point, poly[0] as number[][]));
|
{ key: "confirm", label: "Confirm" },
|
||||||
}
|
{ key: "ingest", label: "Processing" },
|
||||||
|
];
|
||||||
function bboxArea(geometry: GeofabrikFeature["geometry"]): number {
|
const idx = steps.findIndex((s) => s.key === current);
|
||||||
if (!geometry) return Infinity;
|
return (
|
||||||
const coords: number[][] =
|
<nav className="flex items-center gap-4 mb-8">
|
||||||
geometry.type === "Polygon"
|
{steps.map((s, i) => (
|
||||||
? (geometry.coordinates as number[][][]).flat()
|
<div key={s.key} className="flex items-center gap-2">
|
||||||
: (geometry.coordinates as number[][][][]).flat(2);
|
<span
|
||||||
const lngs = coords.map((c) => c[0]);
|
className={`w-6 h-6 rounded-full text-xs flex items-center justify-center font-medium ${
|
||||||
const lats = coords.map((c) => c[1]);
|
i < idx
|
||||||
return (Math.max(...lngs) - Math.min(...lngs)) * (Math.max(...lats) - Math.min(...lats));
|
? "bg-brand-600 text-white"
|
||||||
}
|
: i === idx
|
||||||
|
? "bg-brand-600 text-white ring-2 ring-brand-300"
|
||||||
function findBestRegion(lat: number, lon: number, index: GeofabrikIndex): GeofabrikFeature | null {
|
: "bg-gray-200 text-gray-500"
|
||||||
const point: [number, number] = [lon, lat];
|
}`}
|
||||||
const candidates = index.features.filter(
|
>
|
||||||
(f) => f.properties.urls?.pbf && f.geometry && containsPoint(f.geometry, point),
|
{i < idx ? "✓" : i + 1}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${i === idx ? "text-gray-900" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
{i < steps.length - 1 && (
|
||||||
|
<span className="text-gray-300 mx-2">→</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
);
|
);
|
||||||
if (candidates.length === 0) return null;
|
|
||||||
candidates.sort((a, b) => bboxArea(a.geometry) - bboxArea(b.geometry));
|
|
||||||
return candidates[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Nominatim types + helpers ────────────────────────────────────────────────
|
// ─── Geofabrik browser ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function GeofabrikBrowser({
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
onSelect: (f: GeofabrikFeature) => void;
|
||||||
|
}) {
|
||||||
|
const [index, setIndex] = useState<GeofabrikIndex | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [parent, setParent] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/admin/geofabrik")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setIndex)
|
||||||
|
.catch((e) => setError(String(e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const features = useMemo(() => {
|
||||||
|
if (!index) return [];
|
||||||
|
return index.features.filter((f) => {
|
||||||
|
const matchesParent = parent
|
||||||
|
? f.properties.parent === parent
|
||||||
|
: !f.properties.parent;
|
||||||
|
const matchesQuery = query
|
||||||
|
? f.properties.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
f.properties.id.toLowerCase().includes(query.toLowerCase())
|
||||||
|
: true;
|
||||||
|
return matchesParent && matchesQuery && f.properties.urls?.pbf;
|
||||||
|
});
|
||||||
|
}, [index, parent, query]);
|
||||||
|
|
||||||
|
const parentFeature = index?.features.find(
|
||||||
|
(f) => f.properties.id === parent,
|
||||||
|
);
|
||||||
|
const grandParent = parentFeature
|
||||||
|
? index?.features.find(
|
||||||
|
(f) => f.properties.id === parentFeature.properties.parent,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (loading)
|
||||||
|
return (
|
||||||
|
<div className="card py-12 text-center text-gray-500">
|
||||||
|
Loading Geofabrik region index…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<div className="card py-8 text-center text-red-600">
|
||||||
|
Error loading index: {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Select a Region</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm mb-4 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setParent(undefined)}
|
||||||
|
className="text-brand-600 hover:underline"
|
||||||
|
>
|
||||||
|
All regions
|
||||||
|
</button>
|
||||||
|
{grandParent && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-400">›</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setParent(grandParent.properties.id)}
|
||||||
|
className="text-brand-600 hover:underline"
|
||||||
|
>
|
||||||
|
{grandParent.properties.name}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{parentFeature && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-400">›</span>
|
||||||
|
<span className="font-medium">{parentFeature.properties.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search regions…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm mb-4 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="divide-y divide-gray-100 max-h-80 overflow-y-auto rounded border border-gray-200">
|
||||||
|
{features.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 py-4 px-3">No regions found.</p>
|
||||||
|
) : (
|
||||||
|
features.map((f) => {
|
||||||
|
const hasChildren = index!.features.some(
|
||||||
|
(c) => c.properties.parent === f.properties.id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={f.properties.id}
|
||||||
|
className="flex items-center justify-between px-3 py-2.5 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{f.properties.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">{f.properties.id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{hasChildren && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setParent(f.properties.id);
|
||||||
|
setQuery("");
|
||||||
|
}}
|
||||||
|
className="btn-secondary text-xs py-1"
|
||||||
|
>
|
||||||
|
Browse →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(f)}
|
||||||
|
className="btn-primary text-xs py-1"
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Nominatim polygon geocoder + mini map ───────────────────────────────────
|
||||||
|
|
||||||
interface NominatimResult {
|
interface NominatimResult {
|
||||||
place_id: number;
|
place_id: number;
|
||||||
|
|
@ -64,25 +206,6 @@ interface NominatimResult {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubPolygon {
|
|
||||||
id: number;
|
|
||||||
coordinates: number[][][];
|
|
||||||
selected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildActiveBoundary(polys: SubPolygon[]): { type: string; coordinates: unknown } | null {
|
|
||||||
const active = polys.filter((p) => p.selected);
|
|
||||||
if (active.length === 0) return null;
|
|
||||||
if (active.length === 1) return { type: "Polygon", coordinates: active[0].coordinates };
|
|
||||||
return { type: "MultiPolygon", coordinates: active.map((p) => p.coordinates) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const COUNTRIES = [
|
|
||||||
{ code: "DE", label: "🇩🇪 Germany" },
|
|
||||||
{ code: "NL", label: "🇳🇱 Netherlands" },
|
|
||||||
{ code: "DK", label: "🇩🇰 Denmark" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function toSlug(name: string): string {
|
function toSlug(name: string): string {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
@ -92,119 +215,52 @@ function toSlug(name: string): string {
|
||||||
.replace(/^-|-$/g, "");
|
.replace(/^-|-$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Nominatim geocoder + mini map ────────────────────────────────────────────
|
|
||||||
|
|
||||||
function LocationSelector({
|
function LocationSelector({
|
||||||
|
regionGeometry,
|
||||||
onBoundaryChange,
|
onBoundaryChange,
|
||||||
onResultSelect,
|
onResultSelect,
|
||||||
}: {
|
}: {
|
||||||
|
regionGeometry: GeofabrikFeature["geometry"];
|
||||||
onBoundaryChange: (boundary: { type: string; coordinates: unknown } | null) => void;
|
onBoundaryChange: (boundary: { type: string; coordinates: unknown } | null) => void;
|
||||||
onResultSelect?: (result: { name: string; countryCode: string; lat: number; lon: number } | null) => void;
|
onResultSelect?: (result: { name: string; countryCode: string } | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<NominatimResult[]>([]);
|
const [results, setResults] = useState<NominatimResult[]>([]);
|
||||||
const [selected, setSelected] = useState<NominatimResult | null>(null);
|
const [selected, setSelected] = useState<NominatimResult | null>(null);
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [subPolygons, setSubPolygons] = useState<SubPolygon[]>([]);
|
|
||||||
const subPolygonsRef = useRef<SubPolygon[]>([]);
|
|
||||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<import("maplibre-gl").Map | null>(null);
|
const mapRef = useRef<import("maplibre-gl").Map | null>(null);
|
||||||
const mapReadyRef = useRef(false);
|
const mapReadyRef = useRef(false);
|
||||||
|
|
||||||
const onBoundaryChangeRef = useRef(onBoundaryChange);
|
const onBoundaryChangeRef = useRef(onBoundaryChange);
|
||||||
onBoundaryChangeRef.current = onBoundaryChange;
|
onBoundaryChangeRef.current = onBoundaryChange;
|
||||||
const onResultSelectRef = useRef(onResultSelect);
|
const onResultSelectRef = useRef(onResultSelect);
|
||||||
onResultSelectRef.current = onResultSelect;
|
onResultSelectRef.current = onResultSelect;
|
||||||
|
|
||||||
// Keep ref in sync for map click handler.
|
// Notify parent when selection changes.
|
||||||
useEffect(() => { subPolygonsRef.current = subPolygons; }, [subPolygons]);
|
|
||||||
|
|
||||||
// When the Nominatim result changes: extract sub-polygons + notify parent + fit map.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected) {
|
onBoundaryChangeRef.current(selected?.geojson ?? null);
|
||||||
setSubPolygons([]);
|
if (selected) {
|
||||||
|
const name =
|
||||||
|
selected.address?.city ??
|
||||||
|
selected.address?.town ??
|
||||||
|
selected.address?.village ??
|
||||||
|
selected.address?.municipality ??
|
||||||
|
selected.display_name.split(",")[0].trim();
|
||||||
|
const countryCode = (selected.address?.country_code ?? "").toUpperCase();
|
||||||
|
onResultSelectRef.current?.({ name, countryCode });
|
||||||
|
} else {
|
||||||
onResultSelectRef.current?.(null);
|
onResultSelectRef.current?.(null);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name =
|
|
||||||
selected.address?.city ??
|
|
||||||
selected.address?.town ??
|
|
||||||
selected.address?.village ??
|
|
||||||
selected.address?.municipality ??
|
|
||||||
selected.display_name.split(",")[0].trim();
|
|
||||||
onResultSelectRef.current?.({
|
|
||||||
name,
|
|
||||||
countryCode: (selected.address?.country_code ?? "").toUpperCase(),
|
|
||||||
lat: parseFloat(selected.lat),
|
|
||||||
lon: parseFloat(selected.lon),
|
|
||||||
});
|
|
||||||
|
|
||||||
const geojson = selected.geojson;
|
|
||||||
if (!geojson) { setSubPolygons([]); return; }
|
|
||||||
|
|
||||||
const polys: SubPolygon[] =
|
|
||||||
geojson.type === "Polygon"
|
|
||||||
? [{ id: 0, coordinates: geojson.coordinates as number[][][], selected: true }]
|
|
||||||
: (geojson.coordinates as number[][][][]).map((coords, i) => ({
|
|
||||||
id: i, coordinates: coords, selected: true,
|
|
||||||
}));
|
|
||||||
setSubPolygons(polys);
|
|
||||||
|
|
||||||
// Fit map to full extent of all sub-polygons.
|
|
||||||
const map = mapRef.current;
|
|
||||||
if (map && mapReadyRef.current) {
|
|
||||||
const allCoords = polys.flatMap((sp) => sp.coordinates.flat()) as number[][];
|
|
||||||
const lngs = allCoords.map((c) => c[0]);
|
|
||||||
const lats = allCoords.map((c) => c[1]);
|
|
||||||
map.fitBounds(
|
|
||||||
[Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)],
|
|
||||||
{ padding: 40, duration: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
|
|
||||||
// Whenever sub-polygons change: update map sources + propagate active boundary.
|
// Debounced Nominatim search — request polygon_geojson + featuretype=settlement
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onBoundaryChangeRef.current(buildActiveBoundary(subPolygons));
|
if (query.length < 2) {
|
||||||
|
setResults([]);
|
||||||
const map = mapRef.current;
|
return;
|
||||||
if (!map || !mapReadyRef.current) return;
|
|
||||||
|
|
||||||
// Boundary source — one feature per sub-polygon with `selected` property for styling.
|
|
||||||
const features = subPolygons.map((sp) => ({
|
|
||||||
type: "Feature" as const,
|
|
||||||
id: sp.id,
|
|
||||||
properties: { id: sp.id, selected: sp.selected },
|
|
||||||
geometry: { type: "Polygon" as const, coordinates: sp.coordinates },
|
|
||||||
}));
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(map.getSource("area-boundary") as any)?.setData({ type: "FeatureCollection", features });
|
|
||||||
|
|
||||||
// Bbox from selected sub-polygons only.
|
|
||||||
const active = subPolygons.filter((sp) => sp.selected);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const bboxSrc = map.getSource("area-bbox") as any;
|
|
||||||
if (active.length > 0) {
|
|
||||||
const allCoords = active.flatMap((sp) => sp.coordinates.flat()) as number[][];
|
|
||||||
const lngs = allCoords.map((c) => c[0]);
|
|
||||||
const lats = allCoords.map((c) => c[1]);
|
|
||||||
const [minLng, minLat, maxLng, maxLat] = [
|
|
||||||
Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats),
|
|
||||||
];
|
|
||||||
bboxSrc?.setData({
|
|
||||||
type: "Feature",
|
|
||||||
geometry: { type: "Polygon", coordinates: [[[minLng, minLat], [maxLng, minLat], [maxLng, maxLat], [minLng, maxLat], [minLng, minLat]]] },
|
|
||||||
properties: {},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
bboxSrc?.setData({ type: "FeatureCollection", features: [] });
|
|
||||||
}
|
}
|
||||||
}, [subPolygons]);
|
|
||||||
|
|
||||||
// Debounced Nominatim search.
|
|
||||||
useEffect(() => {
|
|
||||||
if (query.length < 2) { setResults([]); return; }
|
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -213,8 +269,10 @@ function LocationSelector({
|
||||||
{ headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } },
|
{ headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } },
|
||||||
);
|
);
|
||||||
const data: NominatimResult[] = await res.json();
|
const data: NominatimResult[] = await res.json();
|
||||||
|
// Keep polygon results at municipality level or finer (rank >= 10).
|
||||||
setResults(data.filter(
|
setResults(data.filter(
|
||||||
(r) => r.geojson?.type === "Polygon" || r.geojson?.type === "MultiPolygon",
|
(r) => r.place_rank >= 10 &&
|
||||||
|
(r.geojson?.type === "Polygon" || r.geojson?.type === "MultiPolygon"),
|
||||||
));
|
));
|
||||||
setShowDropdown(true);
|
setShowDropdown(true);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -226,15 +284,58 @@ function LocationSelector({
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
// Initialize mini map.
|
// Apply boundary polygon to mini map
|
||||||
|
const applyBoundaryToMap = useCallback(
|
||||||
|
(map: import("maplibre-gl").Map, geojson: NominatimResult["geojson"] | null) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const src = map.getSource("area-boundary") as any;
|
||||||
|
if (!src) return;
|
||||||
|
if (geojson) {
|
||||||
|
src.setData({ type: "Feature", geometry: geojson, properties: {} });
|
||||||
|
// Fit to boundary bbox — flatten all rings/polygons to get full extent
|
||||||
|
try {
|
||||||
|
const coords: number[][] =
|
||||||
|
geojson.type === "Polygon"
|
||||||
|
? (geojson.coordinates as number[][][]).flat()
|
||||||
|
: (geojson.coordinates as number[][][][]).flat(2);
|
||||||
|
const lngs = coords.map((c) => c[0]);
|
||||||
|
const lats = coords.map((c) => c[1]);
|
||||||
|
map.fitBounds(
|
||||||
|
[Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)],
|
||||||
|
{ padding: 40, duration: 500 },
|
||||||
|
);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
src.setData({ type: "FeatureCollection", features: [] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update mini map when selection changes
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (map && mapReadyRef.current) applyBoundaryToMap(map, selected?.geojson ?? null);
|
||||||
|
}, [selected, applyBoundaryToMap]);
|
||||||
|
|
||||||
|
// Initialize mini map
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapContainerRef.current) return;
|
if (!mapContainerRef.current) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const mgl = await import("maplibre-gl");
|
const mgl = await import("maplibre-gl");
|
||||||
const { Protocol } = await import("pmtiles");
|
const { Protocol } = await import("pmtiles");
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
try { mgl.addProtocol("pmtiles", new Protocol().tile); } catch { /* already registered */ }
|
|
||||||
|
// Register pmtiles protocol (idempotent)
|
||||||
|
try {
|
||||||
|
const p = new Protocol();
|
||||||
|
mgl.addProtocol("pmtiles", p.tile);
|
||||||
|
} catch {
|
||||||
|
// Already registered
|
||||||
|
}
|
||||||
|
|
||||||
const map = new mgl.Map({
|
const map = new mgl.Map({
|
||||||
container: mapContainerRef.current!,
|
container: mapContainerRef.current!,
|
||||||
|
|
@ -248,54 +349,62 @@ function LocationSelector({
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
mapReadyRef.current = true;
|
mapReadyRef.current = true;
|
||||||
|
|
||||||
// Boundary — data-driven colour based on `selected` property.
|
// Region outline (Geofabrik polygon)
|
||||||
map.addSource("area-boundary", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
|
map.addSource("area-region", {
|
||||||
map.addLayer({
|
type: "geojson",
|
||||||
id: "area-boundary-fill", type: "fill", source: "area-boundary",
|
data: regionGeometry
|
||||||
paint: {
|
? { type: "Feature", geometry: regionGeometry, properties: {} }
|
||||||
"fill-color": ["case", ["==", ["get", "selected"], true], "#2563eb", "#94a3b8"],
|
: { type: "FeatureCollection", features: [] },
|
||||||
"fill-opacity": ["case", ["==", ["get", "selected"], true], 0.15, 0.05],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: "area-boundary-line", type: "line", source: "area-boundary",
|
id: "area-region-fill",
|
||||||
paint: {
|
type: "fill",
|
||||||
"line-color": ["case", ["==", ["get", "selected"], true], "#2563eb", "#94a3b8"],
|
source: "area-region",
|
||||||
"line-width": ["case", ["==", ["get", "selected"], true], 2, 0.75],
|
paint: { "fill-color": "#64748b", "fill-opacity": 0.1 },
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bbox rectangle.
|
|
||||||
map.addSource("area-bbox", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
|
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: "area-bbox-line", type: "line", source: "area-bbox",
|
id: "area-region-line",
|
||||||
paint: { "line-color": "#f59e0b", "line-width": 1.5, "line-dasharray": [4, 3] },
|
type: "line",
|
||||||
|
source: "area-region",
|
||||||
|
paint: { "line-color": "#64748b", "line-width": 1.5, "line-dasharray": [3, 2] },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click a sub-polygon to toggle it.
|
// Selected city boundary polygon
|
||||||
map.on("click", "area-boundary-fill", (e) => {
|
map.addSource("area-boundary", {
|
||||||
const id = e.features?.[0]?.properties?.id as number | undefined;
|
type: "geojson",
|
||||||
if (id == null) return;
|
data: { type: "FeatureCollection", features: [] },
|
||||||
setSubPolygons((prev) =>
|
});
|
||||||
prev.map((sp) => sp.id === id ? { ...sp, selected: !sp.selected } : sp),
|
map.addLayer({
|
||||||
);
|
id: "area-boundary-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "area-boundary",
|
||||||
|
paint: { "fill-color": "#2563eb", "fill-opacity": 0.15 },
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: "area-boundary-line",
|
||||||
|
type: "line",
|
||||||
|
source: "area-boundary",
|
||||||
|
paint: { "line-color": "#2563eb", "line-width": 2 },
|
||||||
});
|
});
|
||||||
map.on("mouseenter", "area-boundary-fill", () => { map.getCanvas().style.cursor = "pointer"; });
|
|
||||||
map.on("mouseleave", "area-boundary-fill", () => { map.getCanvas().style.cursor = ""; });
|
|
||||||
|
|
||||||
// Apply pending sub-polygons if the map loaded after a selection was made.
|
// Fit to region if available
|
||||||
const pending = subPolygonsRef.current;
|
if (regionGeometry) {
|
||||||
if (pending.length > 0) {
|
try {
|
||||||
const features = pending.map((sp) => ({
|
const coords: number[][] =
|
||||||
type: "Feature" as const, id: sp.id,
|
regionGeometry.type === "Polygon"
|
||||||
properties: { id: sp.id, selected: sp.selected },
|
? regionGeometry.coordinates.flat()
|
||||||
geometry: { type: "Polygon" as const, coordinates: sp.coordinates },
|
: regionGeometry.coordinates.flat(2);
|
||||||
}));
|
const lngs = coords.map((c) => c[0]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const lats = coords.map((c) => c[1]);
|
||||||
(map.getSource("area-boundary") as any)?.setData({ type: "FeatureCollection", features });
|
map.fitBounds(
|
||||||
|
[Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)],
|
||||||
|
{ padding: 20 },
|
||||||
|
);
|
||||||
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
mapReadyRef.current = false;
|
mapReadyRef.current = false;
|
||||||
|
|
@ -304,10 +413,9 @@ function LocationSelector({
|
||||||
};
|
};
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const selectedCount = subPolygons.filter((sp) => sp.selected).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* Geocoder */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<input
|
<input
|
||||||
|
|
@ -316,24 +424,35 @@ function LocationSelector({
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
if (!e.target.value) { setSelected(null); setResults([]); }
|
if (!e.target.value) { setSelected(null); setResults([]); onResultSelectRef.current?.(null); }
|
||||||
}}
|
}}
|
||||||
onFocus={() => results.length > 0 && setShowDropdown(true)}
|
onFocus={() => results.length > 0 && setShowDropdown(true)}
|
||||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
{searching && <span className="text-xs text-gray-400 whitespace-nowrap">Searching…</span>}
|
{searching && (
|
||||||
|
<span className="text-xs text-gray-400 whitespace-nowrap">Searching…</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showDropdown && results.length > 0 && (
|
{showDropdown && results.length > 0 && (
|
||||||
<div className="absolute z-20 left-0 right-0 top-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-56 overflow-y-auto">
|
<div className="absolute z-20 left-0 right-0 top-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-56 overflow-y-auto">
|
||||||
{results.map((r) => (
|
{results.map((r) => (
|
||||||
<button
|
<button
|
||||||
key={r.place_id}
|
key={r.place_id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setSelected(r); setQuery(r.display_name.split(",").slice(0, 3).join(", ")); setShowDropdown(false); }}
|
onClick={() => {
|
||||||
|
setSelected(r);
|
||||||
|
setQuery(r.display_name.split(",").slice(0, 3).join(", "));
|
||||||
|
setShowDropdown(false);
|
||||||
|
}}
|
||||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 border-b border-gray-100 last:border-0"
|
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 border-b border-gray-100 last:border-0"
|
||||||
>
|
>
|
||||||
<span className="font-medium text-gray-800">{r.display_name.split(",")[0]}</span>
|
<span className="font-medium text-gray-800">
|
||||||
<span className="text-gray-400 ml-1 text-xs">{r.display_name.split(",").slice(1, 3).join(",")}</span>
|
{r.display_name.split(",")[0]}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 ml-1 text-xs">
|
||||||
|
{r.display_name.split(",").slice(1, 3).join(",")}
|
||||||
|
</span>
|
||||||
<span className="ml-2 text-xs text-blue-500">{r.geojson?.type}</span>
|
<span className="ml-2 text-xs text-blue-500">{r.geojson?.type}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -341,28 +460,19 @@ function LocationSelector({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={mapContainerRef} className="w-full rounded-md border border-gray-200 overflow-hidden" style={{ height: 220 }} />
|
{/* Mini map */}
|
||||||
|
<div
|
||||||
|
ref={mapContainerRef}
|
||||||
|
className="w-full rounded-md border border-gray-200 overflow-hidden"
|
||||||
|
style={{ height: 220 }}
|
||||||
|
/>
|
||||||
|
|
||||||
{subPolygons.length > 1 ? (
|
{selected?.geojson && (
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{selectedCount} of {subPolygons.length} parts selected
|
|
||||||
{selectedCount < subPolygons.length && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSubPolygons((prev) => prev.map((sp) => ({ ...sp, selected: true })))}
|
|
||||||
className="ml-2 text-brand-600 hover:underline"
|
|
||||||
>
|
|
||||||
select all
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{" · "}
|
|
||||||
<span className="text-gray-400">click on the map to toggle</span>
|
|
||||||
</p>
|
|
||||||
) : selected?.geojson ? (
|
|
||||||
<p className="text-xs text-green-700">
|
<p className="text-xs text-green-700">
|
||||||
✓ {selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type})
|
✓ Boundary: {selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type})
|
||||||
</p>
|
</p>
|
||||||
) : (
|
)}
|
||||||
|
{!selected && (
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
Search for a city to set its administrative boundary and auto-fill the fields below.
|
Search for a city to set its administrative boundary and auto-fill the fields below.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -371,27 +481,28 @@ function LocationSelector({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
// ─── Confirm step ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AddCityPage() {
|
function ConfirmStep({
|
||||||
const [name, setName] = useState("");
|
region,
|
||||||
const [slug, setSlug] = useState("");
|
onBack,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
region: GeofabrikFeature;
|
||||||
|
onBack: () => void;
|
||||||
|
onConfirm: (
|
||||||
|
slug: string,
|
||||||
|
name: string,
|
||||||
|
countryCode: string,
|
||||||
|
boundary: { type: string; coordinates: unknown } | null,
|
||||||
|
) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const defaultSlug = region.properties.id.replace(/\//g, "-");
|
||||||
|
const [slug, setSlug] = useState(defaultSlug);
|
||||||
|
const [name, setName] = useState(region.properties.name);
|
||||||
const [countryCode, setCountryCode] = useState("");
|
const [countryCode, setCountryCode] = useState("");
|
||||||
const [boundary, setBoundary] = useState<{ type: string; coordinates: unknown } | null>(null);
|
const [boundary, setBoundary] = useState<{ type: string; coordinates: unknown } | null>(null);
|
||||||
const [matchedRegion, setMatchedRegion] = useState<GeofabrikFeature | null>(null);
|
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const geofabrikIndexRef = useRef<GeofabrikIndex | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/admin/geofabrik")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: GeofabrikIndex) => { geofabrikIndexRef.current = data; })
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const geofabrikUrl = matchedRegion?.properties.urls?.pbf ?? undefined;
|
|
||||||
|
|
||||||
const handleBoundaryChange = useCallback(
|
const handleBoundaryChange = useCallback(
|
||||||
(b: { type: string; coordinates: unknown } | null) => setBoundary(b),
|
(b: { type: string; coordinates: unknown } | null) => setBoundary(b),
|
||||||
|
|
@ -399,82 +510,41 @@ export default function AddCityPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleResultSelect = useCallback(
|
const handleResultSelect = useCallback(
|
||||||
(result: { name: string; countryCode: string; lat: number; lon: number } | null) => {
|
(result: { name: string; countryCode: string } | null) => {
|
||||||
if (!result) { setMatchedRegion(null); return; }
|
if (!result) return;
|
||||||
setName(result.name);
|
setName(result.name);
|
||||||
setSlug(toSlug(result.name));
|
setSlug(toSlug(result.name));
|
||||||
setCountryCode(COUNTRIES.some((c) => c.code === result.countryCode) ? result.countryCode : "");
|
setCountryCode(result.countryCode);
|
||||||
setMatchedRegion(
|
|
||||||
geofabrikIndexRef.current
|
|
||||||
? findBestRegion(result.lat, result.lon, geofabrikIndexRef.current)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!slug || !name || !geofabrikUrl) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/admin/cities", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
slug, name, countryCode, geofabrikUrl,
|
|
||||||
...(boundary ? { boundary } : {}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setError(data.error ?? "Failed to start ingestion");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
setJobId(data.jobId);
|
|
||||||
} catch (e) {
|
|
||||||
setError(String(e));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (jobId) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl mx-auto">
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<a href="/admin" className="text-sm text-gray-500 hover:text-gray-700">← Back</a>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Adding {name}…</h1>
|
|
||||||
</div>
|
|
||||||
<CityIngestProgress jobId={jobId} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="card max-w-2xl">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Add City</h1>
|
<h2 className="text-lg font-semibold mb-4">Confirm City Details</h2>
|
||||||
<p className="text-sm text-gray-500 mb-6">Search for a city to configure and start ingestion.</p>
|
|
||||||
|
|
||||||
{error && <div className="mb-4 p-3 bg-red-50 rounded text-sm text-red-700">{error}</div>}
|
<div className="space-y-4">
|
||||||
|
{/* City boundary selector — at the top so it can auto-fill the fields below */}
|
||||||
<div className="card space-y-5">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
City Boundary{" "}
|
City Boundary{" "}
|
||||||
<span className="text-gray-400 font-normal">(optional — auto-fills fields below)</span>
|
<span className="text-gray-400 font-normal">(optional — auto-fills name, slug & country)</span>
|
||||||
</label>
|
</label>
|
||||||
<LocationSelector onBoundaryChange={handleBoundaryChange} onResultSelect={handleResultSelect} />
|
<LocationSelector
|
||||||
|
regionGeometry={region.geometry}
|
||||||
|
onBoundaryChange={handleBoundaryChange}
|
||||||
|
onResultSelect={handleResultSelect}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Display Name</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Display Name
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Hamburg"
|
|
||||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -484,52 +554,130 @@ export default function AddCityPage() {
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={slug}
|
value={slug}
|
||||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))}
|
onChange={(e) =>
|
||||||
placeholder="hamburg"
|
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))
|
||||||
|
}
|
||||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm font-mono focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm font-mono focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="w-24">
|
||||||
<div>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Country</label>
|
Country Code <span className="text-gray-400">(2-letter)</span>
|
||||||
<select
|
</label>
|
||||||
value={countryCode}
|
<input
|
||||||
onChange={(e) => setCountryCode(e.target.value)}
|
value={countryCode}
|
||||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
maxLength={2}
|
||||||
>
|
onChange={(e) => setCountryCode(e.target.value.toUpperCase())}
|
||||||
<option value="">— no transit —</option>
|
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
{COUNTRIES.map((c) => (
|
placeholder="DE"
|
||||||
<option key={c.code} value={c.code}>{c.label}</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{!countryCode && (
|
|
||||||
<p className="mt-1 text-xs text-amber-600">No transit scoring without a country.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">OSM Source</label>
|
|
||||||
<div className={`rounded-md border px-3 py-2 text-xs break-all leading-relaxed ${
|
|
||||||
geofabrikUrl ? "border-green-200 bg-green-50 text-green-800" : "border-gray-200 bg-gray-50 text-gray-400"
|
|
||||||
}`}>
|
|
||||||
{geofabrikUrl
|
|
||||||
? geofabrikUrl.replace("https://download.geofabrik.de/", "")
|
|
||||||
: "Select a city above"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-1">
|
<div className="bg-gray-50 rounded-md p-3">
|
||||||
<button
|
<p className="text-xs text-gray-500 font-medium mb-1">Source URL</p>
|
||||||
onClick={handleSubmit}
|
<p className="text-xs text-gray-700 break-all">
|
||||||
disabled={loading || !slug || !name || !geofabrikUrl}
|
{region.properties.urls.pbf}
|
||||||
className="btn-primary"
|
</p>
|
||||||
>
|
|
||||||
{loading ? "Starting…" : "Start Ingestion"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button onClick={onBack} className="btn-secondary">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await onConfirm(slug, name, countryCode, boundary);
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
|
disabled={loading || !slug || !name}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{loading ? "Starting…" : "Start Ingestion"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Progress step ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function AddCityPage() {
|
||||||
|
const [step, setStep] = useState<Step>("browse");
|
||||||
|
const [selected, setSelected] = useState<GeofabrikFeature | null>(null);
|
||||||
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
|
const [ingestError, setIngestError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleConfirm = async (
|
||||||
|
slug: string,
|
||||||
|
name: string,
|
||||||
|
countryCode: string,
|
||||||
|
boundary: { type: string; coordinates: unknown } | null,
|
||||||
|
) => {
|
||||||
|
setIngestError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/cities", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
countryCode,
|
||||||
|
geofabrikUrl: selected!.properties.urls.pbf,
|
||||||
|
...(boundary ? { boundary } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setIngestError(data.error ?? "Failed to start ingestion");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setJobId(data.jobId);
|
||||||
|
setStep("ingest");
|
||||||
|
} catch (e) {
|
||||||
|
setIngestError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Add City</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Select an OpenStreetMap region to import for 15-minute city analysis.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<StepIndicator current={step} />
|
||||||
|
|
||||||
|
{ingestError && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 rounded text-sm text-red-700">
|
||||||
|
{ingestError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "browse" && (
|
||||||
|
<GeofabrikBrowser
|
||||||
|
onSelect={(f) => {
|
||||||
|
setSelected(f);
|
||||||
|
setStep("confirm");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === "confirm" && selected && (
|
||||||
|
<ConfirmStep
|
||||||
|
region={selected}
|
||||||
|
onBack={() => setStep("browse")}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === "ingest" && <CityIngestProgress jobId={jobId} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense, useState } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
function LoginForm() {
|
function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const from = params.get("from") ?? "/admin";
|
const from = params.get("from") ?? "/admin";
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ function LoginForm() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
window.location.href = from;
|
router.push(from);
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setError(data.error ?? "Login failed");
|
setError(data.error ?? "Login failed");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue