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

View File

@@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { UserRound, LockKeyhole, Mail } from "lucide-react";
import { api } from "./StartGame";
import { PasswordForm } from "./PasswordForm";
import { displayNameSchema } from "@/shared/account";
export function AccountSettings({
email,
displayName,
passwordUpdated = false,
}: {
email: string;
displayName: string;
passwordUpdated?: boolean;
}) {
const [name, setName] = useState(displayName),
[message, setMessage] = useState(""),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
const [recoveryMessage, setRecoveryMessage] = useState(""),
[recoveryError, setRecoveryError] = useState(""),
[sending, setSending] = useState(false);
const router = useRouter();
async function save(event: React.FormEvent) {
event.preventDefault();
setError("");
setMessage("");
const valid = displayNameSchema.safeParse(name);
if (!valid.success) {
setError(valid.error.issues[0].message);
return;
}
setBusy(true);
try {
const data = await api<{ profile: { display_name: string } }>(
"profile",
{ displayName: valid.data },
"PATCH",
);
setName(data.profile.display_name);
setMessage("Display name saved.");
router.refresh();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function recover() {
setSending(true);
setRecoveryError("");
try {
const data = await api<{ message: string }>("auth", {
action: "recover",
email,
});
setRecoveryMessage(data.message);
} catch (e) {
setRecoveryError((e as Error).message);
} finally {
setSending(false);
}
}
return (
<div className="settings-grid">
<section className="settings-card">
<div className="settings-heading">
<UserRound size={22} />
<div>
<h2>Your public profile.</h2>
<p className="muted">A name for the maps and places you share.</p>
</div>
</div>
<form onSubmit={save}>
<label className="field">
Display name
<input
name="displayName"
autoComplete="nickname"
value={name}
onChange={(e) => setName(e.target.value)}
minLength={2}
maxLength={32}
required
disabled={busy}
/>
<small>
2–32 characters. Display names do not have to be unique.
</small>
</label>
<div className="account-email">
<Mail size={17} />
<div>
<span>Email address</span>
<strong>{email}</strong>
<small>Used for sign-in and recovery. Only you can see it.</small>
</div>
</div>
{error && (
<p role="alert" className="error-message">
{error}
</p>
)}
{message && (
<p role="status" className="success-message">
{message}
</p>
)}
<button className="button primary" disabled={busy}>
{busy ? "Saving…" : "Save display name"}
</button>
</form>
</section>
<section className="settings-card">
<div className="settings-heading">
<LockKeyhole size={22} />
<div>
<h2>Password & security.</h2>
<p className="muted">Keep your account yours.</p>
</div>
</div>
{passwordUpdated && (
<p role="status" className="success-message">
Your password has been updated.
</p>
)}
<PasswordForm />
<div className="settings-recovery">
<h3>Signed up with an email link?</h3>
<p className="muted">
Set your first password, or recover a forgotten one, using a link
sent to your account email.
</p>
<button className="button" disabled={sending} onClick={recover}>
{sending ? "Sending…" : "Email me a password reset link"}
</button>
{recoveryMessage && (
<p role="status" className="success-message">
{recoveryMessage}
</p>
)}
{recoveryError && (
<p role="alert" className="error-message">
{recoveryError}
</p>
)}
</div>
</section>
</div>
);
}

13
components/Brand.tsx Normal file
View File

@@ -0,0 +1,13 @@
import Link from "next/link";
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>
);
}

View File

@@ -0,0 +1,54 @@
import Link from "next/link";
export function ContributionInstructions({
id,
bounds,
serverAddress,
}: {
id: string;
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
serverAddress: string | null;
}) {
return (
<div className="token-box">
<h3>Add panoramas to this map</h3>
{serverAddress ? (
<div>
<p>Contribution server:</p>
<code>{serverAddress}</code>
<p>
Capture on this server. Uploads from a different address are
blocked.
</p>
</div>
) : (
<p className="muted">
Server check is off. The owner has not set a server address; captures
from other servers can be submitted. Only submit panoramas from this
map’s world.
</p>
)}
<p>
Capture in the same Minecraft world, within X {bounds.minX} to{" "}
{bounds.maxX} and Z {bounds.minZ} to {bounds.maxZ}. Each player may add
up to 12 panoramas in total; the map holds at most 24, including pending
uploads.
</p>
<p>
In the Fabric mod, paste this map ID into “Add to map”. For the server
plugin, export the area and run:
</p>
<code>/minegeo contribute {id}</code>
<p>
Map ID: <code>{id}</code>
</p>
<p>
Open your dashboard after processing, inspect your panoramas, and submit
them to the owner. The owner approves them first, then moderators review
the map update.
</p>
<Link className="button" href="/downloads">
Get the capture tools →
</Link>
</div>
);
}

View File

@@ -74,9 +74,14 @@ export function Dashboard() {
<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 className="inline-actions">
<Link className="button" href="/settings">
Account settings
</Link>
<button className="button" onClick={logout}>
Sign out
</button>
</div>
</div>
{error && (
<p role="alert" className="error-message">
@@ -102,7 +107,13 @@ export function Dashboard() {
{m.source === "plugin" ? "SERVER EXPORT" : "PLAYER CAPTURE"}
</span>
<h3>{m.name}</h3>
<span className="badge">{m.state}</span>{" "}
<span className="badge">
{m.contribution_status
? `Contribution: ${m.contribution_status}`
: m.update_state
? `Update: ${m.update_state}`
: m.state}
</span>{" "}
<span className="muted">{m.panorama_count} locations</span>
<ProcessingProgress job={m.upload_jobs} />
{m.rejection_reason && (
@@ -113,7 +124,7 @@ export function Dashboard() {
)}
</div>
<Link href={"/dashboard/" + m.id} className="button">
Open draft →
{m.parent_dataset_id ? "Open contribution →" : "Open map →"}
</Link>
</article>
))}

View File

@@ -6,6 +6,7 @@ import type { DatasetManifest } from "@/shared/protocol";
import type { ProcessingJob } from "@/shared/processing-progress";
import { ProcessingProgress } from "./ProcessingProgress";
import { PanoramaPreview } from "./PanoramaPreview";
import { OwnerContributions } from "./OwnerContributions";
type Draft = {
id: string;
name: string;
@@ -15,8 +16,13 @@ type Draft = {
rejection_reason: string | null;
map: string | null;
manifest: DatasetManifest;
previews: { id: string; image: string | null }[];
previews: { id: string; label: string; image: string | null }[];
upload_jobs: ProcessingJob | null;
parent_dataset_id: string | null;
contribution_status: string | null;
allow_contributions: boolean;
contribution_server_address: string | null;
update_state: string | null;
};
export function DraftEditor({ id }: { id: string }) {
const [draft, setDraft] = useState<Draft | null>(null),
@@ -27,6 +33,7 @@ export function DraftEditor({ id }: { id: string }) {
[message, setMessage] = useState(""),
[busy, setBusy] = useState(false);
const [preview, setPreview] = useState<string | null>(null);
const [version, setVersion] = useState(0);
useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout>;
@@ -53,7 +60,7 @@ export function DraftEditor({ id }: { id: string }) {
disposed = true;
clearTimeout(timer);
};
}, [id]);
}, [id, version]);
async function save(submit = false) {
setBusy(true);
setError("");
@@ -66,8 +73,12 @@ export function DraftEditor({ id }: { id: string }) {
);
if (submit) {
await api("datasets/" + id + "/submit", {});
setDraft((d) => (d ? { ...d, state: "review" } : d));
setMessage("Submitted for moderator review.");
setVersion((v) => v + 1);
setMessage(
draft?.parent_dataset_id
? "Submitted for map owner approval."
: "Submitted for moderator review.",
);
} else setMessage("Draft saved.");
} catch (e) {
setError((e as Error).message);
@@ -79,6 +90,11 @@ export function DraftEditor({ id }: { id: string }) {
setBusy(true);
try {
await api("datasets/" + id + "/cancel", {});
if (draft?.parent_dataset_id) {
setVersion((v) => v + 1);
setMessage("Contribution withdrawn. Its pending quota is released.");
return;
}
setDraft((d) =>
d
? { ...d, state: "failed", rejection_reason: "Processing cancelled" }
@@ -95,10 +111,40 @@ export function DraftEditor({ id }: { id: string }) {
return (
<p role={error ? "alert" : undefined}>{error || "Loading your draft…"}</p>
);
const editable = ["draft", "rejected"].includes(draft.state);
const editable =
(["draft", "rejected"].includes(draft.state) ||
draft.state === "published") &&
draft.update_state !== "review" &&
!["submitted", "accepted", "rejected"].includes(
draft.contribution_status ?? "",
);
const minimum = draft.parent_dataset_id ? 1 : 5;
return (
<>
<span className="badge">{draft.state}</span>
{draft.update_state && (
<span className="badge">Update: {draft.update_state}</span>
)}
{draft.contribution_status && (
<p role="status">
Contribution: {draft.contribution_status}.{" "}
{draft.state === "failed"
? "This contribution no longer reserves panorama slots. Upload a fresh capture to try again."
: draft.contribution_status === "accepted"
? "The owner included your panoramas in the map draft. Map updates become playable after moderator approval."
: draft.contribution_status === "submitted"
? "Waiting for the map owner's approval."
: draft.contribution_status === "rejected"
? "Upload a fresh capture if you want to try again."
: "Choose your locations, then submit them to the map owner."}
</p>
)}
{draft.state === "published" && (
<p className="muted">
The approved version stays playable while you edit and moderators
review your update.
</p>
)}
<ProcessingProgress job={draft.upload_jobs} />
{["uploading", "processing"].includes(draft.state) && (
<p role="status" className="success-message">
@@ -146,7 +192,8 @@ export function DraftEditor({ id }: { id: string }) {
</label>
<h2>Choose your locations.</h2>
<p className="muted">
Keep at least five clear, useful panoramas. Selected: {selected.length}
Keep at least {minimum} clear, useful panorama{minimum === 1 ? "" : "s"}
. Selected: {selected.length} / 24
</p>
<div className="preview-grid">
{draft.previews.map((p) => (
@@ -156,7 +203,7 @@ export function DraftEditor({ id }: { id: string }) {
type="checkbox"
checked={selected.includes(p.id)}
disabled={!editable || busy}
aria-label={`Keep location ${p.id}`}
aria-label={`Keep ${p.label}`}
onChange={(e) =>
setSelected((s) =>
e.target.checked
@@ -170,11 +217,11 @@ export function DraftEditor({ id }: { id: string }) {
src={p.image}
width={512}
height={512}
alt={`Viewpoint ${p.id}`}
alt={p.label}
unoptimized
/>
)}
<span>{p.id}</span>
<span>{p.label}</span>
</label>
{p.image && (
<button className="button full" onClick={() => setPreview(p.id)}>
@@ -195,23 +242,41 @@ export function DraftEditor({ id }: { id: string }) {
<div className="inline-actions">
<button
className="button"
disabled={busy || selected.length < 5}
disabled={busy || selected.length < minimum}
onClick={() => save()}
>
Save draft
</button>
<button
className="button primary"
disabled={busy || selected.length < 5}
disabled={busy || selected.length < minimum}
onClick={() => save(true)}
>
Submit for review →
{draft.parent_dataset_id
? "Submit to map owner →"
: "Submit for review →"}
</button>
</div>
)}
{["uploading", "processing"].includes(draft.state) && (
{!draft.parent_dataset_id && (
<OwnerContributions
id={id}
allowed={draft.allow_contributions}
serverAddress={draft.contribution_server_address}
bounds={draft.manifest.bounds}
locked={draft.state === "review" || draft.update_state === "review"}
onChange={() => setVersion((v) => v + 1)}
/>
)}
{(["uploading", "processing"].includes(draft.state) ||
(draft.parent_dataset_id &&
["pending", "submitted"].includes(
draft.contribution_status ?? "",
))) && (
<button className="button" disabled={busy} onClick={cancel}>
Cancel processing
{draft.parent_dataset_id
? "Withdraw contribution"
: "Cancel processing"}
</button>
)}
{error && (

View File

@@ -8,7 +8,7 @@ import {
Move,
MapPin,
} from "lucide-react";
import { Brand } from "./Shell";
import { Brand } from "./Brand";
import { Panorama, preloadFaces } from "./Panorama";
import { GuessMap, type Pin } from "./GuessMap";
import { api } from "./StartGame";

View File

@@ -8,6 +8,7 @@ export type CardMap = {
source: string;
panorama_count: number;
image?: string | null;
credit?: string;
server?: { name: string } | undefined;
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
};
@@ -34,6 +35,7 @@ export function MapCard({ map, index = 0 }: { map: CardMap; index?: number }) {
<ArrowUpRight size={20} />
</h3>
<p>{map.description}</p>
{map.credit && <p className="map-credit">By {map.credit}</p>}
<div className="card-meta">
<span>
<MapPin size={13} />

View File

@@ -8,6 +8,7 @@ type ReviewMap = {
name: string;
description: string;
state: string;
update_state: string | null;
source: string;
panorama_count: number;
map: string | null;
@@ -82,6 +83,9 @@ export function Moderation() {
{maps.map((m) => (
<article className="tool-card" key={m.id}>
<span className="badge">{m.state}</span>
{m.update_state === "review" && (
<span className="badge">Map update awaiting review</span>
)}
<h2>{m.name}</h2>
<p className="muted">
{m.source} · {m.panorama_count} locations · {m.description}
@@ -109,14 +113,16 @@ export function Moderation() {
<button className="button" onClick={() => setPreview(m.id)}>
Inspect 360° locations
</button>
{m.state === "review" ? (
{m.state === "review" || m.update_state === "review" ? (
<>
<button
className="button primary"
disabled={busy}
onClick={() => decide(m.id, "approve")}
>
Approve map
{m.update_state === "review"
? "Approve update"
: "Approve map"}
</button>
<button
className="button"
@@ -126,7 +132,8 @@ export function Moderation() {
Return with feedback
</button>
</>
) : (
) : null}
{m.state === "published" && (
<button
className="button"
disabled={busy}

View File

@@ -0,0 +1,236 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { api } from "./StartGame";
import { PanoramaPreview } from "./PanoramaPreview";
import { ContributionInstructions } from "./ContributionInstructions";
type Contribution = {
id: string;
name: string;
owner_id: string;
contributorName: string;
state: string;
contribution_status: string;
panorama_count: number;
contribution_count: number;
rejection_reason: string | null;
};
export function OwnerContributions({
id,
allowed,
serverAddress,
bounds,
locked,
onChange,
}: {
id: string;
allowed: boolean;
serverAddress: string | null;
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
locked: boolean;
onChange: () => void;
}) {
const [items, setItems] = useState<Contribution[]>([]),
[address, setAddress] = useState(serverAddress ?? ""),
[saved, setSaved] = useState(""),
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[preview, setPreview] = useState<string | null>(null),
[reasons, setReasons] = useState<Record<string, string>>({});
useEffect(() => {
setAddress(serverAddress ?? "");
}, [serverAddress]);
async function saveAddress(value: string) {
setBusy(true);
setError("");
setSaved("");
try {
await api(
`datasets/${id}/settings`,
{ contributionServerAddress: value.trim() || null },
"PATCH",
);
setAddress(value);
setSaved(
value.trim()
? "Server address saved."
: "Server check turned off. Submissions still need your approval.",
);
onChange();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
const load = useCallback(async () => {
try {
const result = await api<{ contributions: Contribution[] }>(
`contributions/${id}/inbox`,
);
setItems(result.contributions);
} catch (e) {
setError((e as Error).message);
}
}, [id]);
useEffect(() => {
load();
const timer = setInterval(load, 10000);
return () => clearInterval(timer);
}, [load]);
async function toggle(value: boolean) {
setBusy(true);
setError("");
try {
await api(
`datasets/${id}/settings`,
{ allowContributions: value },
"PATCH",
);
onChange();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function decide(child: string, decision: string) {
setBusy(true);
setError("");
try {
await api(`contributions/${id}/inbox/${child}`, {
decision,
reason: reasons[child],
});
await load();
onChange();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="token-box">
<h2>Panorama settings</h2>
<label>
<input
type="checkbox"
checked={allowed}
disabled={busy}
onChange={(e) => toggle(e.target.checked)}
/>{" "}
Allow players to add panoramas with you
</label>
<p className="muted">
Five random locations are picked for each game. Add up to 24 locations
to make repeat games more varied. You can use the map ID below to add
your own panoramas too.
</p>
<label className="field">
Contribution server address
<input
value={address}
placeholder="play.example.com:25565"
maxLength={255}
onChange={(e) => {
setAddress(e.target.value);
setSaved("");
}}
/>
</label>
<p className="muted">
Use the Minecraft hostname or IP players connect to. This address is
shown publicly when player contributions are enabled. The mod records
the connected server; plugin admins set server-address before exporting.
You can override the captured address here, or clear it to allow
unchecked submissions.
</p>
<div className="inline-actions">
<button
className="button"
disabled={busy}
onClick={() => saveAddress(address)}
>
Save server address
</button>
<button
className="button"
disabled={busy || !serverAddress}
onClick={() => saveAddress("")}
>
Clear address and turn off check
</button>
</div>
{saved && <p role="status">{saved}</p>}
<ContributionInstructions
id={id}
bounds={bounds}
serverAddress={serverAddress}
/>
<h3>Player submissions</h3>
{locked && (
<p className="muted">
Wait for the current moderator review before accepting another
submission.
</p>
)}
{!items.length && <p className="muted">No contributions yet.</p>}
{items.map((c) => (
<article className="tool-card" key={c.id}>
<h3>{c.name}</h3>
<span className="badge">
{c.state === "failed" ? "failed" : c.contribution_status}
</span>
<p>
{c.panorama_count || c.contribution_count} panoramas · {c.state}
</p>
<p className="muted">Contributor: {c.contributorName}</p>
{c.rejection_reason && <p>{c.rejection_reason}</p>}
{c.contribution_status === "submitted" && (
<>
<button className="button" onClick={() => setPreview(c.id)}>
Inspect submitted panoramas
</button>
<label className="field">
Feedback
<textarea
value={reasons[c.id] ?? ""}
onChange={(e) =>
setReasons((r) => ({ ...r, [c.id]: e.target.value }))
}
/>
</label>
<div className="inline-actions">
<button
className="button primary"
disabled={busy || locked}
onClick={() => decide(c.id, "accept")}
>
Accept into map draft
</button>
<button
className="button"
disabled={busy || locked || !reasons[c.id]?.trim()}
onClick={() => decide(c.id, "reject")}
>
Reject with feedback
</button>
</div>
</>
)}
</article>
))}
{preview && (
<PanoramaPreview
endpoint={`contributions/${id}/inbox/${preview}/panoramas`}
onClose={() => setPreview(null)}
/>
)}
{error && (
<p className="error-message" role="alert">
{error}
</p>
)}
</section>
);
}

View File

@@ -2,6 +2,14 @@
import { useEffect, useRef, useState } from "react";
import { Panorama } from "./Panorama";
import { api } from "./StartGame";
import {
ChevronLeft,
ChevronRight,
X,
Move,
Expand,
Minimize,
} from "lucide-react";
export function PanoramaPreview({
endpoint,
@@ -13,18 +21,34 @@ export function PanoramaPreview({
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const closeButton = useRef<HTMLButtonElement>(null);
const [expanded, setExpanded] = useState(false);
const [locations, setLocations] = useState<string[]>([]),
[selected, setSelected] = useState(initialId ?? ""),
[urls, setUrls] = useState<string[] | null>(null),
[ready, setReady] = useState(false),
[error, setError] = useState("");
const [labels, setLabels] = useState<Record<string, string>>({});
useEffect(() => {
dialog.current?.showModal();
const element = dialog.current;
const previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const overflow = document.body.style.overflow;
if (element && !element.open) element.showModal();
document.body.style.overflow = "hidden";
closeButton.current?.focus();
let disposed = false;
api<{ locations: string[] }>(endpoint)
api<{ locations: string[]; labels?: Record<string, string> }>(endpoint)
.then((d) => {
if (disposed) return;
if (!Array.isArray(d.locations))
throw new Error(
"Could not load the submitted locations. Please try again.",
);
setLocations(d.locations);
setLabels(d.labels ?? {});
setSelected((s) =>
d.locations.includes(s) ? s : (d.locations[0] ?? ""),
);
@@ -34,6 +58,9 @@ export function PanoramaPreview({
});
return () => {
disposed = true;
element?.close();
document.body.style.overflow = overflow;
previousFocus?.focus();
};
}, [endpoint]);
useEffect(() => {
@@ -57,40 +84,84 @@ export function PanoramaPreview({
return (
<dialog
ref={dialog}
className="panorama-preview"
aria-label="Inspect panorama"
onCancel={onClose}
className={`panorama-preview${expanded ? " is-expanded" : ""}`}
aria-labelledby="panorama-preview-title"
onCancel={(e) => {
e.preventDefault();
onClose();
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
const box = e.currentTarget.getBoundingClientRect();
if (
e.clientX < box.left ||
e.clientX > box.right ||
e.clientY < box.top ||
e.clientY > box.bottom
)
onClose();
}
}}
>
<header className="preview-header">
<div>
<span className="eyebrow">360° PANORAMA</span>
<h2 id="panorama-preview-title">A closer look.</h2>
</div>
<div className="preview-header-actions">
<button
className="icon-button"
aria-label={expanded ? "Restore preview size" : "Expand preview"}
onClick={() => setExpanded(!expanded)}
>
{expanded ? <Minimize size={19} /> : <Expand size={19} />}
</button>
<button
ref={closeButton}
className="icon-button"
aria-label="Close preview"
onClick={onClose}
>
<X size={21} />
</button>
</div>
</header>
<div className="preview-toolbar">
<label>
Location{" "}
<label className="preview-location">
<span>Location</span>
<select
aria-label="Preview location"
value={selected}
onChange={(e) => setSelected(e.target.value)}
>
{locations.map((p) => (
<option key={p}>{p}</option>
<option key={p} value={p}>
{labels[p] ?? p}
</option>
))}
</select>
</label>
<button
className="button"
disabled={index <= 0}
onClick={() => setSelected(locations[index - 1])}
>
Previous
</button>
<button
className="button"
disabled={index < 0 || index >= locations.length - 1}
onClick={() => setSelected(locations[index + 1])}
>
Next
</button>
<button className="button" onClick={onClose}>
Close preview
</button>
<span className="preview-counter">
{index >= 0 ? index + 1 : 0} / {locations.length}
</span>
<div className="preview-navigation">
<button
className="icon-button"
aria-label="Previous panorama"
disabled={index <= 0}
onClick={() => setSelected(locations[index - 1])}
>
<ChevronLeft size={21} />
</button>
<button
className="icon-button"
aria-label="Next panorama"
disabled={index < 0 || index >= locations.length - 1}
onClick={() => setSelected(locations[index + 1])}
>
<ChevronRight size={21} />
</button>
</div>
</div>
<div className="preview-viewport">
{urls && (
@@ -113,10 +184,13 @@ export function PanoramaPreview({
)
)}
</div>
<p className="muted">
Drag to look around · Scroll to zoom · Check for blocked views and
missing terrain.
</p>
<footer className="preview-footer">
<span>
<Move size={16} />
Drag to look around · Scroll to zoom
</span>
<span>Check for blocked views and missing terrain.</span>
</footer>
</dialog>
);
}

148
components/PasswordForm.tsx Normal file
View File

@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createBrowserClient } from "@supabase/ssr";
import { passwordUpdateSchema } from "@/shared/account";
export function PasswordForm({ recovery = false }: { recovery?: boolean }) {
const [current, setCurrent] = useState(""),
[password, setPassword] = useState(""),
[confirmation, setConfirmation] = useState(""),
[nonce, setNonce] = useState(""),
[needsNonce, setNeedsNonce] = useState(false),
[error, setError] = useState(""),
[message, setMessage] = useState(""),
[busy, setBusy] = useState(false);
const router = useRouter();
async function submit(event: React.FormEvent) {
event.preventDefault();
setError("");
setMessage("");
const body = {
password,
...(!recovery ? { currentPassword: current } : {}),
...(needsNonce ? { nonce } : {}),
};
const valid = passwordUpdateSchema.safeParse(body);
if (!valid.success) {
setError(valid.error.issues[0].message);
return;
}
if (password !== confirmation) {
setError("Your passwords do not match.");
return;
}
setBusy(true);
try {
const response = await fetch("/api/v1/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(valid.data),
signal: AbortSignal.timeout(30000),
});
const data = await response.json();
if (!response.ok) {
if (data.needsReauthentication) {
const client = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
const { error } = await client.auth.reauthenticate();
if (error) throw error;
setNeedsNonce(true);
setMessage(
"Check your email for a verification code, then enter it below to finish.",
);
return;
}
throw new Error(data.error || "Could not update your password");
}
setCurrent("");
setPassword("");
setConfirmation("");
setNonce("");
setNeedsNonce(false);
setMessage(data.message);
if (recovery) {
router.replace("/settings?password=updated");
router.refresh();
}
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<form onSubmit={submit} aria-busy={busy} className="password-form">
{!recovery && (
<label className="field">
Current password
<input
name="currentPassword"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
required
disabled={busy}
/>
</label>
)}
<label className="field">
New password
<input
name="newPassword"
type="password"
autoComplete="new-password"
minLength={12}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={busy}
/>
<small>
At least 12 characters. Use a unique password or passphrase.
</small>
</label>
<label className="field">
Confirm new password
<input
name="confirmPassword"
type="password"
autoComplete="new-password"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
required
disabled={busy}
/>
</label>
{needsNonce && (
<label className="field">
Email verification code
<input
inputMode="numeric"
autoComplete="one-time-code"
value={nonce}
onChange={(e) => setNonce(e.target.value)}
required
disabled={busy}
/>
</label>
)}
{error && (
<p role="alert" className="error-message">
{error}
</p>
)}
{message && (
<p role="status" className="success-message">
{message}
</p>
)}
<button className="button primary" disabled={busy}>
{busy ? "Updating…" : recovery ? "Set new password" : "Update password"}
</button>
</form>
);
}

View File

@@ -1,17 +1,10 @@
import "server-only";
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() {
import { user } from "@/lib/supabase";
import { Brand } from "./Brand";
export async function Header() {
const account = await user();
return (
<header className="site-header">
<div className="header-inner">
@@ -21,8 +14,11 @@ export function Header() {
<Link href="/downloads">Downloads</Link>
<Link href="/about">Guide</Link>
</nav>
<Link href="/dashboard" className="header-account">
My account <ArrowUpRight size={15} />
<Link
href={account ? "/dashboard" : "/log-in"}
className="header-account"
>
{account ? "My account" : "Log in"} <ArrowUpRight size={15} />
</Link>
</div>
</header>

View File

@@ -1,69 +1,267 @@
"use client";
import { useState } from "react";
import { createBrowserClient } from "@supabase/ssr";
import { Mail } from "lucide-react";
export function SignIn() {
const [email, setEmail] = useState(""),
import { useRouter } from "next/navigation";
import { ArrowRight, Compass, Mail, ShieldCheck } from "lucide-react";
import { api } from "./StartGame";
import { authActionSchema, authDestination } from "@/shared/account";
type Mode = "login" | "signup" | "recover" | "magic";
export function SignIn({
next = "/dashboard",
initialError = "",
}: {
next?: string;
initialError?: string;
}) {
const [mode, setMode] = useState<Mode>("login"),
[email, setEmail] = useState(""),
[name, setName] = useState(""),
[password, setPassword] = useState(""),
[confirmation, setConfirmation] = useState(""),
[message, setMessage] = useState(""),
[error, setError] = useState(""),
[error, setError] = useState(initialError),
[busy, setBusy] = useState(false);
const router = useRouter();
function changeMode(value: Mode) {
setMode(value);
setError("");
setMessage("");
setPassword("");
setConfirmation("");
}
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
setMessage("");
const body =
mode === "signup"
? { action: mode, email, password, displayName: name, next }
: mode === "login"
? { action: mode, email, password, next }
: mode === "magic"
? { action: mode, email, next }
: { action: mode, email };
const valid = authActionSchema.safeParse(body);
if (!valid.success) {
setError(valid.error.issues[0].message);
return;
}
if (mode === "signup" && password !== confirmation) {
setError("Your passwords do not match.");
return;
}
setBusy(true);
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.",
const result = await api<{ next?: string; message?: string }>(
"auth",
valid.data,
);
setPassword("");
setConfirmation("");
if (result.next) {
router.replace(authDestination(result.next));
router.refresh();
} else setMessage(result.message ?? "Check your email to continue.");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
const title = {
login: "Welcome back.",
signup: "Make yourself at home.",
recover: "Find your way back.",
magic: "An email, a way in.",
}[mode];
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}
<div className="auth-layout">
<aside className="auth-story">
<span className="eyebrow">YOUR NEXT EXPEDITION</span>
<h1>
A familiar world.
<br />
Your own perspective.
</h1>
<p>
Keep your journeys, share the places you know, and help build the next
map.
</p>
)}
{error && (
<p role="alert" className="error-message">
{error}
<div className="auth-story-map" aria-hidden="true">
<Compass size={52} />
<span>Every world has a story.</span>
</div>
<div className="auth-benefits">
<span>
<Compass size={18} />
Save your expeditions
</span>
<span>
<ShieldCheck size={18} />
Keep control of your maps
</span>
</div>
</aside>
<section className="auth-card">
<div className="auth-tabs" aria-label="Account options">
<button
type="button"
aria-pressed={mode === "login"}
disabled={busy}
onClick={() => changeMode("login")}
>
Log in
</button>
<button
type="button"
aria-pressed={mode === "signup"}
disabled={busy}
onClick={() => changeMode("signup")}
>
Create account
</button>
</div>
<span className="eyebrow">YOUR CARTOGRAPHER ACCOUNT</span>
<h2>{title}</h2>
<p className="muted">
{mode === "signup"
? "Choose the name people will see on your maps."
: mode === "recover"
? "We’ll email you a link to set a new password."
: mode === "magic"
? "For existing accounts. Open the link on this device to continue."
: "Log in with your email and password."}
</p>
)}
<form onSubmit={submit} aria-busy={busy}>
{mode === "signup" && (
<label className="field">
Display name
<input
name="displayName"
autoComplete="nickname"
minLength={2}
maxLength={32}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="How should we call you?"
required
disabled={busy}
/>
<small>2–32 characters. You can change it in settings.</small>
</label>
)}
<label className="field">
Email address
<input
name="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
disabled={busy}
/>
</label>
{(mode === "login" || mode === "signup") && (
<label className="field">
Password
<input
name="password"
type="password"
autoComplete={
mode === "signup" ? "new-password" : "current-password"
}
minLength={mode === "signup" ? 12 : 1}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={busy}
/>
{mode === "signup" && (
<small>
Use at least 12 characters. A unique passphrase works well.
</small>
)}
</label>
)}
{mode === "signup" && (
<label className="field">
Confirm password
<input
name="confirmPassword"
type="password"
autoComplete="new-password"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
required
disabled={busy}
/>
</label>
)}
{mode === "login" && (
<button
type="button"
className="text-link auth-recovery"
disabled={busy}
onClick={() => changeMode("recover")}
>
Forgot password?
</button>
)}
{error && (
<p role="alert" className="error-message">
{error}
</p>
)}
{message && (
<p role="status" className="success-message">
{message}
</p>
)}
<button className="button primary full" disabled={busy}>
{mode === "recover" || mode === "magic" ? (
<Mail size={17} />
) : (
<ArrowRight size={17} />
)}{" "}
{busy
? "Please wait…"
: {
login: "Log in",
signup: "Create account",
recover: "Send reset link",
magic: "Send sign-in link",
}[mode]}
</button>
</form>
{mode === "login" && (
<div className="auth-alternative">
<span>Already use email links?</span>
<button
type="button"
className="text-link"
disabled={busy}
onClick={() => changeMode("magic")}
>
Send me a sign-in link
</button>
</div>
)}
{(mode === "recover" || mode === "magic") && (
<button
type="button"
className="text-link auth-back"
disabled={busy}
onClick={() => changeMode("login")}
>
← Back to log in
</button>
)}
<p className="auth-footnote">
Your email stays private. Your display name appears with your maps.
</p>
</section>
</div>
);
}