Files
MineGeo/components/PanoramaPreview.tsx

197 lines
5.6 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { Panorama } from "./Panorama";
import { api } from "./StartGame";
import {
ChevronLeft,
ChevronRight,
X,
Move,
Expand,
Minimize,
} from "lucide-react";
export function PanoramaPreview({
endpoint,
initialId,
onClose,
}: {
endpoint: string;
initialId?: string;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const closeButton = useRef<HTMLButtonElement>(null);
const [expanded, setExpanded] = useState(false);
const [locations, setLocations] = useState<string[]>([]),
[selected, setSelected] = useState(initialId ?? ""),
[urls, setUrls] = useState<string[] | null>(null),
[ready, setReady] = useState(false),
[error, setError] = useState("");
const [labels, setLabels] = useState<Record<string, string>>({});
useEffect(() => {
const element = dialog.current;
const previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const overflow = document.body.style.overflow;
if (element && !element.open) element.showModal();
document.body.style.overflow = "hidden";
closeButton.current?.focus();
let disposed = false;
api<{ locations: string[]; labels?: Record<string, string> }>(endpoint)
.then((d) => {
if (disposed) return;
if (!Array.isArray(d.locations))
throw new Error(
"Could not load the submitted locations. Please try again.",
);
setLocations(d.locations);
setLabels(d.labels ?? {});
setSelected((s) =>
d.locations.includes(s) ? s : (d.locations[0] ?? ""),
);
})
.catch((e) => {
if (!disposed) setError(e.message);
});
return () => {
disposed = true;
element?.close();
document.body.style.overflow = overflow;
previousFocus?.focus();
};
}, [endpoint]);
useEffect(() => {
if (!selected) return;
let disposed = false;
setUrls(null);
setReady(false);
setError("");
api<{ faces: string[] }>(`${endpoint}/${selected}`)
.then((d) => {
if (!disposed) setUrls(d.faces);
})
.catch((e) => {
if (!disposed) setError(e.message);
});
return () => {
disposed = true;
};
}, [endpoint, selected]);
const index = locations.indexOf(selected);
return (
<dialog
ref={dialog}
className={`panorama-preview${expanded ? " is-expanded" : ""}`}
aria-labelledby="panorama-preview-title"
onCancel={(e) => {
e.preventDefault();
onClose();
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
const box = e.currentTarget.getBoundingClientRect();
if (
e.clientX < box.left ||
e.clientX > box.right ||
e.clientY < box.top ||
e.clientY > box.bottom
)
onClose();
}
}}
>
<header className="preview-header">
<div>
<span className="eyebrow">360° PANORAMA</span>
<h2 id="panorama-preview-title">A closer look.</h2>
</div>
<div className="preview-header-actions">
<button
className="icon-button"
aria-label={expanded ? "Restore preview size" : "Expand preview"}
onClick={() => setExpanded(!expanded)}
>
{expanded ? <Minimize size={19} /> : <Expand size={19} />}
</button>
<button
ref={closeButton}
className="icon-button"
aria-label="Close preview"
onClick={onClose}
>
<X size={21} />
</button>
</div>
</header>
<div className="preview-toolbar">
<label className="preview-location">
<span>Location</span>
<select
aria-label="Preview location"
value={selected}
onChange={(e) => setSelected(e.target.value)}
>
{locations.map((p) => (
<option key={p} value={p}>
{labels[p] ?? p}
</option>
))}
</select>
</label>
<span className="preview-counter">
{index >= 0 ? index + 1 : 0} / {locations.length}
</span>
<div className="preview-navigation">
<button
className="icon-button"
aria-label="Previous panorama"
disabled={index <= 0}
onClick={() => setSelected(locations[index - 1])}
>
<ChevronLeft size={21} />
</button>
<button
className="icon-button"
aria-label="Next panorama"
disabled={index < 0 || index >= locations.length - 1}
onClick={() => setSelected(locations[index + 1])}
>
<ChevronRight size={21} />
</button>
</div>
</div>
<div className="preview-viewport">
{urls && (
<Panorama
urls={urls}
zoom
onReady={() => setReady(true)}
onError={setError}
/>
)}
{error ? (
<p role="alert" className="preview-status">
{error}
</p>
) : (
!ready && (
<p role="status" className="preview-status">
Loading panorama…
</p>
)
)}
</div>
<footer className="preview-footer">
<span>
<Move size={16} />
Drag to look around · Scroll to zoom
</span>
<span>Check for blocked views and missing terrain.</span>
</footer>
</dialog>
);
}