166 lines
5.9 KiB
TypeScript
166 lines
5.9 KiB
TypeScript
|
|
"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>
|
||
|
|
);
|
||
|
|
}
|