Skip to content
Open
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# The quiz's expert deck, sign-in and leaderboard (/play). Without these the
# site still builds, and the open quiz still plays; the rest stays off.
# See content/quiz/README.md.

# Supabase project settings: Project Settings > API. Public by design.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=

# Server only. Never prefix it with NEXT_PUBLIC_.
SUPABASE_SERVICE_ROLE_KEY=
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ next-env.d.ts
# misc
.DS_Store
npm-debug.log*

# the expert deck: never in the public repo (see content/quiz/README.md)
/private/
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Please read [CONTRIBUTING.md](CONTRIBUTING.md) first: it covers the status rules

## How it's built

A small [Next.js](https://nextjs.org) app with no backend, no database and no tracking. Every page is static, built from the files in [`content/`](content/).
A small [Next.js](https://nextjs.org) app with no tracking. Every page of the guide is static, built from the files in [`content/`](content/). The one exception is the [quiz](https://ax.guide/play): its open deck plays in the browser, while sign-in, the expert deck and the leaderboard run on [Supabase](https://supabase.com). Without Supabase keys the site still builds, and the open quiz still plays. See [`content/quiz/README.md`](content/quiz/README.md).

```
content/
Expand All @@ -74,9 +74,12 @@ content/
pairs.json the pairs people mix up (/compare)
guide.json which one do I need (/which)
home.json, site.json page text: titles, nav, footer, status labels
quiz/open.json the quiz's open deck (the expert deck is kept out of the repo)
contribute.md the /contribute page
lib/schema.ts the rules every standard's front matter must follow
lib/standards.ts reads, checks and numbers the standards
lib/quiz*.ts the quiz: cards, scoring, dealing, expert runs
supabase/migrations/ the quiz's database: players, the expert deck, runs
app/ pages and share images (Next.js App Router)
components/ small presentational components
tests/ checks every content file and this README's table
Expand All @@ -101,6 +104,7 @@ npm run dev # http://localhost:3000
| `npm run lint` | ESLint |
| `npm run typecheck` | TypeScript |
| `npm run readme` | Regenerate the table of standards in this README |
| `npm run quiz:sql` | Check `private/quiz-expert.json` and write the SQL that loads it into Supabase |

### Deploy

Expand Down
10 changes: 10 additions & 0 deletions app/api/play/me/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { json } from "@/lib/play-route";
import { playerName } from "@/lib/quiz-expert";
import { currentUserId, playBackendEnabled } from "@/lib/supabase-server";

/** Whether expert runs are on, and who is signed in. */
export async function GET() {
if (!playBackendEnabled()) return json({ enabled: false, player: null });
const userId = await currentUserId();
return json({ enabled: true, player: userId ? { name: (await playerName(userId)) ?? "player" } : null });
}
21 changes: 21 additions & 0 deletions app/api/play/runs/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { z } from "zod";
import { withPlayer } from "@/lib/play-route";
import { answerCard, nextCard, PlayError } from "@/lib/quiz-expert";

const step = z.discriminatedUnion("action", [
z.strictObject({ action: z.literal("answer"), position: z.int().min(0), choice: z.int().min(0).max(3).nullable() }),
z.strictObject({ action: z.literal("next"), position: z.int().min(0) }),
]);

/** One step of an expert run: answer the open card, or deal the next one. */
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withPlayer(async (userId) => {
const parsed = step.safeParse(await request.json().catch(() => undefined));
if (!parsed.success) throw new PlayError(400, "Malformed step.");
const body = parsed.data;
return body.action === "answer"
? answerCard(userId, id, body.position, body.choice)
: nextCard(userId, id, body.position);
});
}
7 changes: 7 additions & 0 deletions app/api/play/runs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { withPlayer } from "@/lib/play-route";
import { startRun } from "@/lib/quiz-expert";

/** Start an expert run: deals its first card. */
export async function POST() {
return withPlayer((userId) => startRun(userId));
}
7 changes: 7 additions & 0 deletions app/api/play/signout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { json } from "@/lib/play-route";
import { signOut } from "@/lib/supabase-server";

export async function POST() {
await signOut();
return json({ ok: true });
}
14 changes: 14 additions & 0 deletions app/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { safeNext } from "@/lib/quiz";
import { exchangeAuthCode } from "@/lib/supabase-server";

/** Where GitHub, Google and email sign-in links land: set the session, go back to the game. */
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const next = safeNext(url.searchParams.get("next"));
const ok = code ? await exchangeAuthCode(code) : false;
const target = new URL(next, url.origin);
if (!ok) target.searchParams.set("signin", "failed");
return NextResponse.redirect(target);
}
7 changes: 6 additions & 1 deletion app/home.css
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,12 @@

@media (min-width: 48rem) {
.next__list {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 72rem) {
.next__list {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}

Expand Down
78 changes: 78 additions & 0 deletions app/play/leaderboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Metadata } from "next";
import Link from "next/link";
import { leaderboard } from "@/lib/quiz-expert";
import "../play.css";

export const metadata: Metadata = {
title: "Quiz leaderboard",
description: "The best expert runs on the ax.guide agentic web quiz.",
alternates: { canonical: "/play/leaderboard" },
};

// Read fresh at most once a minute.
export const revalidate = 60;

type Props = { searchParams: Promise<{ period?: string }> };

/** Each player's best expert run: the expert deck is server-side, so these scores were earned. */
export default async function LeaderboardPage({ searchParams }: Props) {
const period = (await searchParams).period === "week" ? "week" : "all";
const rows = await leaderboard(period);
return (
<div className="wrap play">
<header className="page-intro">
<p className="kicker">The ax.guide quiz</p>
<h1 className="page-title">Leaderboard</h1>
<p className="lede">
Each player&apos;s best expert run: 18 cards from a deck whose answers aren&apos;t public. Equal scores share a
place, and the earlier run is listed first.
</p>
</header>
<nav className="play-tabs" aria-label="Period">
<Link className="button" href="/play/leaderboard" aria-current={period === "all" ? "page" : undefined}>
All time
</Link>
<Link className="button" href="/play/leaderboard?period=week" aria-current={period === "week" ? "page" : undefined}>
This week
</Link>
</nav>
{rows === undefined ? (
<p className="play-notice">The leaderboard opens with the expert deck, soon.</p>
) : rows.length === 0 ? (
<p className="play-notice">No expert runs yet. The top spot is open.</p>
) : (
<table className="play-board-table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Player</th>
<th scope="col" className="num">
Right
</th>
<th scope="col" className="num">
Points
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.runId}>
<td>{row.rank}</td>
<td>
<Link href={`/play/r/${row.runId}`}>{row.name}</Link>
</td>
<td className="num">
{row.correct}/{row.total}
</td>
<td className="num">{row.score.toLocaleString("en-US")}</td>
</tr>
))}
</tbody>
</table>
)}
<Link href="/play" className="button button--primary" style={{ marginTop: "var(--space-10)" }}>
Play
</Link>
</div>
);
}
25 changes: 25 additions & 0 deletions app/play/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { QuizGame } from "@/components/quiz/quiz-game";
import { getOpenDeck } from "@/lib/quiz-open";
import { getStandards } from "@/lib/standards";
import "./play.css";

export const metadata: Metadata = {
title: "The agentic web quiz",
description:
"How well do you know the standards that let AI agents find, use, sign in to, shop on and pay websites? Twelve cards, twenty seconds each.",
alternates: { canonical: "/play" },
};

/**
* The quiz. Static: the open deck ships with the page and an open run plays in
* the browser. Expert runs talk to /api/play.
*/
export default function PlayPage() {
const standardNames = Object.fromEntries(getStandards().map((s) => [s.id, s.name]));
return (
<div className="wrap play">
<QuizGame deck={getOpenDeck()} standardNames={standardNames} />
</div>
);
}
Loading
Loading