b6770d394c
Collapse the game UI to one route (`/`): a screen dispatcher renders login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of URL routes. Move screen components to lib/screens & lib/game; the game shell reads the game id from `appScreen.gameId` and re-inits per-game stores via an $effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now state-based. WIP: browser-history (Back→lobby), restore-validation, the return-to-lobby button, push deep-links, and the test migration are follow-ups on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
346 lines
7.2 KiB
Svelte
346 lines
7.2 KiB
Svelte
<script lang="ts">
|
|
import { appScreen } from "$lib/app-nav.svelte";
|
|
import {
|
|
AuthError,
|
|
confirmEmailCode,
|
|
sendEmailCode,
|
|
} from "../../api/auth";
|
|
import { GATEWAY_BASE_URL } from "$lib/env";
|
|
import { i18n, SUPPORTED_LOCALES } from "$lib/i18n/index.svelte";
|
|
import { session } from "$lib/session-store.svelte";
|
|
|
|
type Step = "email" | "code";
|
|
|
|
let step: Step = $state("email");
|
|
let email = $state("");
|
|
let code = $state("");
|
|
let challengeId: string | null = $state(null);
|
|
let pending = $state(false);
|
|
let error: string | null = $state(null);
|
|
|
|
// Safari ignores `autocomplete="off"` on type=email / login-shaped
|
|
// fields and pops the Keychain suggester regardless. The classic
|
|
// workaround is to render the input as `readonly` initially —
|
|
// Safari does not autofill readonly fields — and drop the
|
|
// attribute on the first user focus so typing still works. Once
|
|
// dropped, the flag stays false for the rest of the page life.
|
|
let emailReadonly = $state(true);
|
|
let codeReadonly = $state(true);
|
|
|
|
function describe(err: unknown): string {
|
|
if (err instanceof AuthError) {
|
|
return err.message;
|
|
}
|
|
if (err instanceof Error) {
|
|
return err.message;
|
|
}
|
|
return "request failed";
|
|
}
|
|
|
|
async function submitEmail(event: Event): Promise<void> {
|
|
event.preventDefault();
|
|
if (pending) return;
|
|
const trimmed = email.trim();
|
|
if (trimmed.length === 0) {
|
|
error = i18n.t("login.email_required");
|
|
return;
|
|
}
|
|
pending = true;
|
|
error = null;
|
|
try {
|
|
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
|
|
locale: i18n.locale,
|
|
});
|
|
challengeId = result.challengeId;
|
|
code = "";
|
|
step = "code";
|
|
} catch (err) {
|
|
error = describe(err);
|
|
} finally {
|
|
pending = false;
|
|
}
|
|
}
|
|
|
|
async function submitCode(event: Event): Promise<void> {
|
|
event.preventDefault();
|
|
if (pending) return;
|
|
const trimmedCode = code.trim();
|
|
if (trimmedCode.length === 0) {
|
|
error = i18n.t("login.code_required");
|
|
return;
|
|
}
|
|
if (challengeId === null) {
|
|
error = i18n.t("login.challenge_expired");
|
|
step = "email";
|
|
return;
|
|
}
|
|
if (session.keypair === null) {
|
|
error = i18n.t("login.device_key_not_ready");
|
|
return;
|
|
}
|
|
pending = true;
|
|
error = null;
|
|
try {
|
|
const result = await confirmEmailCode(GATEWAY_BASE_URL, {
|
|
challengeId,
|
|
code: trimmedCode,
|
|
publicKey: session.keypair.publicKey,
|
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
});
|
|
await session.signIn(result.deviceSessionId);
|
|
appScreen.go("lobby");
|
|
} catch (err) {
|
|
if (err instanceof AuthError && err.code === "invalid_request") {
|
|
challengeId = null;
|
|
code = "";
|
|
step = "email";
|
|
error = i18n.t("login.code_expired_or_used");
|
|
} else {
|
|
error = describe(err);
|
|
}
|
|
} finally {
|
|
pending = false;
|
|
}
|
|
}
|
|
|
|
async function resend(): Promise<void> {
|
|
if (pending) return;
|
|
const trimmed = email.trim();
|
|
if (trimmed.length === 0) {
|
|
step = "email";
|
|
return;
|
|
}
|
|
pending = true;
|
|
error = null;
|
|
try {
|
|
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
|
|
locale: i18n.locale,
|
|
});
|
|
challengeId = result.challengeId;
|
|
code = "";
|
|
} catch (err) {
|
|
error = describe(err);
|
|
} finally {
|
|
pending = false;
|
|
}
|
|
}
|
|
|
|
function changeEmail(): void {
|
|
challengeId = null;
|
|
code = "";
|
|
error = null;
|
|
step = "email";
|
|
}
|
|
</script>
|
|
|
|
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
|
|
<main id="main-content" tabindex="-1">
|
|
<header>
|
|
<h1>{i18n.t("login.title")}</h1>
|
|
<div class="language-picker">
|
|
<svg
|
|
class="globe"
|
|
viewBox="0 0 24 24"
|
|
width="20"
|
|
height="20"
|
|
aria-hidden="true"
|
|
focusable="false"
|
|
>
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="9"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
/>
|
|
<path
|
|
d="M3 12h18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
/>
|
|
<path
|
|
d="M12 3a13 13 0 0 1 0 18M12 3a13 13 0 0 0 0 18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
/>
|
|
</svg>
|
|
<label class="sr-only" for="login-language-select">
|
|
{i18n.t("common.language")}
|
|
</label>
|
|
<select
|
|
id="login-language-select"
|
|
data-testid="login-language-select"
|
|
bind:value={i18n.locale}
|
|
>
|
|
{#each SUPPORTED_LOCALES as locale (locale.code)}
|
|
<option value={locale.code}>{locale.nativeName}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
</header>
|
|
|
|
{#if step === "email"}
|
|
<form
|
|
onsubmit={submitEmail}
|
|
aria-busy={pending}
|
|
autocomplete="off"
|
|
>
|
|
<label>
|
|
{i18n.t("login.email_label")}
|
|
<input
|
|
type="email"
|
|
name="galaxy-login-email"
|
|
autocomplete="new-password"
|
|
autocorrect="off"
|
|
autocapitalize="off"
|
|
spellcheck="false"
|
|
readonly={emailReadonly}
|
|
onfocus={() => (emailReadonly = false)}
|
|
bind:value={email}
|
|
disabled={pending}
|
|
required
|
|
data-testid="login-email-input"
|
|
/>
|
|
</label>
|
|
<button
|
|
type="submit"
|
|
disabled={pending}
|
|
data-testid="login-email-submit"
|
|
>
|
|
{pending ? i18n.t("login.sending") : i18n.t("login.send_code")}
|
|
</button>
|
|
</form>
|
|
{:else}
|
|
<form
|
|
onsubmit={submitCode}
|
|
aria-busy={pending}
|
|
autocomplete="off"
|
|
>
|
|
<p data-testid="login-code-target">
|
|
{i18n.t("login.code_sent_to", { email })}
|
|
</p>
|
|
<label>
|
|
{i18n.t("login.code_label")}
|
|
<input
|
|
type="text"
|
|
name="galaxy-login-code"
|
|
inputmode="numeric"
|
|
autocomplete="new-password"
|
|
autocorrect="off"
|
|
autocapitalize="off"
|
|
spellcheck="false"
|
|
readonly={codeReadonly}
|
|
onfocus={() => (codeReadonly = false)}
|
|
bind:value={code}
|
|
disabled={pending}
|
|
required
|
|
data-testid="login-code-input"
|
|
/>
|
|
</label>
|
|
<button type="submit" disabled={pending} data-testid="login-code-submit">
|
|
{pending ? i18n.t("login.verifying") : i18n.t("login.verify")}
|
|
</button>
|
|
<div class="secondary">
|
|
<button
|
|
type="button"
|
|
onclick={resend}
|
|
disabled={pending}
|
|
data-testid="login-resend"
|
|
>
|
|
{i18n.t("login.send_new_code")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={changeEmail}
|
|
disabled={pending}
|
|
data-testid="login-change-email"
|
|
>
|
|
{i18n.t("login.change_email")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if error !== null}
|
|
<p role="alert" data-testid="login-error">{error}</p>
|
|
{/if}
|
|
</main>
|
|
|
|
<style>
|
|
main {
|
|
padding: 2rem;
|
|
font-family: system-ui, sans-serif;
|
|
max-width: 32rem;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
header h1 {
|
|
margin: 0;
|
|
}
|
|
|
|
.language-picker {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.globe {
|
|
flex: none;
|
|
}
|
|
|
|
.language-picker select {
|
|
font-size: 0.875rem;
|
|
padding: 0.25rem 0.5rem;
|
|
}
|
|
|
|
form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
form > label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
input {
|
|
font-size: 1rem;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
button {
|
|
font-size: 1rem;
|
|
padding: 0.5rem 1rem;
|
|
}
|
|
|
|
.secondary {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.secondary button {
|
|
font-size: 0.875rem;
|
|
padding: 0.25rem 0.75rem;
|
|
}
|
|
|
|
[role="alert"] {
|
|
margin-top: 1rem;
|
|
color: var(--color-danger);
|
|
}
|
|
</style>
|