Improve MineGeo exploration and mapping features
This commit is contained in:
152
components/AccountSettings.tsx
Normal file
152
components/AccountSettings.tsx
Normal 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
13
components/Brand.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
components/ContributionInstructions.tsx
Normal file
54
components/ContributionInstructions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
236
components/OwnerContributions.tsx
Normal file
236
components/OwnerContributions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
148
components/PasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user