151 lines
4.8 KiB
TypeScript
151 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import { useJobProgress } from "@/hooks/use-job-progress";
|
|
|
|
interface CityDetail {
|
|
slug: string;
|
|
name: string;
|
|
country_code: string;
|
|
geofabrik_url: string;
|
|
status: string;
|
|
last_ingested: string | null;
|
|
poi_count: number;
|
|
grid_count: number;
|
|
error_message: string | null;
|
|
}
|
|
|
|
export default function CityDetailPage() {
|
|
const { slug } = useParams<{ slug: string }>();
|
|
const router = useRouter();
|
|
const [city, setCity] = useState<CityDetail | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [jobId, setJobId] = useState<string | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
const { stages, overall } = useJobProgress(jobId);
|
|
|
|
useEffect(() => {
|
|
fetch(`/api/admin/cities`)
|
|
.then((r) => r.json())
|
|
.then((all: CityDetail[]) => {
|
|
setCity(all.find((c) => c.slug === slug) ?? null);
|
|
setLoading(false);
|
|
})
|
|
.catch(() => setLoading(false));
|
|
}, [slug]);
|
|
|
|
const handleReIngest = async () => {
|
|
const res = await fetch(`/api/admin/ingest/${slug}`, { method: "POST" });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setJobId(data.jobId);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!confirm(`Delete city "${city?.name}"? This will remove all POI and grid data.`)) return;
|
|
setDeleting(true);
|
|
const res = await fetch(`/api/admin/cities/${slug}`, { method: "DELETE" });
|
|
if (res.ok) router.push("/admin");
|
|
else setDeleting(false);
|
|
};
|
|
|
|
if (loading) return <div className="text-gray-500">Loading…</div>;
|
|
if (!city)
|
|
return (
|
|
<div className="text-gray-500">
|
|
City not found.{" "}
|
|
<a href="/admin" className="text-brand-600 underline">
|
|
Back
|
|
</a>
|
|
</div>
|
|
);
|
|
|
|
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">{city.name}</h1>
|
|
<span className="badge bg-gray-100 text-gray-600 text-xs">
|
|
{city.slug}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
|
{[
|
|
{ label: "POIs", value: city.poi_count.toLocaleString() },
|
|
{ label: "Grid Points", value: city.grid_count.toLocaleString() },
|
|
{
|
|
label: "Last Ingested",
|
|
value: city.last_ingested
|
|
? new Date(city.last_ingested).toLocaleDateString()
|
|
: "Never",
|
|
},
|
|
].map((s) => (
|
|
<div key={s.label} className="card text-center">
|
|
<p className="text-2xl font-bold text-gray-900">{s.value}</p>
|
|
<p className="text-sm text-gray-500 mt-1">{s.label}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Source */}
|
|
<div className="card mb-6">
|
|
<h2 className="text-sm font-medium text-gray-700 mb-2">Data Source</h2>
|
|
<p className="text-xs text-gray-500 break-all">{city.geofabrik_url}</p>
|
|
</div>
|
|
|
|
{/* Live progress if ingesting */}
|
|
{jobId && (
|
|
<div className="card mb-6">
|
|
<h2 className="text-sm font-semibold mb-4">Ingestion Progress</h2>
|
|
<ol className="space-y-3">
|
|
{stages.map((s) => (
|
|
<li key={s.key} className="flex items-center gap-3 text-sm">
|
|
<span
|
|
className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${
|
|
s.status === "completed"
|
|
? "bg-green-200 text-green-700"
|
|
: s.status === "active"
|
|
? "bg-brand-100 text-brand-700"
|
|
: "bg-gray-100 text-gray-400"
|
|
}`}
|
|
>
|
|
{s.status === "completed" ? "✓" : s.status === "active" ? "…" : "○"}
|
|
</span>
|
|
<span className={s.status === "active" ? "font-medium" : "text-gray-500"}>
|
|
{s.label}
|
|
</span>
|
|
{s.status === "active" && (
|
|
<span className="text-xs text-gray-400">{s.pct}%</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
{overall === "completed" && (
|
|
<p className="text-sm text-green-700 mt-4">✓ Ingestion complete!</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3">
|
|
<button onClick={handleReIngest} className="btn-primary">
|
|
Re-ingest Data
|
|
</button>
|
|
<button
|
|
onClick={handleDelete}
|
|
disabled={deleting}
|
|
className="btn-danger"
|
|
>
|
|
{deleting ? "Deleting…" : "Delete City"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|