Improve MineGeo exploration and mapping features

This commit is contained in:
2026-10-05 13:41:36 +05:00
parent aec0641f87
commit c5368fa8cc
69 changed files with 3581 additions and 333 deletions

286
app/account.css Normal file
View File

@@ -0,0 +1,286 @@
/* Shared account and authentication surfaces, layered after the Minecraft theme. */
.auth-page {
padding-top: 48px;
padding-bottom: 72px;
}
.auth-layout {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: clamp(32px, 7vw, 100px);
align-items: center;
max-width: 1120px;
margin: 0 auto;
}
.auth-story h1 {
font-size: clamp(42px, 4.4vw, 64px);
line-height: 1.05;
margin: 18px 0;
}
.auth-story > p {
color: var(--muted);
max-width: 420px;
line-height: 1.8;
font-size: 15px;
}
.auth-story-map {
position: relative;
height: 210px;
border: 1px solid #4b5c43;
margin: 30px 0 22px;
padding: 26px;
display: flex;
flex-direction: column;
justify-content: space-between;
color: #c0e7a2;
background:
linear-gradient(90deg, #101a1cd9, #11192388),
url("/images/example-map.webp") center/cover;
box-shadow: inset 0 0 70px #09121977;
}
.auth-story-map span {
font:
24px MineGeoPixel,
monospace;
}
.auth-benefits {
display: flex;
gap: 18px;
flex-wrap: wrap;
color: var(--muted);
font-size: 12px;
}
.auth-benefits span {
display: flex;
align-items: center;
gap: 8px;
}
.auth-benefits svg {
color: var(--green);
}
.auth-layout .auth-card {
width: 100%;
max-width: none;
margin: 0;
padding: 32px;
background: #121c28f5;
border: 1px solid #3a4958;
box-shadow: 0 20px 60px #0003;
}
.auth-card h2 {
font-size: 38px;
line-height: 1.1;
margin: 14px 0 10px;
}
.auth-card .field {
font-size: 13px;
margin: 18px 0;
}
.field small {
display: block;
margin-top: 8px;
font-size: 11px;
color: var(--muted);
line-height: 1.6;
}
.auth-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
background: #0a121c;
padding: 4px;
border: 1px solid var(--line);
margin-bottom: 28px;
}
.auth-tabs button {
padding: 10px 8px;
border: 0;
color: var(--muted);
font-size: 13px;
cursor: pointer;
}
.auth-tabs button[aria-pressed="true"] {
color: var(--text);
background: #223342;
box-shadow: 0 2px 7px #0004;
}
.auth-recovery {
display: block;
margin: -4px 0 22px auto;
font-size: 12px;
}
.auth-card .text-link {
cursor: pointer;
color: var(--green);
}
.auth-alternative {
border-top: 1px solid var(--line);
padding-top: 20px;
margin-top: 24px;
text-align: center;
display: grid;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.auth-footnote {
color: var(--muted);
font-size: 11px;
line-height: 1.6;
text-align: center;
margin: 24px 0 0;
}
.auth-back {
display: block;
margin: 22px 0 0;
font-size: 12px;
}
.auth-card input,
.settings-card input {
min-height: 46px;
border-radius: 3px;
}
.reset-card {
max-width: 520px;
padding: 32px;
margin: 24px auto;
}
.reset-card h1 {
font-size: 46px;
}
.account-page-heading {
margin: 28px 0 36px;
}
.account-page-heading h1 {
margin: 10px 0;
}
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: start;
}
.settings-card {
border: 1px solid #3a4958;
background: #121c28e8;
padding: 28px;
box-shadow: 0 12px 30px #0002;
min-width: 0;
}
.settings-heading {
display: flex;
align-items: flex-start;
gap: 14px;
border-bottom: 1px solid var(--line);
padding-bottom: 20px;
}
.settings-heading > svg {
margin-top: 6px;
color: var(--green);
flex: none;
}
.settings-heading h2 {
font-size: 32px;
line-height: 1.2;
margin-bottom: 6px;
}
.settings-heading p {
font-size: 12px;
margin: 0;
}
.account-email {
display: flex;
gap: 12px;
margin: 24px 0;
padding: 16px;
border: 1px solid var(--line);
background: #0d1520;
min-width: 0;
}
.account-email svg {
flex: none;
color: var(--muted);
margin-top: 4px;
}
.account-email div {
min-width: 0;
display: grid;
gap: 5px;
}
.account-email span,
.account-email small {
font-size: 11px;
color: var(--muted);
}
.account-email strong {
font-weight: 400;
overflow-wrap: anywhere;
}
.settings-recovery {
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid var(--line);
}
.settings-recovery h3 {
font-size: 24px;
margin-bottom: 8px;
}
.settings-recovery p {
font-size: 12px;
}
.map-credit {
color: var(--muted);
font-size: 12px;
margin: 14px 0 0;
}
.dashboard-toolbar .inline-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
@media (max-width: 850px) {
.auth-layout {
grid-template-columns: 1fr;
max-width: 520px;
gap: 24px;
}
.auth-story-map,
.auth-benefits {
display: none;
}
.auth-story h1 {
font-size: 42px;
margin: 12px 0;
}
.auth-story > p {
margin-bottom: 0;
font-size: 13px;
}
.settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 500px) {
.auth-page {
padding-top: 28px;
}
.auth-layout .auth-card,
.settings-card,
.reset-card {
padding: 22px;
}
.auth-story h1 {
font-size: 36px;
}
.auth-card h2 {
font-size: 32px;
}
.dashboard-toolbar {
flex-direction: column;
}
.dashboard-toolbar .inline-actions {
justify-content: flex-start;
}
.preview-toolbar {
padding: 10px 16px;
gap: 8px;
}
}

View File

@@ -1,13 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { authClient } from "@/lib/supabase";
import { requestOrigin } from "@/lib/request-origin";
import { authDestination } from "@/shared/account";
import { createRecoveryGrant, recoveryCookie } from "@/lib/password-recovery";
import { accountProfile } from "@/lib/profile";
export async function GET(request: NextRequest) {
const origin = requestOrigin(request, process.env.MINEGEO_URL);
const code = request.nextUrl.searchParams.get("code");
if (code) {
const tokenHash = request.nextUrl.searchParams.get("token_hash");
const type = request.nextUrl.searchParams.get("type");
const next = authDestination(request.nextUrl.searchParams.get("next"));
if (
code ||
(tokenHash &&
(type === "email" || type === "recovery" || type === "magiclink"))
) {
const client = await authClient();
const { error } = await client.auth.exchangeCodeForSession(code);
if (!error) return NextResponse.redirect(new URL("/dashboard", origin));
const { data, error } = code
? await client.auth.exchangeCodeForSession(code)
: await client.auth.verifyOtp({
token_hash: tokenHash!,
type: type as "email" | "recovery" | "magiclink",
});
if (!error && data.user) {
await accountProfile(data.user);
const destination = type === "recovery" ? "/auth/reset-password" : next;
const response = NextResponse.redirect(new URL(destination, origin));
if (destination === "/auth/reset-password") {
const { data: claims } = await client.auth.getClaims();
const sessionId = claims?.claims.session_id;
if (typeof sessionId === "string")
response.cookies.set(
recoveryCookie,
createRecoveryGrant(
data.user.id,
sessionId,
process.env.SUPABASE_SECRET_KEY!,
),
{
httpOnly: true,
sameSite: "lax",
secure: new URL(origin).protocol === "https:",
path: "/",
maxAge: 600,
},
);
}
return response;
}
}
return NextResponse.redirect(new URL("/dashboard?auth=failed", origin));
return NextResponse.redirect(new URL("/log-in?auth=failed", origin));
}

View File

@@ -0,0 +1 @@
export { GET } from "../callback/route";

View File

@@ -0,0 +1,56 @@
import { cookies } from "next/headers";
import Link from "next/link";
import { Header, Footer } from "@/components/Shell";
import { PasswordForm } from "@/components/PasswordForm";
import { authClient, user } from "@/lib/supabase";
import { recoveryCookie, verifyRecoveryGrant } from "@/lib/password-recovery";
export const metadata = { title: "Reset password" };
export default async function ResetPassword() {
const [account, jar, client] = await Promise.all([
user(),
cookies(),
authClient(),
]);
const { data } = account ? await client.auth.getClaims() : { data: null };
const sessionId = data?.claims.session_id;
const valid =
account &&
typeof sessionId === "string" &&
verifyRecoveryGrant(
jar.get(recoveryCookie)?.value,
account.id,
sessionId,
process.env.SUPABASE_SECRET_KEY!,
);
return (
<>
<Header />
<main className="section page-content">
<section className="auth-card reset-card">
<span className="eyebrow">ACCOUNT RECOVERY</span>
<h1>A fresh start.</h1>
{valid ? (
<>
<p className="muted">Choose a new password for your account.</p>
<PasswordForm recovery />
</>
) : (
<>
<p>
This reset link has expired or has already been used. Request a
fresh link to continue.
</p>
<Link
className="button primary"
href={account ? "/settings" : "/log-in"}
>
Request a new reset link →
</Link>
</>
)}
</section>
</main>
<Footer />
</>
);
}

View File

@@ -1,17 +1,22 @@
import { Header, Footer } from "@/components/Shell";
import { DraftEditor } from "@/components/DraftEditor";
import { user } from "@/lib/supabase";
import { redirect } from "next/navigation";
export default async function Draft({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
if (!(await user()))
redirect(`/log-in?next=${encodeURIComponent("/dashboard/" + id)}`);
return (
<>
<Header />
<main className="section page-content">
<span className="eyebrow">YOUR WORLD, BEFORE THE WORLD SEES IT</span>
<h1>At the drawing board.</h1>
<DraftEditor id={(await params).id} />
<DraftEditor id={id} />
</main>
<Footer />
</>

View File

@@ -3,9 +3,11 @@ import { SignIn } from "@/components/SignIn";
import { Dashboard } from "@/components/Dashboard";
import { admin, user, required } from "@/lib/supabase";
import Link from "next/link";
import { accountProfile } from "@/lib/profile";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const account = await user();
const profile = account ? await accountProfile(account) : null;
const history = account
? required(
await admin()
@@ -24,6 +26,7 @@ export default async function DashboardPage() {
<>
<span className="eyebrow">THE CARTOGRAPHER’S DESK</span>
<h1>My expedition.</h1>
<p className="lead">Welcome back, {profile?.display_name}.</p>
<Dashboard />
{history.length > 0 && (
<section className="token-box">

View File

@@ -31,8 +31,12 @@ export default function Downloads() {
<li>Minecraft Java 1.21.11 + Fabric Loader + Fabric API</li>
<li>Open the capture panel with G</li>
<li>Link your account through a browser pairing code</li>
<li>Capture at least five viewpoints, then upload</li>
<li>Capture 5–24 viewpoints for a new map, then upload</li>
<li>
Paste a map ID into “Add to map” to contribute 1–12 panoramas
</li>
<li>Only terrain received by your client enters the map</li>
<li>Contributions check the recorded server address</li>
</ul>
{fabric ? (
<a
@@ -61,8 +65,12 @@ export default function Downloads() {
<ul>
<li>Bukkit/Paper 1.21.11 · Java 21</li>
<li>Select a bounded area and export existing chunks</li>
<li>Automatically choose safe surface viewpoints</li>
<li>Choose up to 24 random, safe surface viewpoints</li>
<li>
Use /minegeo contribute &lt;map ID&gt; to join an existing map
</li>
<li>Link a contributor token and upload resumably</li>
<li>Set the public Minecraft server address before exporting</li>
<li>Vanilla blocks/fluids, simplified lighting</li>
</ul>
{plugin ? (
@@ -78,7 +86,7 @@ export default function Downloads() {
)}
<pre>
{
"/minegeo website https://your-minegeo-site.example\n/minegeo link <dashboard token>\n/minegeo area world -64 -64 64 64\n/minegeo export My Overworld\n/minegeo status\n# Wait for Export complete, then:\n/minegeo upload"
"/minegeo website https://your-minegeo-site.example\n/minegeo server play.your-server.example\n/minegeo link <dashboard token>\n/minegeo area world -64 -64 64 64\n/minegeo export My Overworld\n/minegeo status\n# Wait for Export complete, then:\n/minegeo upload"
}
</pre>
<p className="muted">
@@ -88,9 +96,10 @@ export default function Downloads() {
completed export; it does not capture the world.
</p>
<p className="muted" style={{ fontSize: 11 }}>
Put the JAR in plugins/ and restart. Set website and server-name
in plugins/MineGeo/config.yml, then run /minegeo reload. HTTP LAN
addresses are supported for testing. Permission: minegeo.admin.
Put the JAR in plugins/ and restart. Set website, server-name and
server-address in plugins/MineGeo/config.yml, then run /minegeo
reload. HTTP LAN addresses are supported for testing. Permission:
minegeo.admin.
</p>
</article>
</div>
@@ -98,8 +107,9 @@ export default function Downloads() {
<div>
<h2>Your map, your credit.</h2>
<p>
Uploaders are credited separately. Plugin exports and player
captures stay clearly labeled.
Map owners can allow player contributions. Each player may add up
to 12 panoramas per map. Owners approve the selection, then
moderators review the map update before it becomes playable.
</p>
</div>
<Link className="button" href="/dashboard">

View File

@@ -1457,32 +1457,173 @@ pre {
}
/* Contributor and moderator previews use the same projection as the game. */
.panorama-preview {
width: min(1100px, 96vw);
max-width: 96vw;
padding: 16px;
background: #0b1119;
color: #edf2ef;
border: 1px solid #58684b;
position: fixed;
inset: 0;
margin: auto;
width: min(1180px, calc(100vw - 64px));
height: min(820px, calc(100dvh - 64px));
max-width: none;
max-height: calc(100dvh - 32px);
padding: 0;
overflow: hidden;
background: var(--panel);
color: var(--text);
border: 1px solid #455663;
box-shadow: 0 30px 100px #0009;
}
.panorama-preview[open] {
display: flex;
flex-direction: column;
}
.panorama-preview.is-expanded {
width: calc(100vw - 24px);
height: calc(100dvh - 24px);
max-height: none;
}
.panorama-preview::backdrop {
background: #000b;
background: #03070edb;
backdrop-filter: blur(6px);
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--line);
flex: none;
}
.preview-header h2 {
font-size: 34px;
line-height: 1;
margin: 5px 0 0;
}
.preview-header .eyebrow {
font-size: 10px;
}
.preview-header-actions,
.preview-navigation {
display: flex;
gap: 8px;
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
flex: none;
border: 1px solid #40505d;
background: #1b2834;
color: var(--text);
cursor: pointer;
border-radius: 4px;
}
.icon-button:hover {
background: #2b3d4a;
}
.icon-button:disabled {
opacity: 0.35;
cursor: default;
}
:where(button, a, input, textarea, select):focus-visible {
outline: 2px solid var(--green);
outline-offset: 4px;
}
.preview-location {
display: flex;
gap: 12px;
align-items: center;
min-width: 0;
font-size: 12px;
color: var(--muted);
}
.preview-counter {
margin-left: auto;
color: var(--muted);
font: 12px monospace;
white-space: nowrap;
}
.preview-footer {
padding: 15px 24px;
color: var(--muted);
font-size: 12px;
display: flex;
gap: 18px;
justify-content: space-between;
flex: none;
}
.preview-footer span:first-child {
display: flex;
align-items: center;
gap: 8px;
}
.preview-viewport .panorama-controls {
right: 18px;
bottom: 18px;
}
.preview-viewport .panorama-controls button {
width: 42px;
height: 42px;
background: #0d1724e8;
}
.preview-viewport .panorama {
cursor: grab;
}
.preview-viewport .panorama:active {
cursor: grabbing;
}
.preview-status {
margin: 0;
text-align: center;
}
@media (max-width: 700px) {
.panorama-preview {
width: calc(100vw - 20px);
height: calc(100dvh - 20px);
max-height: none;
}
.preview-header {
padding: 16px;
}
.preview-header h2 {
font-size: 28px;
}
.preview-footer {
padding: 12px 16px;
flex-direction: column;
gap: 5px;
font-size: 11px;
}
.preview-location {
flex: 1;
}
.preview-location > span {
display: none;
}
}
.preview-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
gap: 14px;
padding: 12px 24px;
flex: none;
}
.preview-toolbar select {
background: #141e27;
color: inherit;
padding: 8px;
color: var(--text);
border: 1px solid #40505d;
border-radius: 4px;
padding: 10px 12px;
max-width: 300px;
min-width: 0;
width: 100%;
}
.preview-viewport {
position: relative;
height: min(65vh, 650px);
flex: 1;
min-height: 140px;
overflow: hidden;
background: #080f18;
}
.preview-status {
position: absolute;

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import "./globals.css";
import "./block-theme.css";
import "./account.css";
export const metadata: Metadata = {
title: {
default: "MineGeo — Get lost in a familiar world",

32
app/log-in/page.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { Header, Footer } from "@/components/Shell";
import { SignIn } from "@/components/SignIn";
import { user } from "@/lib/supabase";
import { authDestination } from "@/shared/account";
export const metadata = { title: "Log in" };
export default async function LogIn({
searchParams,
}: {
searchParams: Promise<{ next?: string; auth?: string }>;
}) {
const query = await searchParams;
const next = authDestination(query.next);
if (await user())
redirect(next === "/auth/reset-password" ? "/settings" : next);
return (
<>
<Header />
<main className="section page-content auth-page">
<SignIn
next={next}
initialError={
query.auth === "failed"
? "This sign-in link expired or could not be verified. Request a new link below."
: ""
}
/>
</main>
<Footer />
</>
);
}

View File

@@ -18,7 +18,13 @@ export default async function Pair({
<p className="muted">
Sign in first, then reopen the pairing link from Minecraft.
</p>
<SignIn />
<SignIn
next={
code && /^[A-F0-9]{8}$/.test(code)
? `/pair?code=${code}`
: "/dashboard"
}
/>
</>
) : code && /^[A-F0-9]{8}$/.test(code) ? (
<Pairing code={code} />

View File

@@ -3,6 +3,7 @@ import Image from "next/image";
import { Header, Footer } from "@/components/Shell";
import { StartGame } from "@/components/StartGame";
import { getMap } from "@/lib/catalog";
import { ContributionInstructions } from "@/components/ContributionInstructions";
export default async function ServerPage({
params,
}: {
@@ -25,6 +26,10 @@ export default async function ServerPage({
</span>
<h1>{map.name}</h1>
<p className="lead">{map.description}</p>
<p className="map-credit">Created by {map.credit}</p>
<p className="muted">
Map ID: <code>{map.id}</code>
</p>
<div className="detail-grid">
<div>
<div className="detail-map">
@@ -47,6 +52,13 @@ export default async function ServerPage({
</div>
<StartGame datasetId={id} />
</div>
{map.allow_contributions && (
<ContributionInstructions
id={id}
bounds={map.bounds}
serverAddress={map.contribution_server_address}
/>
)}
</main>
<Footer />
</>

40
app/settings/page.tsx Normal file
View File

@@ -0,0 +1,40 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { Header, Footer } from "@/components/Shell";
import { AccountSettings } from "@/components/AccountSettings";
import { user } from "@/lib/supabase";
import { accountProfile } from "@/lib/profile";
export const metadata = { title: "Account settings" };
export default async function Settings({
searchParams,
}: {
searchParams: Promise<{ password?: string }>;
}) {
const account = await user();
if (!account) redirect("/log-in?next=%2Fsettings");
const [profile, query] = await Promise.all([
accountProfile(account),
searchParams,
]);
return (
<>
<Header />
<main className="section page-content">
<Link className="text-link" href="/dashboard">
← My account
</Link>
<div className="account-page-heading">
<span className="eyebrow">YOUR CARTOGRAPHER ACCOUNT</span>
<h1>Account settings.</h1>
<p className="lead">Your name, your sign-in, your next expedition.</p>
</div>
<AccountSettings
email={account.email ?? ""}
displayName={profile.display_name}
passwordUpdated={query.password === "updated"}
/>
</main>
<Footer />
</>
);
}