mega-plant/components/SiteCoordinateEditor.tsx

166 lines
5.9 KiB
TypeScript
Raw Normal View History

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, MapPin, Check, X, ExternalLink } from "lucide-react";
// Pin a site on the map.
//
// Deliberately plain number inputs plus a "open in maps" link, rather than a
// drag-to-pin map: the in-app Leaflet map is still being worked on, and a coordinate
// entered by hand is usually copied off a map anyway. A point can be pasted straight in.
//
// Bounds are enforced server-side as well as here — a stray 0,0 or a digit dropped in
// the wrong box would otherwise place the marker in the Gulf of Guinea.
export function SiteCoordinateEditor({
siteId,
name,
latitude,
longitude,
}: {
siteId: string;
name: string;
latitude: number | null;
longitude: number | null;
}) {
const router = useRouter();
const has = Boolean(latitude || longitude);
const [open, setOpen] = useState(false);
const [lat, setLat] = useState(latitude ? String(latitude) : "");
const [lng, setLng] = useState(longitude ? String(longitude) : "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
async function save(clear = false) {
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/sites/${siteId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
clear ? { latitude: null, longitude: null } : { latitude: Number(lat), longitude: Number(lng) },
),
});
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
if (!data.ok) {
setError(data.error || "Could not save");
} else {
setOpen(false);
setSaved(true);
setTimeout(() => setSaved(false), 2500);
router.refresh();
}
} catch {
setError("Could not reach the server");
} finally {
setBusy(false);
}
}
if (!open) {
return (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center gap-2 min-h-[44px] px-3 rounded-lg text-sm font-medium text-[#003D5B] dark:text-white hover:bg-gray-100 dark:hover:bg-white/5 transition"
>
{saved ? (
<Check className="w-4 h-4 text-[#00798C]" />
) : (
<MapPin className={`w-4 h-4 ${has ? "text-[#00798C]" : "text-gray-400"}`} />
)}
{has
? `${latitude!.toFixed(5)}, ${longitude!.toFixed(5)}`
: "No location set — add coordinates"}
</button>
{has && (
<a
href={`https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 min-h-[44px] px-2 text-sm text-[#00798C] hover:underline"
>
Open in maps
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
);
}
return (
<div className="space-y-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label htmlFor={`lat-${siteId}`} className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Latitude
</label>
<input
id={`lat-${siteId}`}
type="number"
step="0.000001"
inputMode="decimal"
value={lat}
onChange={(e) => setLat(e.target.value)}
placeholder="-26.101615"
className="min-h-[44px] w-full px-3 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-sm tabular-nums focus:outline-none focus:border-[#30638E]"
/>
</div>
<div className="flex-1">
<label htmlFor={`lng-${siteId}`} className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Longitude
</label>
<input
id={`lng-${siteId}`}
type="number"
step="0.000001"
inputMode="decimal"
value={lng}
onChange={(e) => setLng(e.target.value)}
placeholder="28.348725"
className="min-h-[44px] w-full px-3 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-sm tabular-nums focus:outline-none focus:border-[#30638E]"
/>
</div>
</div>
{error && <p className="text-sm text-[#D1495B]">{error}</p>}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => save(false)}
disabled={busy || lat === "" || lng === ""}
className="inline-flex items-center gap-2 min-h-[44px] px-4 rounded-lg text-sm font-semibold text-white bg-[#00798C] hover:opacity-90 transition disabled:opacity-40"
>
{busy && <Loader2 className="w-4 h-4 animate-spin" />}
Save location
</button>
<button
type="button"
onClick={() => setOpen(false)}
disabled={busy}
className="inline-flex items-center gap-2 min-h-[44px] px-3 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 transition"
>
<X className="w-4 h-4" />
Cancel
</button>
{has && (
<button
type="button"
onClick={() => save(true)}
disabled={busy}
className="inline-flex items-center min-h-[44px] px-3 text-sm text-[#D1495B] hover:underline disabled:opacity-40"
>
Clear location
</button>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Paste a point copied from any map. Only coordinates inside South Africa are accepted.
</p>
</div>
);
}