The first commit, the core and all files is adding to repo

This commit is contained in:
2026-10-04 14:20:13 +05:00
commit 53d7b263c4
94 changed files with 10668 additions and 0 deletions

View 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
View 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 &lt;token&gt;</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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}