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

683 lines
23 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 };
address?: {
city?: string;
town?: string;
village?: string;
municipality?: string;
country_code?: string;
};
}
function toSlug(name: string): string {
return name
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
function LocationSelector({
regionGeometry,
onBoundaryChange,
onResultSelect,
}: {
regionGeometry: GeofabrikFeature["geometry"];
onBoundaryChange: (boundary: { type: string; coordinates: unknown } | null) => void;
onResultSelect?: (result: { name: string; countryCode: string } | 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;
const onResultSelectRef = useRef(onResultSelect);
onResultSelectRef.current = onResultSelect;
// Notify parent when selection changes.
useEffect(() => {
onBoundaryChangeRef.current(selected?.geojson ?? null);
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);
}
}, [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&addressdetails=1&format=json&limit=8`,
{ headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } },
);
const data: NominatimResult[] = await res.json();
// Keep polygon results at municipality level or finer (rank >= 10).
setResults(data.filter(
(r) => r.place_rank >= 10 &&
(r.geojson?.type === "Polygon" || r.geojson?.type === "MultiPolygon"),
));
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 — 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(() => {
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.flat()
: regionGeometry.coordinates.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: 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([]); onResultSelectRef.current?.(null); }
}}
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 set its administrative boundary and auto-fill the fields below.
</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),
[],
);
const handleResultSelect = useCallback(
(result: { name: string; countryCode: string } | null) => {
if (!result) return;
setName(result.name);
setSlug(toSlug(result.name));
setCountryCode(result.countryCode);
},
[],
);
return (
<div className="card max-w-2xl">
<h2 className="text-lg font-semibold mb-4">Confirm City Details</h2>
<div className="space-y-4">
{/* City boundary selector — at the top so it can auto-fill the fields below */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
City Boundary{" "}
<span className="text-gray-400 font-normal">(optional auto-fills name, slug &amp; country)</span>
</label>
<LocationSelector
regionGeometry={region.geometry}
onBoundaryChange={handleBoundaryChange}
onResultSelect={handleResultSelect}
/>
</div>
<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>
<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>
);
}