fifteen/apps/web/app/admin/cities/new/page.tsx
2026-03-01 21:58:53 +01:00

801 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}