Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@
"duel_share_action": "Challenge a friend on this grid",
"duel_share_creating": "Creating link…",
"duel_share_ready": "Here's your challenge link",
"duel_create_eyebrow": "New challenge",
"duel_create_title": "Start a duel in one click",
"duel_create_subtitle": "Generate a random grid and share the link with a friend — they'll play the same grid as you.",
"duel_create_button": "Create a grid to share",
"duel_create_share_hint": "The link expires in 30 days.",
"modal_rules_title": "How to play",
"modal_rules_intro": "Find the entity that satisfies both the row and column.",
"modal_rules_step_1": "Tap an empty cell.",
Expand Down
5 changes: 5 additions & 0 deletions web/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@
"duel_share_action": "Défier un ami sur cette grille",
"duel_share_creating": "Création du lien…",
"duel_share_ready": "Voici votre lien de défi",
"duel_create_eyebrow": "Nouveau défi",
"duel_create_title": "Lance un duel en un clic",
"duel_create_subtitle": "Générez une grille aléatoire et partagez le lien à un ami : il jouera la même grille que vous.",
"duel_create_button": "Créer une grille à partager",
"duel_create_share_hint": "Le lien expire dans 30 jours.",
"modal_rules_title": "Comment jouer",
"modal_rules_intro": "Trouvez l'entité qui satisfait à la fois la ligne et la colonne.",
"modal_rules_step_1": "Touchez une case vide.",
Expand Down
177 changes: 150 additions & 27 deletions web/src/lib/components/DuelView.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
<script lang="ts">
import Check from "lucide-svelte/icons/check";
import Copy from "lucide-svelte/icons/copy";
import Crown from "lucide-svelte/icons/crown";
import Share2 from "lucide-svelte/icons/share-2";
import Sparkles from "lucide-svelte/icons/sparkles";
import Swords from "lucide-svelte/icons/swords";
import * as m from "../../paraglide/messages.js";
import { api, ApiError } from "../api/client.js";
import { copyToClipboard, shareNative } from "../share.js";
import Grid from "./Grid.svelte";

// Inline structural type — the openapi-typescript output uses anonymous
Expand All @@ -17,41 +22,49 @@
status: "active" | "won" | "lost" | "abandoned";
};

/**
* Three screens behind one route:
* - `landing`: /duel hit without ?id= — show "create a fresh duel" CTA.
* - `view`: /duel?id=…&sig=… valid — show the duel summary + play CTA.
* - `playing`: mounted Grid in duel mode.
* Errors flip the screen to `landing` after showing a banner.
*/
type Screen = "landing" | "view" | "playing";

interface Props {
domain: string;
}

let { domain }: Props = $props();

let duelId = $state<string | null>(null);
let sig = $state<string | null>(null);
let screen = $state<Screen>("landing");
let gridId = $state<string | null>(null);
let expiresAt = $state<string | null>(null);
let players = $state<PlayerSummary[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let playing = $state(false);

const parseUrl = (): void => {
if (typeof window === "undefined") return;
// Share-link creation state for the landing screen.
let creating = $state(false);
let createError = $state<string | null>(null);
let createdShareUrl = $state<string | null>(null);
let createdCopied = $state(false);

const parseUrl = (): { id: string | null; sig: string | null } => {
if (typeof window === "undefined") return { id: null, sig: null };
const params = new URLSearchParams(window.location.search);
duelId = params.get("id");
sig = params.get("sig");
return { id: params.get("id"), sig: params.get("sig") };
};

const fetchDuel = async (): Promise<void> => {
if (!duelId || !sig) {
error = m.duel_missing_link();
loading = false;
return;
}
const fetchDuel = async (id: string, s: string): Promise<void> => {
loading = true;
error = null;
try {
const r = await api.get("/api/duels/{duelId}", { duelId }, { sig });
const r = await api.get("/api/duels/{duelId}", { duelId: id }, { sig: s });
gridId = r.gridId;
expiresAt = r.expiresAt;
players = r.players;
screen = "view";
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 401) error = m.duel_bad_signature();
Expand All @@ -61,16 +74,54 @@
} else {
error = m.error_network();
}
// Fall back to the landing screen so the player can still create a
// fresh duel — surfacing only the error leaves them stuck.
screen = "landing";
} finally {
loading = false;
}
};

$effect(() => {
parseUrl();
void fetchDuel();
const { id, sig: s } = parseUrl();
if (id && s) {
void fetchDuel(id, s);
} else {
// No query params → landing screen, no fetch.
loading = false;
screen = "landing";
}
});

const createDuel = async (): Promise<void> => {
if (creating) return;
creating = true;
createError = null;
createdShareUrl = null;
createdCopied = false;
try {
const r = await api.post("/api/duels", undefined, { domain });
createdShareUrl = r.shareUrl;
const shared = await shareNative(r.shareUrl, m.duel_share_title());
if (!shared) {
const ok = await copyToClipboard(r.shareUrl);
createdCopied = ok;
if (ok) setTimeout(() => (createdCopied = false), 2400);
}
} catch (err) {
createError = err instanceof ApiError ? err.message : m.error_network();
} finally {
creating = false;
}
};

const copyCreated = async (): Promise<void> => {
if (!createdShareUrl) return;
const ok = await copyToClipboard(createdShareUrl);
createdCopied = ok;
if (ok) setTimeout(() => (createdCopied = false), 2400);
};

const formatExpires = (iso: string): string => {
const d = new Date(iso);
const locale = document.documentElement.lang === "en" ? "en-GB" : "fr-FR";
Expand All @@ -87,12 +138,69 @@
<span class="kd-spinner" aria-hidden="true"></span>
<p class="text-fg-muted text-sm">{m.loading_stations()}</p>
</div>
{:else if error}
<div class="surface flex flex-col gap-2 rounded-xl px-6 py-8">
<p class="text-danger text-sm font-medium" role="alert">{error}</p>
<a href="/" class="text-fg-subtle hover:text-fg text-sm underline">{m.back_to_home()}</a>
</div>
{:else if !playing}
{:else if screen === "landing"}
<!-- Landing: invite the player to create a fresh duel link. Reached both
when /duel is visited without query params and as a fallback when an
existing duel link errors out (404 / 410 / 401). -->
<header class="flex flex-col gap-2">
<p class="eyebrow text-accent inline-flex items-center gap-1.5">
<Sparkles size={12} aria-hidden="true" />
{m.duel_create_eyebrow()}
</p>
<h1 class="font-display text-fg text-3xl font-semibold leading-tight">
{m.duel_create_title()}
</h1>
<p class="text-fg-muted text-sm">{m.duel_create_subtitle()}</p>
</header>

{#if error}
<p class="text-danger text-sm" role="alert">{error}</p>
{/if}

{#if createdShareUrl === null}
<button
type="button"
class="kd-cta inline-flex items-center justify-center gap-2 self-start rounded-lg px-4 py-2.5 text-sm font-semibold"
disabled={creating}
onclick={createDuel}
>
<Swords size={16} aria-hidden="true" />
<span>{creating ? m.duel_share_creating() : m.duel_create_button()}</span>
</button>
{:else}
<div class="border-border bg-bg-subtle flex flex-col gap-2 rounded-xl border p-4">
<p class="eyebrow text-fg-muted">{m.duel_share_ready()}</p>
<p class="text-fg break-all text-xs font-mono">{createdShareUrl}</p>
<div class="flex flex-col gap-2 sm:flex-row">
<button
type="button"
class="kd-cta inline-flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold"
onclick={() => void shareNative(createdShareUrl, m.duel_share_title())}
>
<Share2 size={14} aria-hidden="true" />
<span>{m.share_button()}</span>
</button>
<button
type="button"
class="kd-secondary inline-flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold"
onclick={copyCreated}
>
{#if createdCopied}
<Check size={14} aria-hidden="true" />
<span>{m.share_copied()}</span>
{:else}
<Copy size={14} aria-hidden="true" />
<span>{m.copy_result()}</span>
{/if}
</button>
</div>
<p class="text-fg-muted mt-1 text-[11px]">{m.duel_create_share_hint()}</p>
</div>
{/if}
{#if createError}
<p class="text-danger text-sm" role="alert">{createError}</p>
{/if}
{:else if screen === "view"}
<header class="flex flex-col gap-2">
<p class="eyebrow text-accent inline-flex items-center gap-1.5">
<Swords size={12} aria-hidden="true" />
Expand All @@ -107,8 +215,6 @@
{/if}
</header>

<!-- Players that have already attempted the grid. Empty on a fresh
duel — the friend who clicks first sees only the CTA below. -->
{#if players.length > 0}
<section class="surface overflow-hidden rounded-xl">
<header
Expand Down Expand Up @@ -147,12 +253,12 @@
<button
type="button"
class="kd-cta inline-flex items-center justify-center gap-2 self-start rounded-lg px-4 py-2.5 text-sm font-semibold"
onclick={() => (playing = true)}
onclick={() => (screen = "playing")}
>
<Swords size={16} aria-hidden="true" />
<span>{m.duel_play_button()}</span>
</button>
{:else if gridId}
{:else if screen === "playing" && gridId}
<Grid {domain} mode="duel" duelGridId={gridId} />
{/if}
</section>
Expand Down Expand Up @@ -189,14 +295,31 @@
transform 120ms var(--ease-out),
box-shadow 160ms var(--ease-out);
}
.kd-cta:hover {
.kd-cta:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow:
0 1px 0 color-mix(in oklab, var(--color-accent) 50%, transparent) inset,
0 6px 14px color-mix(in oklab, var(--color-accent) 30%, transparent);
}
.kd-cta:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.kd-cta:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.kd-secondary {
background: transparent;
color: var(--color-fg);
border: 1px solid var(--color-border);
transition: background-color 140ms var(--ease-out);
}
.kd-secondary:hover {
background: color-mix(in oklab, var(--color-fg) 6%, transparent);
}
.kd-secondary:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
</style>
19 changes: 17 additions & 2 deletions web/src/lib/components/Grid.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,24 @@
};

const loadGrid = async (): Promise<void> => {
// Duel grids materialise only when the player clicks 'Play this grid' on
// /duel — never auto-start, the consent step matters there.
// Duel: the consent step happened upstream on /duel (the friend clicked
// "Play this grid"). By the time Grid mounts with mode=duel + duelGridId,
// we should immediately start the game so cells render. Mirror solo's
// logic but pinned to the duel's grid_id rather than a fresh one: if the
// persisted session points at the same grid, resume; otherwise clear and
// start anew.
if (mode === "duel") {
if (duelGridId && store.state && store.state.gridId !== duelGridId) {
store.clear();
}
const hasActiveDuel =
store.state !== null &&
!store.state.ended &&
store.grid !== null &&
store.state.gridId === duelGridId;
if (!hasActiveDuel) {
await startGame();
}
gridLoading = false;
return;
}
Expand Down
Loading