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

@@ -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>
);
}