801 lines
26 KiB
TypeScript
801 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||
import type { GeofabrikFeature, GeofabrikIndex } from "@transportationer/shared";
|
||
import { useJobProgress } from "@/hooks/use-job-progress";
|
||
|
||
type Step = "browse" | "confirm" | "ingest";
|
||
|
||
// ─── Step indicator ───────────────────────────────────────────────────────────
|
||
|
||
function StepIndicator({ current }: { current: Step }) {
|
||
const steps: { key: Step; label: string }[] = [
|
||
{ key: "browse", label: "Select Region" },
|
||
{ key: "confirm", label: "Confirm" },
|
||
{ key: "ingest", label: "Processing" },
|
||
];
|
||
const idx = steps.findIndex((s) => s.key === current);
|
||
return (
|
||
<nav className="flex items-center gap-4 mb-8">
|
||
{steps.map((s, i) => (
|
||
<div key={s.key} className="flex items-center gap-2">
|
||
<span
|
||
className={`w-6 h-6 rounded-full text-xs flex items-center justify-center font-medium ${
|
||
i < idx
|
||
? "bg-brand-600 text-white"
|
||
: i === idx
|
||
? "bg-brand-600 text-white ring-2 ring-brand-300"
|
||
: "bg-gray-200 text-gray-500"
|
||
}`}
|
||
>
|
||
{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>
|
||
);
|
||
}
|
||
|
||
// ─── 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 geocoder + radius selector + mini map ─────────────────────────
|
||
|
||
interface NominatimResult {
|
||
place_id: number;
|
||
display_name: string;
|
||
lat: string;
|
||
lon: string;
|
||
type: string;
|
||
class: string;
|
||
}
|
||
|
||
const RADIUS_OPTIONS = [2, 5, 10, 15, 20, 30];
|
||
|
||
function computeBbox(
|
||
lat: number,
|
||
lng: number,
|
||
radiusKm: number,
|
||
): [number, number, number, number] {
|
||
const latDelta = radiusKm / 111.0;
|
||
const lngDelta = radiusKm / (111.0 * Math.cos((lat * Math.PI) / 180));
|
||
return [lng - lngDelta, lat - latDelta, lng + lngDelta, lat + latDelta];
|
||
}
|
||
|
||
function bboxToGeoJSON(bbox: [number, number, number, number]) {
|
||
const [w, s, e, n] = bbox;
|
||
return {
|
||
type: "Feature" as const,
|
||
geometry: {
|
||
type: "Polygon" as const,
|
||
coordinates: [
|
||
[
|
||
[w, s],
|
||
[e, s],
|
||
[e, n],
|
||
[w, n],
|
||
[w, s],
|
||
],
|
||
],
|
||
},
|
||
properties: {},
|
||
};
|
||
}
|
||
|
||
function LocationSelector({
|
||
regionGeometry,
|
||
onBboxChange,
|
||
}: {
|
||
regionGeometry: GeofabrikFeature["geometry"];
|
||
onBboxChange: (bbox: [number, number, number, number] | null) => void;
|
||
}) {
|
||
const [query, setQuery] = useState("");
|
||
const [results, setResults] = useState<NominatimResult[]>([]);
|
||
const [selected, setSelected] = useState<NominatimResult | null>(null);
|
||
const [radius, setRadius] = useState(10);
|
||
const [showDropdown, setShowDropdown] = useState(false);
|
||
const [searching, setSearching] = useState(false);
|
||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||
const mapRef = useRef<import("maplibre-gl").Map | null>(null);
|
||
const mapReadyRef = useRef(false);
|
||
|
||
const onBboxChangeRef = useRef(onBboxChange);
|
||
onBboxChangeRef.current = onBboxChange;
|
||
|
||
const bbox = useMemo((): [number, number, number, number] | null => {
|
||
if (!selected) return null;
|
||
return computeBbox(parseFloat(selected.lat), parseFloat(selected.lon), radius);
|
||
}, [selected, radius]);
|
||
|
||
// Notify parent when bbox changes
|
||
useEffect(() => {
|
||
onBboxChangeRef.current(bbox);
|
||
}, [bbox]);
|
||
|
||
// Debounced Nominatim search
|
||
useEffect(() => {
|
||
if (query.length < 2) {
|
||
setResults([]);
|
||
return;
|
||
}
|
||
const timer = setTimeout(async () => {
|
||
setSearching(true);
|
||
try {
|
||
const res = await fetch(
|
||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=6&addressdetails=0`,
|
||
{ headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } },
|
||
);
|
||
const data: NominatimResult[] = await res.json();
|
||
setResults(data);
|
||
setShowDropdown(true);
|
||
} catch {
|
||
setResults([]);
|
||
} finally {
|
||
setSearching(false);
|
||
}
|
||
}, 500);
|
||
return () => clearTimeout(timer);
|
||
}, [query]);
|
||
|
||
// Apply bbox to mini map
|
||
const applyBboxToMap = useCallback(
|
||
(map: import("maplibre-gl").Map, b: [number, number, number, number] | null) => {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const src = map.getSource("area-bbox") as any;
|
||
if (!src) return;
|
||
if (b) {
|
||
src.setData(bboxToGeoJSON(b));
|
||
map.fitBounds([b[0], b[1], b[2], b[3]], { padding: 40, duration: 500 });
|
||
} else {
|
||
src.setData({ type: "FeatureCollection", features: [] });
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
// Update mini map when bbox changes
|
||
useEffect(() => {
|
||
const map = mapRef.current;
|
||
if (map && mapReadyRef.current) applyBboxToMap(map, bbox);
|
||
}, [bbox, applyBboxToMap]);
|
||
|
||
// Initialize mini map
|
||
useEffect(() => {
|
||
if (!mapContainerRef.current) return;
|
||
let cancelled = false;
|
||
|
||
(async () => {
|
||
const mgl = await import("maplibre-gl");
|
||
const { Protocol } = await import("pmtiles");
|
||
|
||
if (cancelled) return;
|
||
|
||
// Register pmtiles protocol (idempotent)
|
||
try {
|
||
const p = new Protocol();
|
||
mgl.addProtocol("pmtiles", p.tile);
|
||
} catch {
|
||
// Already registered
|
||
}
|
||
|
||
const map = new mgl.Map({
|
||
container: mapContainerRef.current!,
|
||
style: "/tiles/style.json",
|
||
center: [10, 51],
|
||
zoom: 3,
|
||
});
|
||
mapRef.current = map;
|
||
|
||
map.on("load", () => {
|
||
if (cancelled) return;
|
||
mapReadyRef.current = true;
|
||
|
||
// Region outline (Geofabrik polygon)
|
||
map.addSource("area-region", {
|
||
type: "geojson",
|
||
data: regionGeometry
|
||
? { type: "Feature", geometry: regionGeometry, properties: {} }
|
||
: { type: "FeatureCollection", features: [] },
|
||
});
|
||
map.addLayer({
|
||
id: "area-region-fill",
|
||
type: "fill",
|
||
source: "area-region",
|
||
paint: { "fill-color": "#64748b", "fill-opacity": 0.1 },
|
||
});
|
||
map.addLayer({
|
||
id: "area-region-line",
|
||
type: "line",
|
||
source: "area-region",
|
||
paint: { "line-color": "#64748b", "line-width": 1.5, "line-dasharray": [3, 2] },
|
||
});
|
||
|
||
// Selected sub-area bbox
|
||
map.addSource("area-bbox", {
|
||
type: "geojson",
|
||
data: { type: "FeatureCollection", features: [] },
|
||
});
|
||
map.addLayer({
|
||
id: "area-bbox-fill",
|
||
type: "fill",
|
||
source: "area-bbox",
|
||
paint: { "fill-color": "#2563eb", "fill-opacity": 0.15 },
|
||
});
|
||
map.addLayer({
|
||
id: "area-bbox-line",
|
||
type: "line",
|
||
source: "area-bbox",
|
||
paint: { "line-color": "#2563eb", "line-width": 2 },
|
||
});
|
||
|
||
// Fit to region if available
|
||
if (regionGeometry) {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const src = map.getSource("area-region") as any;
|
||
if (src) {
|
||
// Fit map to the region outline
|
||
try {
|
||
const coords: number[][] =
|
||
regionGeometry.type === "Polygon"
|
||
? regionGeometry.coordinates[0]
|
||
: regionGeometry.coordinates[0][0];
|
||
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: 20 },
|
||
);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply bbox if already set (e.g. after component re-render)
|
||
if (bbox) applyBboxToMap(map, bbox);
|
||
});
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
mapReadyRef.current = false;
|
||
mapRef.current?.remove();
|
||
mapRef.current = null;
|
||
};
|
||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{/* Geocoder */}
|
||
<div className="relative">
|
||
<div className="flex gap-2 items-center">
|
||
<input
|
||
type="search"
|
||
placeholder="Search for a city or location…"
|
||
value={query}
|
||
onChange={(e) => {
|
||
setQuery(e.target.value);
|
||
if (!e.target.value) { setSelected(null); setResults([]); }
|
||
}}
|
||
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"
|
||
/>
|
||
{searching && (
|
||
<span className="text-xs text-gray-400 whitespace-nowrap">Searching…</span>
|
||
)}
|
||
</div>
|
||
|
||
{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">
|
||
{results.map((r) => (
|
||
<button
|
||
key={r.place_id}
|
||
type="button"
|
||
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"
|
||
>
|
||
<span className="font-medium text-gray-800">
|
||
{r.display_name.split(",")[0]}
|
||
</span>
|
||
<span className="text-gray-400 ml-1 text-xs">
|
||
{r.display_name.split(",").slice(1, 3).join(",")}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Radius selector */}
|
||
{selected && (
|
||
<div className="flex items-center gap-3">
|
||
<label className="text-sm text-gray-600 shrink-0">Radius:</label>
|
||
<div className="flex gap-1.5 flex-wrap">
|
||
{RADIUS_OPTIONS.map((r) => (
|
||
<button
|
||
key={r}
|
||
type="button"
|
||
onClick={() => setRadius(r)}
|
||
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
|
||
radius === r
|
||
? "bg-brand-600 text-white border-brand-600"
|
||
: "bg-white text-gray-600 border-gray-300 hover:border-brand-400"
|
||
}`}
|
||
>
|
||
{r} km
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Mini map */}
|
||
<div
|
||
ref={mapContainerRef}
|
||
className="w-full rounded-md border border-gray-200 overflow-hidden"
|
||
style={{ height: 220 }}
|
||
/>
|
||
|
||
{bbox && (
|
||
<p className="text-xs text-green-700">
|
||
✓ Sub-region: {radius} km around {selected!.display_name.split(",")[0]} — bbox [{bbox.map((v) => v.toFixed(4)).join(", ")}]
|
||
</p>
|
||
)}
|
||
{!selected && (
|
||
<p className="text-xs text-gray-400">
|
||
Search for a location to select a sub-region, or leave empty to use the entire dataset.
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Confirm step ─────────────────────────────────────────────────────────────
|
||
|
||
function ConfirmStep({
|
||
region,
|
||
onBack,
|
||
onConfirm,
|
||
}: {
|
||
region: GeofabrikFeature;
|
||
onBack: () => void;
|
||
onConfirm: (
|
||
slug: string,
|
||
name: string,
|
||
countryCode: string,
|
||
bbox: [number, number, number, number] | 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 [bbox, setBbox] = useState<[number, number, number, number] | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const handleBboxChange = useCallback(
|
||
(b: [number, number, number, number] | null) => setBbox(b),
|
||
[],
|
||
);
|
||
|
||
return (
|
||
<div className="card max-w-2xl">
|
||
<h2 className="text-lg font-semibold mb-4">Confirm City Details</h2>
|
||
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Display Name
|
||
</label>
|
||
<input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
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>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Slug <span className="text-gray-400">(URL identifier)</span>
|
||
</label>
|
||
<input
|
||
value={slug}
|
||
onChange={(e) =>
|
||
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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="w-24">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Country Code <span className="text-gray-400">(2-letter)</span>
|
||
</label>
|
||
<input
|
||
value={countryCode}
|
||
maxLength={2}
|
||
onChange={(e) => setCountryCode(e.target.value.toUpperCase())}
|
||
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"
|
||
placeholder="DE"
|
||
/>
|
||
</div>
|
||
|
||
{/* Sub-region selector */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Sub-region{" "}
|
||
<span className="text-gray-400 font-normal">(optional — clip to a city area)</span>
|
||
</label>
|
||
<p className="text-xs text-gray-500 mb-3">
|
||
Select a city center and radius to ingest only that sub-area of the dataset.
|
||
Recommended for large regions (e.g. entire states). Affects OSM import,
|
||
routing tiles, and grid analysis.
|
||
</p>
|
||
<LocationSelector
|
||
regionGeometry={region.geometry}
|
||
onBboxChange={handleBboxChange}
|
||
/>
|
||
</div>
|
||
|
||
<div className="bg-gray-50 rounded-md p-3">
|
||
<p className="text-xs text-gray-500 font-medium mb-1">Source URL</p>
|
||
<p className="text-xs text-gray-700 break-all">
|
||
{region.properties.urls.pbf}
|
||
</p>
|
||
</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, bbox);
|
||
setLoading(false);
|
||
}}
|
||
disabled={loading || !slug || !name}
|
||
className="btn-primary"
|
||
>
|
||
{loading ? "Starting…" : "Start Ingestion"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Progress step ────────────────────────────────────────────────────────────
|
||
|
||
function ProgressStep({ jobId }: { jobId: string | null }) {
|
||
const { stages, overall, error } = useJobProgress(jobId);
|
||
|
||
return (
|
||
<div className="card max-w-lg">
|
||
<h2 className="text-lg font-semibold mb-6">Processing City Data</h2>
|
||
|
||
<ol className="space-y-4">
|
||
{stages.map((stage) => (
|
||
<li key={stage.key} className="flex items-start gap-3">
|
||
<StageIcon status={stage.status} />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-900">{stage.label}</p>
|
||
{stage.status === "active" && (
|
||
<>
|
||
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1.5">
|
||
<div
|
||
className="bg-brand-600 h-1.5 rounded-full transition-all duration-500"
|
||
style={{ width: `${stage.pct}%` }}
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-gray-500 mt-1 truncate">
|
||
{stage.message}
|
||
</p>
|
||
</>
|
||
)}
|
||
{stage.status === "failed" && error && (
|
||
<p className="text-xs text-red-600 mt-1">{error}</p>
|
||
)}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
|
||
{overall === "completed" && (
|
||
<div className="mt-6 p-4 bg-green-50 rounded-lg text-green-800 text-sm">
|
||
✓ City ingestion complete!{" "}
|
||
<a href="/admin" className="underline font-medium">
|
||
Return to dashboard
|
||
</a>{" "}
|
||
or{" "}
|
||
<a href="/" className="underline font-medium">
|
||
view on map
|
||
</a>
|
||
.
|
||
</div>
|
||
)}
|
||
|
||
{overall === "failed" && (
|
||
<div className="mt-6 p-4 bg-red-50 rounded-lg text-red-800 text-sm">
|
||
✗ Ingestion failed: {error}.{" "}
|
||
<a href="/admin" className="underline">
|
||
Return to dashboard
|
||
</a>
|
||
.
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StageIcon({ status }: { status: StageStatus["status"] }) {
|
||
if (status === "completed")
|
||
return (
|
||
<span className="w-5 h-5 flex items-center justify-center rounded-full bg-green-100 text-green-600 text-xs mt-0.5 shrink-0">
|
||
✓
|
||
</span>
|
||
);
|
||
if (status === "failed")
|
||
return (
|
||
<span className="w-5 h-5 flex items-center justify-center rounded-full bg-red-100 text-red-600 text-xs mt-0.5 shrink-0">
|
||
✗
|
||
</span>
|
||
);
|
||
if (status === "active")
|
||
return (
|
||
<span className="w-5 h-5 flex items-center justify-center mt-0.5 shrink-0">
|
||
<svg
|
||
className="animate-spin w-4 h-4 text-brand-600"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
>
|
||
<circle
|
||
cx="12"
|
||
cy="12"
|
||
r="10"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
opacity="0.25"
|
||
/>
|
||
<path
|
||
d="M12 2a10 10 0 0 1 10 10"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
/>
|
||
</svg>
|
||
</span>
|
||
);
|
||
return (
|
||
<span className="w-5 h-5 rounded-full border-2 border-gray-300 mt-0.5 shrink-0" />
|
||
);
|
||
}
|
||
|
||
import type { StageStatus } from "@/hooks/use-job-progress";
|
||
|
||
// ─── 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,
|
||
bbox: [number, number, number, number] | 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,
|
||
...(bbox ? { bbox } : {}),
|
||
}),
|
||
});
|
||
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" && <ProgressStep jobId={jobId} />}
|
||
</div>
|
||
);
|
||
}
|