Add panorama previews and repair beta export rendering
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
122
components/PanoramaPreview.tsx
Normal file
122
components/PanoramaPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user