The first commit, the core and all files is adding to repo
This commit is contained in:
56
components/CatalogBrowser.tsx
Normal file
56
components/CatalogBrowser.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { MapCard, type CardMap } from "./MapCard";
|
||||
export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
|
||||
const [query, setQuery] = useState(""),
|
||||
[source, setSource] = useState("all");
|
||||
const shown = maps.filter(
|
||||
(m) =>
|
||||
(source === "all" || m.source === source) &&
|
||||
(m.name + " " + m.description + " " + m.server?.name)
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase()),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="catalog-controls">
|
||||
<label className="search-field">
|
||||
<Search size={17} />
|
||||
<input
|
||||
aria-label="Search worlds"
|
||||
placeholder="Search worlds or servers…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="tabs" aria-label="Capture source">
|
||||
{[
|
||||
["all", "All worlds"],
|
||||
["plugin", "Server exports"],
|
||||
["fabric", "Player captures"],
|
||||
].map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
className={source === id ? "active" : ""}
|
||||
onClick={() => setSource(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted">
|
||||
{shown.length} {shown.length === 1 ? "world" : "worlds"} to explore
|
||||
</p>
|
||||
<div className="cards-grid">
|
||||
{shown.map((m, i) => (
|
||||
<MapCard map={m} index={i} key={m.id} />
|
||||
))}
|
||||
</div>
|
||||
{!shown.length && (
|
||||
<p className="empty-state">No worlds match that search.</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
157
components/Dashboard.tsx
Normal file
157
components/Dashboard.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "./StartGame";
|
||||
import type { MapListing } from "@/lib/catalog";
|
||||
type Draft = MapListing & {
|
||||
upload_jobs: { state: string; progress: number; error: string | null } | null;
|
||||
};
|
||||
export function Dashboard() {
|
||||
const [maps, setMaps] = useState<Draft[]>([]),
|
||||
[error, setError] = useState(""),
|
||||
[tokens, setTokens] = useState<
|
||||
{ id: string; label: string; revoked: boolean }[]
|
||||
>([]),
|
||||
[token, setToken] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [m, t] = await Promise.all([
|
||||
api<{ maps: Draft[] }>("datasets"),
|
||||
api<{ tokens: { id: string; label: string; revoked: boolean }[] }>(
|
||||
"tokens",
|
||||
),
|
||||
]);
|
||||
setMaps(m.maps);
|
||||
setTokens(t.tokens);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = setInterval(load, 10000);
|
||||
return () => clearInterval(timer);
|
||||
}, [load]);
|
||||
async function newToken() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await api<{ token: string }>("tokens", {
|
||||
label: "Server plugin",
|
||||
});
|
||||
setToken(result.token);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function revoke(id: string) {
|
||||
try {
|
||||
await api("tokens/" + id, undefined, "DELETE");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
async function logout() {
|
||||
const client = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
await client.auth.signOut();
|
||||
router.refresh();
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="dashboard-toolbar">
|
||||
<p className="muted">
|
||||
Your maps start privately. You decide when they’re ready for review.
|
||||
</p>
|
||||
<button className="button" onClick={logout}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!maps.length && (
|
||||
<div className="empty-state">
|
||||
<h2>A blank map is a beginning.</h2>
|
||||
<p>
|
||||
Install the Fabric mod to capture while you explore, or the server
|
||||
plugin to export your world.
|
||||
</p>
|
||||
<Link className="button primary" href="/downloads">
|
||||
Get the capture tools →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{maps.map((m) => (
|
||||
<article className="draft-row" key={m.id}>
|
||||
<div>
|
||||
<span className="eyebrow">
|
||||
{m.source === "plugin" ? "SERVER EXPORT" : "PLAYER CAPTURE"}
|
||||
</span>
|
||||
<h3>{m.name}</h3>
|
||||
<span className="badge">{m.state}</span>{" "}
|
||||
<span className="muted">
|
||||
{m.panorama_count} locations{" "}
|
||||
{m.upload_jobs?.state === "running"
|
||||
? `· ${m.upload_jobs.progress}% processed`
|
||||
: ""}
|
||||
</span>
|
||||
{m.rejection_reason && (
|
||||
<p className="error-message">{m.rejection_reason}</p>
|
||||
)}
|
||||
{m.upload_jobs?.error && (
|
||||
<p className="muted">{m.upload_jobs.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<Link href={"/dashboard/" + m.id} className="button">
|
||||
Open draft →
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
<section className="token-box">
|
||||
<span className="eyebrow">FOR SERVER ADMINISTRATORS</span>
|
||||
<h2>Link your server plugin.</h2>
|
||||
<p className="muted">
|
||||
Create a contributor token, then run{" "}
|
||||
<code>/minegeo link <token></code> in your server console.
|
||||
Fabric uses browser pairing instead.
|
||||
</p>
|
||||
<button className="button" disabled={busy} onClick={newToken}>
|
||||
Generate plugin token
|
||||
</button>
|
||||
{token && (
|
||||
<>
|
||||
<p className="success-message">
|
||||
Copy this token now. It is shown once. Keep it out of public chat.
|
||||
</p>
|
||||
<code>{token}</code>
|
||||
</>
|
||||
)}
|
||||
{tokens.map((t) => (
|
||||
<div className="draft-row" key={t.id}>
|
||||
<span>
|
||||
{t.label}{" "}
|
||||
<small className="muted">{t.revoked ? "· revoked" : ""}</small>
|
||||
</span>
|
||||
{!t.revoked && (
|
||||
<button className="button" onClick={() => revoke(t.id)}>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
172
components/DraftEditor.tsx
Normal file
172
components/DraftEditor.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { api } from "./StartGame";
|
||||
import type { DatasetManifest } from "@/shared/protocol";
|
||||
type Draft = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
state: string;
|
||||
source: string;
|
||||
map: string | null;
|
||||
manifest: DatasetManifest;
|
||||
previews: { id: string; image: string | null }[];
|
||||
};
|
||||
export function DraftEditor({ id }: { id: string }) {
|
||||
const [draft, setDraft] = useState<Draft | null>(null),
|
||||
[name, setName] = useState(""),
|
||||
[description, setDescription] = useState(""),
|
||||
[selected, setSelected] = useState<string[]>([]),
|
||||
[error, setError] = useState(""),
|
||||
[message, setMessage] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
api<Draft>("datasets/" + id)
|
||||
.then((d) => {
|
||||
setDraft(d);
|
||||
setName(d.name);
|
||||
setDescription(d.description);
|
||||
setSelected(d.manifest.viewpoints.map((p) => p.id));
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, [id]);
|
||||
async function save(submit = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api(
|
||||
"datasets/" + id,
|
||||
{ name, description, selectedIds: selected },
|
||||
"PATCH",
|
||||
);
|
||||
if (submit) {
|
||||
await api("datasets/" + id + "/submit", {});
|
||||
setDraft((d) => (d ? { ...d, state: "review" } : d));
|
||||
setMessage("Submitted for moderator review.");
|
||||
} else setMessage("Draft saved.");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function cancel() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api("datasets/" + id + "/cancel", {});
|
||||
setMessage("Processing cancelled.");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
if (!draft)
|
||||
return (
|
||||
<p role={error ? "alert" : undefined}>{error || "Loading your draft…"}</p>
|
||||
);
|
||||
const editable = ["draft", "rejected"].includes(draft.state);
|
||||
return (
|
||||
<>
|
||||
<span className="badge">{draft.state}</span>
|
||||
<p className="muted">
|
||||
{draft.source === "plugin"
|
||||
? "Server-generated views use vanilla terrain and simplified lighting. Entities, shaders, custom packs and dynamic sign text are omitted."
|
||||
: "Player captures show the terrain received by the client. Unexplored map coverage remains blank."}
|
||||
</p>
|
||||
{draft.map && (
|
||||
<div className="detail-map" style={{ maxWidth: 600 }}>
|
||||
<Image
|
||||
src={draft.map}
|
||||
width={1024}
|
||||
height={1024}
|
||||
alt="Draft surface map"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<label className="field">
|
||||
Map name
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
Description
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</label>
|
||||
<h2>Choose your locations.</h2>
|
||||
<p className="muted">
|
||||
Keep at least five clear, useful panoramas. Selected: {selected.length}
|
||||
</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
|
||||
/>
|
||||
)}
|
||||
<span>{p.id}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{editable && (
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy || selected.length < 5}
|
||||
onClick={() => save()}
|
||||
>
|
||||
Save draft
|
||||
</button>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={busy || selected.length < 5}
|
||||
onClick={() => save(true)}
|
||||
>
|
||||
Submit for review →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{["uploading", "processing"].includes(draft.state) && (
|
||||
<button className="button" disabled={busy} onClick={cancel}>
|
||||
Cancel processing
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p role="status" className="success-message">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
300
components/Game.tsx
Normal file
300
components/Game.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MousePointer2,
|
||||
Move,
|
||||
MapPin,
|
||||
} from "lucide-react";
|
||||
import { Brand } from "./Shell";
|
||||
import { Panorama, preloadFaces } from "./Panorama";
|
||||
import { GuessMap, type Pin } from "./GuessMap";
|
||||
import { api } from "./StartGame";
|
||||
import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol";
|
||||
export type RoundResult = {
|
||||
score: number;
|
||||
distance: number | null;
|
||||
timedOut: boolean;
|
||||
actual: Pin;
|
||||
guess: Pin | null;
|
||||
};
|
||||
export type SessionView = {
|
||||
id: string;
|
||||
name: string;
|
||||
difficulty: Difficulty;
|
||||
round: number;
|
||||
bounds: Bounds;
|
||||
map: string;
|
||||
serverNow: number;
|
||||
rounds: {
|
||||
number: number;
|
||||
faces: string[];
|
||||
deadline: string | null;
|
||||
result: RoundResult | null;
|
||||
}[];
|
||||
};
|
||||
export function Game({ id }: { id: string }) {
|
||||
const router = useRouter(),
|
||||
[session, setSession] = useState<SessionView | null>(null),
|
||||
[pin, setPin] = useState<Pin | null>(null),
|
||||
[expanded, setExpanded] = useState(false),
|
||||
[loaded, setLoaded] = useState({ panorama: false, map: false }),
|
||||
[error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false),
|
||||
[result, setResult] = useState<RoundResult | null>(null),
|
||||
[remaining, setRemaining] = useState(0),
|
||||
[progress, setProgress] = useState(0);
|
||||
const offset = useRef(0),
|
||||
activating = useRef(false),
|
||||
timingOut = useRef(false);
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const s = await api<SessionView>("sessions/" + id);
|
||||
offset.current = s.serverNow - Date.now();
|
||||
if (s.round >= 5) {
|
||||
router.replace("/results/" + id);
|
||||
return;
|
||||
}
|
||||
setSession(s);
|
||||
setRemaining(
|
||||
s.rounds[s.round].deadline
|
||||
? Math.max(
|
||||
0,
|
||||
Math.ceil(
|
||||
(Date.parse(s.rounds[s.round].deadline!) - s.serverNow) / 1000,
|
||||
),
|
||||
)
|
||||
: difficulties[s.difficulty].seconds,
|
||||
);
|
||||
s.rounds.forEach((r) =>
|
||||
preloadFaces(r.faces)
|
||||
.then(() => setProgress((p) => Math.min(100, p + 20)))
|
||||
.catch(() => {}),
|
||||
);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, [id, router]);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!session ||
|
||||
!loaded.panorama ||
|
||||
!loaded.map ||
|
||||
result ||
|
||||
activating.current
|
||||
)
|
||||
return;
|
||||
const r = session.rounds[session.round];
|
||||
if (r.deadline) return;
|
||||
activating.current = true;
|
||||
api<{ deadline: string; serverNow: number }>(`sessions/${id}/activate`, {
|
||||
round: session.round,
|
||||
})
|
||||
.then((d) => {
|
||||
offset.current = d.serverNow - Date.now();
|
||||
setSession((s) =>
|
||||
s
|
||||
? {
|
||||
...s,
|
||||
rounds: s.rounds.map((r) =>
|
||||
r.number === s.round ? { ...r, deadline: d.deadline } : r,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => (activating.current = false));
|
||||
}, [loaded, session, id, result]);
|
||||
const guess = useCallback(
|
||||
async (timeout = false) => {
|
||||
if (!session || busy || result || (!pin && !timeout)) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await api<RoundResult>(`sessions/${id}/guess`, {
|
||||
round: session.round,
|
||||
...(!timeout && pin ? pin : {}),
|
||||
});
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
timingOut.current = false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[session, busy, result, pin, id],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!session || result) return;
|
||||
const deadline = session.rounds[session.round].deadline;
|
||||
if (!deadline) return;
|
||||
const tick = () => {
|
||||
const left = Math.max(
|
||||
0,
|
||||
Math.ceil((Date.parse(deadline) - Date.now() - offset.current) / 1000),
|
||||
);
|
||||
setRemaining(left);
|
||||
if (left === 0 && !timingOut.current) {
|
||||
timingOut.current = true;
|
||||
guess(true);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(tick, 250);
|
||||
return () => clearInterval(timer);
|
||||
}, [session, result, guess]);
|
||||
async function next() {
|
||||
if (!session) return;
|
||||
setSession(null);
|
||||
setResult(null);
|
||||
setPin(null);
|
||||
setLoaded({ panorama: false, map: false });
|
||||
timingOut.current = false;
|
||||
setProgress(0);
|
||||
await load();
|
||||
}
|
||||
const ready = useCallback(
|
||||
() => setLoaded((s) => ({ ...s, panorama: true })),
|
||||
[],
|
||||
),
|
||||
mapReady = useCallback(() => setLoaded((s) => ({ ...s, map: true })), []);
|
||||
const onError = useCallback((e: string) => setError(e), []);
|
||||
const current = session?.rounds[session.round];
|
||||
const total =
|
||||
session?.rounds.reduce((n, r) => n + (r.result?.score || 0), 0) || 0;
|
||||
return (
|
||||
<main className="game-page">
|
||||
{session && current && (
|
||||
<Panorama
|
||||
key={session.round}
|
||||
urls={current.faces}
|
||||
zoom={difficulties[session.difficulty].zoom}
|
||||
onReady={ready}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
<div className="game-header">
|
||||
<Brand />
|
||||
<div className="game-info">
|
||||
<div>
|
||||
<small>ROUND</small>
|
||||
<strong>{session ? session.round + 1 : "—"} / 5</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>TIME LEFT</small>
|
||||
<strong className={remaining <= 10 ? "urgent" : ""}>
|
||||
{Math.floor(remaining / 60)}:
|
||||
{String(remaining % 60).padStart(2, "0")}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>POINTS</small>
|
||||
<strong>{total.toLocaleString()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{session && (
|
||||
<div className={"game-map " + (expanded ? "expanded" : "")}>
|
||||
<div className="map-topline">
|
||||
<span>↑ N · PLACE YOUR GUESS</span>
|
||||
<button
|
||||
aria-label={expanded ? "Collapse map" : "Expand map"}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
<GuessMap
|
||||
key={"map-" + session.round}
|
||||
bounds={session.bounds}
|
||||
image={session.map}
|
||||
pin={result?.guess || pin}
|
||||
actual={result?.actual}
|
||||
onPin={setPin}
|
||||
onReady={mapReady}
|
||||
onError={onError}
|
||||
disabled={!!result || busy}
|
||||
/>
|
||||
<div className="map-actions">
|
||||
<small>
|
||||
{pin
|
||||
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}`
|
||||
: "Click the map to drop a pin"}
|
||||
</small>
|
||||
<button
|
||||
className="button primary full"
|
||||
disabled={!pin || busy || !!result || !current?.deadline}
|
||||
onClick={() => guess()}
|
||||
>
|
||||
<MapPin size={15} />
|
||||
{busy ? "Checking your guess…" : "Submit guess"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="look-help">
|
||||
<span>
|
||||
<Move size={12} /> DRAG TO LOOK AROUND
|
||||
</span>
|
||||
<span>
|
||||
<MousePointer2 size={12} />{" "}
|
||||
{session?.difficulty === "hard" ? "NO ZOOM" : "SCROLL TO ZOOM"}
|
||||
</span>
|
||||
</div>
|
||||
{(!session || !loaded.panorama || !loaded.map) && !error && (
|
||||
<div className="game-loading">
|
||||
<div className="loading-cube" />
|
||||
<h2>Preparing your expedition.</h2>
|
||||
<p>Loading compressed panoramas · {progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="round-result" role="alert">
|
||||
<h2>Connection interrupted.</h2>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
className="button primary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="round-result">
|
||||
<span className="eyebrow">
|
||||
ROUND {(session?.round || 0) + 1} COMPLETE
|
||||
</span>
|
||||
<h2>
|
||||
{result.timedOut
|
||||
? "Time slipped away."
|
||||
: result.score === 5000
|
||||
? "Right on the block!"
|
||||
: result.score > 3000
|
||||
? "You know your way around."
|
||||
: "A little off the beaten block."}
|
||||
</h2>
|
||||
<div className="score">
|
||||
{result.score.toLocaleString()}
|
||||
<small> / 5,000</small>
|
||||
</div>
|
||||
<p>
|
||||
{result.timedOut
|
||||
? "The clock ran out. This round scores zero."
|
||||
: `${Math.round(result.distance || 0).toLocaleString()} blocks from the actual location.`}
|
||||
</p>
|
||||
<button className="button primary full" onClick={next}>
|
||||
{session?.round === 4 ? "See expedition results" : "Next round"} →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
124
components/GuessMap.tsx
Normal file
124
components/GuessMap.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type * as Leaflet from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import type { Bounds } from "@/shared/protocol";
|
||||
export type Pin = { x: number; z: number };
|
||||
export function GuessMap({
|
||||
bounds,
|
||||
image,
|
||||
pin,
|
||||
actual,
|
||||
onPin,
|
||||
onReady,
|
||||
onError,
|
||||
disabled = false,
|
||||
}: {
|
||||
bounds: Bounds;
|
||||
image: string;
|
||||
pin?: Pin | null;
|
||||
actual?: Pin | null;
|
||||
onPin?: (p: Pin) => void;
|
||||
onReady?: () => void;
|
||||
onError?: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null),
|
||||
markers = useRef<Leaflet.LayerGroup | null>(null),
|
||||
lib = useRef<typeof Leaflet | null>(null),
|
||||
[generation, setGeneration] = useState(0);
|
||||
const callbacks = useRef({ onPin, onReady, onError, disabled });
|
||||
callbacks.current = { onPin, onReady, onError, disabled };
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let cleanup = () => {};
|
||||
import("leaflet")
|
||||
.then((L) => {
|
||||
if (disposed || !root.current) return;
|
||||
lib.current = L;
|
||||
const b = L.latLngBounds([
|
||||
[-bounds.maxZ, bounds.minX],
|
||||
[-bounds.minZ, bounds.maxX],
|
||||
]);
|
||||
const m = L.map(root.current, {
|
||||
crs: L.CRS.Simple,
|
||||
minZoom: -5,
|
||||
maxZoom: 3,
|
||||
maxBounds: b.pad(0.15),
|
||||
attributionControl: false,
|
||||
});
|
||||
const overlay = L.imageOverlay(image, b);
|
||||
overlay.on("load", () => callbacks.current.onReady?.());
|
||||
overlay.on("error", () =>
|
||||
callbacks.current.onError?.(
|
||||
"The surface map could not load. Reconnect to retry.",
|
||||
),
|
||||
);
|
||||
overlay.addTo(m);
|
||||
m.fitBounds(b, { padding: [5, 5] });
|
||||
markers.current = L.layerGroup().addTo(m);
|
||||
setGeneration((v) => v + 1);
|
||||
m.on("click", (e: Leaflet.LeafletMouseEvent) => {
|
||||
if (callbacks.current.disabled) return;
|
||||
const x = e.latlng.lng,
|
||||
z = -e.latlng.lat;
|
||||
if (
|
||||
x >= bounds.minX &&
|
||||
x <= bounds.maxX &&
|
||||
z >= bounds.minZ &&
|
||||
z <= bounds.maxZ
|
||||
)
|
||||
callbacks.current.onPin?.({ x, z });
|
||||
});
|
||||
const observer = new ResizeObserver(() => m.invalidateSize());
|
||||
observer.observe(root.current);
|
||||
cleanup = () => {
|
||||
observer.disconnect();
|
||||
markers.current = null;
|
||||
m.remove();
|
||||
};
|
||||
})
|
||||
.catch(() =>
|
||||
callbacks.current.onError?.("The map viewer could not initialize."),
|
||||
);
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [bounds, image]);
|
||||
useEffect(() => {
|
||||
const L = lib.current,
|
||||
group = markers.current;
|
||||
if (!L || !group) return;
|
||||
group.clearLayers();
|
||||
const marker = (p: Pin, answer = false) =>
|
||||
L.marker([-p.z, p.x], {
|
||||
icon: L.divIcon({
|
||||
className: "",
|
||||
html: `<div class="pin-marker ${answer ? "actual-marker" : ""}"></div>`,
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7],
|
||||
}),
|
||||
}).addTo(group);
|
||||
if (pin) marker(pin);
|
||||
if (actual) marker(actual, true);
|
||||
if (pin && actual)
|
||||
L.polyline(
|
||||
[
|
||||
[-pin.z, pin.x],
|
||||
[-actual.z, actual.x],
|
||||
],
|
||||
{ color: "#e6d494", weight: 2, dashArray: "5 5" },
|
||||
).addTo(group);
|
||||
}, [pin, actual, generation]);
|
||||
return (
|
||||
<div className="map-surface">
|
||||
<div
|
||||
ref={root}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
role="application"
|
||||
aria-label="Minecraft map. North is up. Click to place a guess."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
components/MapCard.tsx
Normal file
50
components/MapCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight, MapPin } from "lucide-react";
|
||||
export type CardMap = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source: string;
|
||||
panorama_count: number;
|
||||
image?: string | null;
|
||||
server?: { name: string } | undefined;
|
||||
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
|
||||
};
|
||||
export function MapCard({ map, index = 0 }: { map: CardMap; index?: number }) {
|
||||
return (
|
||||
<Link className="map-card" href={`/servers/${map.id}`}>
|
||||
<div className="card-image">
|
||||
<Image
|
||||
src={map.image || "/generated/paper-proof/map.webp"}
|
||||
alt={`${map.name} terrain`}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(max-width: 640px) 100vw, 33vw"
|
||||
/>
|
||||
<span className="card-type">
|
||||
{map.source === "plugin" ? "SERVER EXPORT" : "PLAYER CAPTURE"}
|
||||
</span>
|
||||
<span className="card-number">0{index + 1}</span>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<span className="eyebrow">{map.server?.name || "Overworld"}</span>
|
||||
<h3>
|
||||
{map.name}
|
||||
<ArrowUpRight size={20} />
|
||||
</h3>
|
||||
<p>{map.description}</p>
|
||||
<div className="card-meta">
|
||||
<span>
|
||||
<MapPin size={13} />
|
||||
{map.panorama_count} locations
|
||||
</span>
|
||||
<span>
|
||||
{map.bounds.maxX - map.bounds.minX} ×{" "}
|
||||
{map.bounds.maxZ - map.bounds.minZ} blocks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
134
components/Moderation.tsx
Normal file
134
components/Moderation.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { api } from "./StartGame";
|
||||
type ReviewMap = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
state: string;
|
||||
source: string;
|
||||
panorama_count: number;
|
||||
map: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
export function Moderation() {
|
||||
const [maps, setMaps] = useState<ReviewMap[]>([]),
|
||||
[reports, setReports] = useState<
|
||||
{ id: string; dataset_id: string; reason: string }[]
|
||||
>([]),
|
||||
[error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false),
|
||||
[reasons, setReasons] = useState<Record<string, string>>({});
|
||||
async function load() {
|
||||
try {
|
||||
const d = await api<{ maps: ReviewMap[]; reports: typeof reports }>(
|
||||
"moderation",
|
||||
);
|
||||
setMaps(d.maps);
|
||||
setReports(d.reports);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
async function decide(datasetId: string, decision: string) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api("moderation", {
|
||||
datasetId,
|
||||
decision,
|
||||
reason: reasons[datasetId] || undefined,
|
||||
});
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!maps.length && !error && (
|
||||
<p className="muted">The review queue is clear.</p>
|
||||
)}
|
||||
{maps.map((m) => (
|
||||
<article className="tool-card" key={m.id}>
|
||||
<span className="badge">{m.state}</span>
|
||||
<h2>{m.name}</h2>
|
||||
<p className="muted">
|
||||
{m.source} · {m.panorama_count} locations · {m.description}
|
||||
</p>
|
||||
{m.map && (
|
||||
<Image
|
||||
src={m.map}
|
||||
width={512}
|
||||
height={512}
|
||||
alt="Map for moderator review"
|
||||
unoptimized
|
||||
style={{ maxWidth: "100%", height: "auto" }}
|
||||
/>
|
||||
)}
|
||||
<label className="field">
|
||||
Review reason
|
||||
<textarea
|
||||
value={reasons[m.id] || ""}
|
||||
onChange={(e) =>
|
||||
setReasons((s) => ({ ...s, [m.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="inline-actions">
|
||||
{m.state === "review" ? (
|
||||
<>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={busy}
|
||||
onClick={() => decide(m.id, "approve")}
|
||||
>
|
||||
Approve map
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy || !reasons[m.id]?.trim()}
|
||||
onClick={() => decide(m.id, "reject")}
|
||||
>
|
||||
Return with feedback
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => decide(m.id, "unpublish")}
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{reports.length > 0 && (
|
||||
<section className="token-box">
|
||||
<h2>Community reports</h2>
|
||||
{reports.map((r) => (
|
||||
<article className="draft-row" key={r.id}>
|
||||
<div>
|
||||
<code>{r.dataset_id}</code>
|
||||
<p>{r.reason}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
components/Pairing.tsx
Normal file
40
components/Pairing.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { api } from "./StartGame";
|
||||
export function Pairing({ code }: { code: string }) {
|
||||
const [message, setMessage] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
async function approve() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api("pairing/approve", { code });
|
||||
setMessage("Capture client linked. You can return to Minecraft.");
|
||||
} catch (e) {
|
||||
setMessage((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="auth-card">
|
||||
<span className="eyebrow">LINK YOUR CAPTURE CLIENT</span>
|
||||
<h2>{code}</h2>
|
||||
<p className="muted">
|
||||
Approve only if this code is displayed by your MineGeo Fabric mod.
|
||||
Linking lets that client upload private map drafts to your account.
|
||||
</p>
|
||||
<button
|
||||
className="button primary full"
|
||||
disabled={busy || !!message}
|
||||
onClick={approve}
|
||||
>
|
||||
Link my Minecraft client
|
||||
</button>
|
||||
{message && (
|
||||
<p role="status" className="success-message">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
components/Panorama.tsx
Normal file
198
components/Panorama.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Plus, Minus, RotateCcw } from "lucide-react";
|
||||
import * as THREE from "three";
|
||||
const textureCache = new Map<string, Promise<THREE.Texture>>();
|
||||
export function preloadFaces(urls: string[]) {
|
||||
return Promise.all(
|
||||
urls.map((url) => {
|
||||
if (!textureCache.has(url))
|
||||
textureCache.set(
|
||||
url,
|
||||
new THREE.TextureLoader()
|
||||
.loadAsync(url)
|
||||
.then((t) => {
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
return t;
|
||||
})
|
||||
.catch((e) => {
|
||||
textureCache.delete(url);
|
||||
throw e;
|
||||
}),
|
||||
);
|
||||
return textureCache.get(url)!;
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Explicit projection matches the six positive-FOV camera images emitted by
|
||||
// both exporters; it avoids CubeTexture's renderer-dependent orientation.
|
||||
const fragment = `uniform sampler2D px;uniform sampler2D nx;uniform sampler2D py;uniform sampler2D ny;uniform sampler2D pz;uniform sampler2D nz;varying vec3 vDirection;
|
||||
void main(){vec3 d=normalize(vDirection);vec3 a=abs(d);vec2 uv;vec4 color;if(a.x>=a.y&&a.x>=a.z){if(d.x>0.0){uv=vec2(d.z,d.y)/a.x*.5+.5;color=texture2D(px,uv);}else{uv=vec2(-d.z,d.y)/a.x*.5+.5;color=texture2D(nx,uv);}}else if(a.y>=a.z){if(d.y>0.0){uv=vec2(-d.x,-d.z)/a.y*.5+.5;color=texture2D(py,uv);}else{uv=vec2(-d.x,d.z)/a.y*.5+.5;color=texture2D(ny,uv);}}else{if(d.z>0.0){uv=vec2(-d.x,d.y)/a.z*.5+.5;color=texture2D(pz,uv);}else{uv=vec2(d.x,d.y)/a.z*.5+.5;color=texture2D(nz,uv);}}gl_FragColor=color;
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}`;
|
||||
export function Panorama({
|
||||
urls,
|
||||
zoom,
|
||||
onReady,
|
||||
onError,
|
||||
}: {
|
||||
urls: string[];
|
||||
zoom: boolean;
|
||||
onReady: () => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const mount = useRef<HTMLDivElement>(null),
|
||||
view = useRef({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
[fov, setFov] = useState(80);
|
||||
const callbacks = useRef({ onReady, onError });
|
||||
callbacks.current = { onReady, onError };
|
||||
useEffect(() => {
|
||||
let disposed = false,
|
||||
renderer: THREE.WebGLRenderer | undefined,
|
||||
frame = 0,
|
||||
observer: ResizeObserver | undefined;
|
||||
let material: THREE.ShaderMaterial | undefined,
|
||||
geometry: THREE.SphereGeometry | undefined;
|
||||
const element = mount.current!;
|
||||
view.current = { yaw: 0, pitch: 0, fov: 80 };
|
||||
const down = (e: PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
element.setPointerCapture(e.pointerId);
|
||||
drag = { x: e.clientX, y: e.clientY };
|
||||
},
|
||||
move = (e: PointerEvent) => {
|
||||
if (!drag) return;
|
||||
view.current.yaw -= (e.clientX - drag.x) * 0.003;
|
||||
view.current.pitch = Math.max(
|
||||
-1.5,
|
||||
Math.min(1.5, view.current.pitch + (e.clientY - drag.y) * 0.003),
|
||||
);
|
||||
drag = { x: e.clientX, y: e.clientY };
|
||||
},
|
||||
up = () => {
|
||||
drag = null;
|
||||
},
|
||||
wheel = (e: WheelEvent) => {
|
||||
if (!zoom) return;
|
||||
e.preventDefault();
|
||||
view.current.fov = Math.max(
|
||||
35,
|
||||
Math.min(100, view.current.fov + e.deltaY * 0.04),
|
||||
);
|
||||
};
|
||||
let drag: { x: number; y: number } | null = null;
|
||||
preloadFaces(urls)
|
||||
.then((textures) => {
|
||||
if (disposed) return;
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
element.appendChild(renderer.domElement);
|
||||
const scene = new THREE.Scene(),
|
||||
camera = new THREE.PerspectiveCamera(80, 1, 0.1, 10);
|
||||
geometry = new THREE.SphereGeometry(5, 32, 16);
|
||||
material = new THREE.ShaderMaterial({
|
||||
side: THREE.BackSide,
|
||||
uniforms: Object.fromEntries(
|
||||
["px", "nx", "py", "ny", "pz", "nz"].map((f, i) => [
|
||||
f,
|
||||
{ value: textures[i] },
|
||||
]),
|
||||
),
|
||||
vertexShader:
|
||||
"varying vec3 vDirection;void main(){vDirection=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}",
|
||||
fragmentShader: fragment,
|
||||
});
|
||||
scene.add(new THREE.Mesh(geometry, material));
|
||||
const resize = () => {
|
||||
camera.aspect = element.clientWidth / element.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer!.setSize(element.clientWidth, element.clientHeight);
|
||||
};
|
||||
observer = new ResizeObserver(resize);
|
||||
observer.observe(element);
|
||||
resize();
|
||||
function draw() {
|
||||
if (disposed) return;
|
||||
const v = view.current;
|
||||
camera.fov = zoom ? v.fov : 80;
|
||||
camera.updateProjectionMatrix();
|
||||
camera.lookAt(
|
||||
Math.sin(v.yaw) * Math.cos(v.pitch),
|
||||
Math.sin(v.pitch),
|
||||
-Math.cos(v.yaw) * Math.cos(v.pitch),
|
||||
);
|
||||
renderer!.render(scene, camera);
|
||||
frame = requestAnimationFrame(draw);
|
||||
}
|
||||
draw();
|
||||
element.addEventListener("pointerdown", down);
|
||||
element.addEventListener("pointermove", move);
|
||||
element.addEventListener("pointerup", up);
|
||||
element.addEventListener("pointercancel", up);
|
||||
element.addEventListener("wheel", wheel, { passive: false });
|
||||
callbacks.current.onReady();
|
||||
})
|
||||
.catch((e) =>
|
||||
callbacks.current.onError(
|
||||
"Panorama could not load. " + (e instanceof Error ? e.message : ""),
|
||||
),
|
||||
);
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(frame);
|
||||
observer?.disconnect();
|
||||
element.removeEventListener("pointerdown", down);
|
||||
element.removeEventListener("pointermove", move);
|
||||
element.removeEventListener("pointerup", up);
|
||||
element.removeEventListener("pointercancel", up);
|
||||
element.removeEventListener("wheel", wheel);
|
||||
geometry?.dispose();
|
||||
material?.dispose();
|
||||
renderer?.dispose();
|
||||
renderer?.domElement.remove();
|
||||
};
|
||||
}, [urls, zoom]);
|
||||
useEffect(() => {
|
||||
view.current.fov = fov;
|
||||
}, [fov]);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="panorama"
|
||||
ref={mount}
|
||||
aria-label="360 degree panorama: drag to look around"
|
||||
/>
|
||||
{
|
||||
<div className="panorama-controls">
|
||||
{zoom && (
|
||||
<>
|
||||
<button
|
||||
aria-label="Zoom in"
|
||||
onClick={() => setFov((v) => Math.max(35, v - 10))}
|
||||
>
|
||||
<Plus size={17} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Zoom out"
|
||||
onClick={() => setFov((v) => Math.min(100, v + 10))}
|
||||
>
|
||||
<Minus size={17} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
aria-label="Reset view"
|
||||
onClick={() => {
|
||||
view.current = { yaw: 0, pitch: 0, fov: 80 };
|
||||
setFov(80);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={17} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
components/Results.tsx
Normal file
95
components/Results.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "./StartGame";
|
||||
import { GuessMap } from "./GuessMap";
|
||||
import type { SessionView } from "./Game";
|
||||
export function Results({ id }: { id: string }) {
|
||||
const [session, setSession] = useState<SessionView | null>(null),
|
||||
[selected, setSelected] = useState(0),
|
||||
[error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
api<SessionView>("sessions/" + id)
|
||||
.then((s) => {
|
||||
if (s.round < 5) router.replace("/play/" + id);
|
||||
else setSession(s);
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, [id, router]);
|
||||
if (error) return <p role="alert">{error}</p>;
|
||||
if (!session) return <p className="muted">Unpacking your expedition…</p>;
|
||||
const total = session.rounds.reduce((n, r) => n + (r.result?.score || 0), 0);
|
||||
const current = session.rounds[selected].result;
|
||||
return (
|
||||
<>
|
||||
<span className="eyebrow">
|
||||
EXPEDITION COMPLETE · {session.difficulty.toUpperCase()}
|
||||
</span>
|
||||
<h1>Every guess is a story.</h1>
|
||||
<p className="lead">
|
||||
{session.name} · Here’s where your instincts took you.
|
||||
</p>
|
||||
<div className="results-total">
|
||||
{total.toLocaleString()}
|
||||
<small> / 25,000</small>
|
||||
</div>
|
||||
<div className="results-grid">
|
||||
<div className="result-map">
|
||||
<GuessMap
|
||||
bounds={session.bounds}
|
||||
image={session.map}
|
||||
pin={current?.guess}
|
||||
actual={current?.actual}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="result-summary">
|
||||
<h2>Your five-round journey</h2>
|
||||
<div className="tabs">
|
||||
{session.rounds.map((r, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={i === selected ? "active" : ""}
|
||||
onClick={() => setSelected(i)}
|
||||
>
|
||||
Round {i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<table className="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ROUND</th>
|
||||
<th>DISTANCE</th>
|
||||
<th>POINTS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{session.rounds.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{i + 1}</td>
|
||||
<td>
|
||||
{r.result?.timedOut
|
||||
? "Timed out"
|
||||
: `${Math.round(r.result?.distance || 0)} blocks`}
|
||||
</td>
|
||||
<td>{r.result?.score.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="inline-actions">
|
||||
<Link className="button primary" href="/servers">
|
||||
Another adventure →
|
||||
</Link>
|
||||
<Link className="button" href="/dashboard">
|
||||
My expeditions
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
components/Shell.tsx
Normal file
69
components/Shell.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import Link from "next/link";
|
||||
import { Compass, ArrowUpRight, Globe2 } from "lucide-react";
|
||||
export function Brand() {
|
||||
return (
|
||||
<Link href="/" className="brand" aria-label="MineGeo home">
|
||||
<span className="brand-cube">
|
||||
<span />
|
||||
</span>
|
||||
Mine<span>Geo</span>
|
||||
<sup>BETA</sup>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="site-header">
|
||||
<div className="header-inner">
|
||||
<Brand />
|
||||
<nav aria-label="Main navigation">
|
||||
<Link href="/servers">Explore servers</Link>
|
||||
<Link href="/downloads">Create a map</Link>
|
||||
<Link href="/about">How to play</Link>
|
||||
</nav>
|
||||
<Link href="/dashboard" className="header-account">
|
||||
My expedition <ArrowUpRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div>
|
||||
<Brand />
|
||||
<p>A familiar world. A whole new perspective.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
<Globe2 size={14} /> English
|
||||
</span>
|
||||
<Link href="/downloads">Tools & downloads</Link>
|
||||
<Link href="/moderation">Moderation</Link>
|
||||
</div>
|
||||
<p className="disclaimer">
|
||||
An independent community project. Not affiliated with Mojang or
|
||||
Microsoft.
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
export function EmptyState({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<Compass size={36} />
|
||||
<h3>{title}</h3>
|
||||
<p>{children}</p>
|
||||
<Link className="button primary" href="/downloads">
|
||||
Create the first map <ArrowUpRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
components/SignIn.tsx
Normal file
69
components/SignIn.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { Mail } from "lucide-react";
|
||||
export function SignIn() {
|
||||
const [email, setEmail] = useState(""),
|
||||
[message, setMessage] = useState(""),
|
||||
[error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const client = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
const { error } = await client.auth.signInWithOtp({
|
||||
email,
|
||||
options: { emailRedirectTo: window.location.origin + "/auth/callback" },
|
||||
});
|
||||
if (error) throw error;
|
||||
setMessage(
|
||||
"Check your inbox for a sign-in link. You can return here after opening it.",
|
||||
);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="auth-card">
|
||||
<span className="eyebrow">YOUR CARTOGRAPHER ACCOUNT</span>
|
||||
<h2>Welcome, explorer.</h2>
|
||||
<p className="muted">
|
||||
Sign in to share maps and keep your expeditions. No password needed.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<label className="field">
|
||||
Email address
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="button primary full" disabled={busy}>
|
||||
<Mail size={16} />
|
||||
{busy ? "Sending…" : "Send me a sign-in link"}
|
||||
</button>
|
||||
</form>
|
||||
{message && (
|
||||
<p role="status" className="success-message">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
components/StartGame.tsx
Normal file
79
components/StartGame.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Timer, ZoomIn } from "lucide-react";
|
||||
import { difficulties, type Difficulty } from "@/shared/protocol";
|
||||
export async function api<T = Record<string, unknown>>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
method = body ? "POST" : "GET",
|
||||
): Promise<T> {
|
||||
const res = await fetch("/api/v1/" + path, {
|
||||
method,
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Request failed");
|
||||
return data;
|
||||
}
|
||||
export function StartGame({ datasetId }: { datasetId: string }) {
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
|
||||
[loading, setLoading] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
async function start() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const session = await api<{ id: string }>("sessions", {
|
||||
datasetId,
|
||||
difficulty,
|
||||
});
|
||||
router.push("/play/" + session.id);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="start-panel">
|
||||
<span className="eyebrow">YOUR EXPEDITION</span>
|
||||
<h2>Choose your challenge.</h2>
|
||||
<div className="difficulty-options">
|
||||
{Object.entries(difficulties).map(([key, value]) => (
|
||||
<button
|
||||
key={key}
|
||||
aria-pressed={difficulty === key}
|
||||
className={difficulty === key ? "selected" : ""}
|
||||
onClick={() => setDifficulty(key as Difficulty)}
|
||||
>
|
||||
<strong>{value.label}</strong>
|
||||
<span>
|
||||
<Timer size={13} />
|
||||
{value.seconds}s per round
|
||||
</span>
|
||||
<span>
|
||||
<ZoomIn size={13} />
|
||||
{value.zoom ? "Zoom allowed" : "No zoom"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="muted">5 rounds · 25,000 possible points · No movement</p>
|
||||
<button
|
||||
className="button primary full"
|
||||
onClick={start}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Preparing your world…" : "Start exploring"}
|
||||
<ArrowRight size={17} />
|
||||
</button>
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user