Add panorama previews and repair beta export rendering

This commit is contained in:
2026-10-04 23:12:34 +05:00
parent 763cf8713c
commit aec0641f87
21 changed files with 802 additions and 60 deletions

View File

@@ -5,6 +5,7 @@ import { api } from "./StartGame";
import type { DatasetManifest } from "@/shared/protocol";
import type { ProcessingJob } from "@/shared/processing-progress";
import { ProcessingProgress } from "./ProcessingProgress";
import { PanoramaPreview } from "./PanoramaPreview";
type Draft = {
id: string;
name: string;
@@ -25,6 +26,7 @@ export function DraftEditor({ id }: { id: string }) {
[error, setError] = useState(""),
[message, setMessage] = useState(""),
[busy, setBusy] = useState(false);
const [preview, setPreview] = useState<string | null>(null);
useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout>;
@@ -55,6 +57,7 @@ export function DraftEditor({ id }: { id: string }) {
async function save(submit = false) {
setBusy(true);
setError("");
setMessage("");
try {
await api(
"datasets/" + id,
@@ -147,32 +150,47 @@ export function DraftEditor({ id }: { id: string }) {
</p>
<div className="preview-grid">
{draft.previews.map((p) => (
<label key={p.id}>
<input
type="checkbox"
checked={selected.includes(p.id)}
disabled={!editable}
onChange={(e) =>
setSelected((s) =>
e.target.checked
? [...s, p.id]
: s.filter((id) => id !== p.id),
)
}
/>
{p.image && (
<Image
src={p.image}
width={512}
height={512}
alt={`Viewpoint ${p.id}`}
unoptimized
<div className="preview-tile" key={p.id}>
<label>
<input
type="checkbox"
checked={selected.includes(p.id)}
disabled={!editable || busy}
aria-label={`Keep location ${p.id}`}
onChange={(e) =>
setSelected((s) =>
e.target.checked
? [...s, p.id]
: s.filter((id) => id !== p.id),
)
}
/>
{p.image && (
<Image
src={p.image}
width={512}
height={512}
alt={`Viewpoint ${p.id}`}
unoptimized
/>
)}
<span>{p.id}</span>
</label>
{p.image && (
<button className="button full" onClick={() => setPreview(p.id)}>
Look around
</button>
)}
<span>{p.id}</span>
</label>
</div>
))}
</div>
{preview && (
<PanoramaPreview
endpoint={`datasets/${id}/panoramas`}
initialId={preview}
onClose={() => setPreview(null)}
/>
)}
{editable && (
<div className="inline-actions">
<button

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import Image from "next/image";
import { api } from "./StartGame";
import { PanoramaPreview } from "./PanoramaPreview";
type ReviewMap = {
id: string;
name: string;
@@ -20,6 +21,8 @@ export function Moderation() {
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[reasons, setReasons] = useState<Record<string, string>>({});
const [preview, setPreview] = useState<string | null>(null),
[loading, setLoading] = useState(true);
async function load() {
try {
const d = await api<{ maps: ReviewMap[]; reports: typeof reports }>(
@@ -27,8 +30,23 @@ export function Moderation() {
);
setMaps(d.maps);
setReports(d.reports);
setError("");
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}
async function resolveReport(id: string) {
setBusy(true);
setError("");
try {
await api(`reports/${id}`, { state: "resolved" }, "PATCH");
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
useEffect(() => {
@@ -57,7 +75,8 @@ export function Moderation() {
{error}
</p>
)}
{!maps.length && !error && (
{loading && <p role="status">Loading review queue…</p>}
{!loading && !maps.length && !error && (
<p className="muted">The review queue is clear.</p>
)}
{maps.map((m) => (
@@ -87,6 +106,9 @@ export function Moderation() {
/>
</label>
<div className="inline-actions">
<button className="button" onClick={() => setPreview(m.id)}>
Inspect 360° locations
</button>
{m.state === "review" ? (
<>
<button
@@ -125,10 +147,23 @@ export function Moderation() {
<code>{r.dataset_id}</code>
<p>{r.reason}</p>
</div>
<button
className="button"
disabled={busy}
onClick={() => resolveReport(r.id)}
>
Resolve report
</button>
</article>
))}
</section>
)}
{preview && (
<PanoramaPreview
endpoint={`moderation/${preview}/panoramas`}
onClose={() => setPreview(null)}
/>
)}
</>
);
}

View File

@@ -82,6 +82,12 @@ export function Panorama({
);
};
let drag: { x: number; y: number } | null = null;
if (urls.length !== 6) {
callbacks.current.onError(
"This panorama is missing one or more of its six faces.",
);
return;
}
preloadFaces(urls)
.then((textures) => {
if (disposed) return;
@@ -134,11 +140,12 @@ export function Panorama({
element.addEventListener("wheel", wheel, { passive: false });
callbacks.current.onReady();
})
.catch((e) =>
.catch((e) => {
if (disposed) return;
callbacks.current.onError(
"Panorama could not load. " + (e instanceof Error ? e.message : ""),
),
);
);
});
return () => {
disposed = true;
cancelAnimationFrame(frame);
@@ -170,13 +177,13 @@ export function Panorama({
<>
<button
aria-label="Zoom in"
onClick={() => setFov((v) => Math.max(35, v - 10))}
onClick={() => setFov(Math.max(35, view.current.fov - 10))}
>
<Plus size={17} />
</button>
<button
aria-label="Zoom out"
onClick={() => setFov((v) => Math.min(100, v + 10))}
onClick={() => setFov(Math.min(100, view.current.fov + 10))}
>
<Minus size={17} />
</button>

View File

@@ -0,0 +1,122 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Panorama } from "./Panorama";
import { api } from "./StartGame";
export function PanoramaPreview({
endpoint,
initialId,
onClose,
}: {
endpoint: string;
initialId?: string;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [locations, setLocations] = useState<string[]>([]),
[selected, setSelected] = useState(initialId ?? ""),
[urls, setUrls] = useState<string[] | null>(null),
[ready, setReady] = useState(false),
[error, setError] = useState("");
useEffect(() => {
dialog.current?.showModal();
let disposed = false;
api<{ locations: string[] }>(endpoint)
.then((d) => {
if (disposed) return;
setLocations(d.locations);
setSelected((s) =>
d.locations.includes(s) ? s : (d.locations[0] ?? ""),
);
})
.catch((e) => {
if (!disposed) setError(e.message);
});
return () => {
disposed = true;
};
}, [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"
aria-label="Inspect panorama"
onCancel={onClose}
>
<div className="preview-toolbar">
<label>
Location{" "}
<select
aria-label="Preview location"
value={selected}
onChange={(e) => setSelected(e.target.value)}
>
{locations.map((p) => (
<option key={p}>{p}</option>
))}
</select>
</label>
<button
className="button"
disabled={index <= 0}
onClick={() => setSelected(locations[index - 1])}
>
Previous
</button>
<button
className="button"
disabled={index < 0 || index >= locations.length - 1}
onClick={() => setSelected(locations[index + 1])}
>
Next
</button>
<button className="button" onClick={onClose}>
Close preview
</button>
</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>
<p className="muted">
Drag to look around · Scroll to zoom · Check for blocked views and
missing terrain.
</p>
</dialog>
);
}

View File

@@ -9,6 +9,7 @@ export async function api<T = Record<string, unknown>>(
method = body ? "POST" : "GET",
): Promise<T> {
const res = await fetch("/api/v1/" + path, {
signal: AbortSignal.timeout(30000),
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,