fifteen/apps/web/app/admin/cities/new/page.tsx

648 lines
22 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 { CityIngestProgress } from "@/components/city-ingest-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 polygon geocoder + mini map ───────────────────────────────────
interface NominatimResult {
place_id: number;
display_name: string;
lat: string;
lon: string;
type: string;
class: string;
place_rank: number;
geojson?: { type: string; coordinates: unknown };
}
function LocationSelector({
regionGeometry,
onBoundaryChange,
}: {
regionGeometry: GeofabrikFeature["geometry"];
onBoundaryChange: (boundary: { type: string; coordinates: unknown } | null) => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<NominatimResult[]>([]);
const [selected, setSelected] = useState<NominatimResult | null>(null);
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 onBoundaryChangeRef = useRef(onBoundaryChange);
onBoundaryChangeRef.current = onBoundaryChange;
// Notify parent when selection changes
useEffect(() => {
onBoundaryChangeRef.current(selected?.geojson ?? null);
}, [selected]);
// Debounced Nominatim search — request polygon_geojson + featuretype=settlement
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)}&polygon_geojson=1&format=json&limit=8`,
{ headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } },
);
const data: NominatimResult[] = await res.json();
// Keep only results with a real polygon and place_rank >= 13.
// Landkreise/counties are rank 12; cities/towns/municipalities are 13+.
const polygons = data.filter(
(r) =>
r.place_rank >= 12 &&
(r.geojson?.type === "Polygon" || r.geojson?.type === "MultiPolygon"),
);
setResults(polygons);
setShowDropdown(true);
} catch {
setResults([]);
} finally {
setSearching(false);
}
}, 500);
return () => clearTimeout(timer);
}, [query]);
// 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
try {
const coords: number[][] =
geojson.type === "Polygon"
? (geojson.coordinates as number[][][])[0]
: (geojson.coordinates as number[][][][])[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: 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(() => {
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 city boundary polygon
map.addSource("area-boundary", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
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 },
});
// Fit to region if available
if (regionGeometry) {
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 */ }
}
});
})();
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 municipality…"
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>
<span className="ml-2 text-xs text-blue-500">{r.geojson?.type}</span>
</button>
))}
</div>
)}
</div>
{/* Mini map */}
<div
ref={mapContainerRef}
className="w-full rounded-md border border-gray-200 overflow-hidden"
style={{ height: 220 }}
/>
{selected?.geojson && (
<p className="text-xs text-green-700">
Boundary: {selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type})
</p>
)}
{!selected && (
<p className="text-xs text-gray-400">
Search for a city to use its administrative boundary for stats coverage, 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,
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 [boundary, setBoundary] = useState<{ type: string; coordinates: unknown } | null>(null);
const [loading, setLoading] = useState(false);
const handleBoundaryChange = useCallback(
(b: { type: string; coordinates: unknown } | null) => setBoundary(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>
{/* City boundary selector */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
City Boundary{" "}
<span className="text-gray-400 font-normal">(optional for accurate coverage stats)</span>
</label>
<p className="text-xs text-gray-500 mb-3">
Search for the city municipality to use its administrative boundary for
coverage statistics. The bounding box is still used for OSM import, routing,
and grid generation.
</p>
<LocationSelector
regionGeometry={region.geometry}
onBoundaryChange={handleBoundaryChange}
/>
</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, 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>
);
}