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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,15 @@ bun dev

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

Sign in and sign up from the header, or continue browsing as a guest. Accounts currently
provide identity only; brackets still save to this browser's localStorage and do not sync
between devices or become private to a signed-in account.
Sign in from the header and open **My brackets** to create, save, and reopen private
brackets across devices. Guest brackets remain in browser storage. Use **Import browser
brackets** to copy those saves into your account; the originals remain on this device.

Saved brackets can publish a read-only snapshot with **Create share link**. Links expose
the bracket name, display name, subtitle, and picks to anyone holding the link. Further
edits stay private until published again. Stop sharing to permanently revoke that link.
Friends can compare their account brackets on the shared page. Correct-pick counts are
informal because predictions remain editable. Account saves currently support the 2025 postseason.

For CI and deployment, configure `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and
`CLERK_SECRET_KEY` in the environment. Use development keys for local testing and configure
Expand Down
41 changes: 41 additions & 0 deletions e2e/tests/shared-brackets.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { test, expect } from "../fixtures/test-fixtures";
import { createInitialBracket } from "../../src/lib/playoff-rules";
import { PLAYOFF_SEASON_YEAR } from "../../src/data/teams";

const token = "b705e8b6-73cf-43c4-a8cf-9fc4a567a92a";

test("guests can view a shared snapshot without editing it", async ({
page,
mockEspnApi: _mock,
}) => {
const state = createInitialBracket("Public Fan");
state.name = "Friends playoff picks";
state.afc.wildCard[0].winner = state.afc.wildCard[0].homeTeam;
await page.route(`**/api/shared-brackets/${token}`, (route) =>
route.fulfill({
json: { seasonYear: PLAYOFF_SEASON_YEAR, state, sharedAt: "2026-01-10T12:00:00Z" },
}),
);
await page.goto(`/s/${token}`);
await expect(page.getByRole("heading", { name: state.name })).toBeVisible();
await expect(page.getByText("1 of 13 picks made")).toBeVisible();
await expect(page.getByRole("link", { name: "Make my own bracket" })).toBeVisible();
await expect(page.getByRole("button", { name: "Save bracket" })).toHaveCount(0);
await expect(page.getByText("to compare your saved picks.")).toBeVisible();
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth,
);
expect(overflow).toBe(false);
});

test("revoked shared links show a recovery path", async ({ page }) => {
await page.route(`**/api/shared-brackets/${token}`, (route) =>
route.fulfill({
status: 404,
json: { error: "This shared bracket is unavailable or its owner stopped sharing it." },
}),
);
await page.goto(`/s/${token}`);
await expect(page.getByRole("heading", { name: "Bracket unavailable" })).toBeVisible();
await expect(page.getByRole("link", { name: "Back to games" })).toBeVisible();
});
4 changes: 2 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default defineConfig({
: [["html", { open: "on-failure" }]],

use: {
baseURL: "http://localhost:3000",
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
Expand Down Expand Up @@ -45,7 +45,7 @@ export default defineConfig({

webServer: {
command: process.env.CI ? "bun run start" : "bun run build && bun run start",
url: "http://localhost:3000",
url: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
Expand Down
24 changes: 24 additions & 0 deletions src/app/api/brackets/[id]/share/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { z } from "zod";
import { readJson, withAccount } from "@/lib/bracket-api";
import { bracketId } from "@/lib/bracket-document";
import { publishBracket, unpublishBracket } from "@/lib/db/brackets";
type Context = { params: Promise<{ id: string }> };
const input = z.object({ revision: z.number().int().positive() });
export function POST(request: Request, context: Context) {
return withAccount(request, async (ownerId) =>
publishBracket(
ownerId,
bracketId.parse((await context.params).id),
input.parse(await readJson(request)).revision,
),
);
}
export function DELETE(request: Request, context: Context) {
return withAccount(request, async (ownerId) =>
unpublishBracket(
ownerId,
bracketId.parse((await context.params).id),
input.parse(await readJson(request)).revision,
),
);
}
14 changes: 14 additions & 0 deletions src/app/api/shared-brackets/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { z } from "zod";
import { json } from "@/lib/bracket-api";
import { BracketStoreError, getSharedBracket } from "@/lib/db/brackets";
export const dynamic = "force-dynamic";
export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) {
const token = z.uuid().safeParse((await params).token);
if (!token.success) return json({ error: "Shared bracket not found." }, 404);
try {
return json(await getSharedBracket(token.data));
} catch (error) {
if (error instanceof BracketStoreError) return json({ error: error.message }, error.status);
return json({ error: "Unable to load this bracket. Please retry." }, 503);
}
}
37 changes: 37 additions & 0 deletions src/app/s/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react";
import { AccountControls } from "@/components/AccountControls";
import { SharedBracket } from "@/components/account/SharedBracket";
export const metadata: Metadata = {
title: "Shared playoff bracket | bracket.build",
robots: { index: false, follow: false },
};
export default async function Page({ params }: { params: Promise<{ token: string }> }) {
return (
<>
<a
href="#shared-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 z-50 rounded bg-white p-3 text-black"
>
Skip to content
</a>
<div className="min-h-screen bg-black px-4 py-6 text-white sm:px-8">
<header className="mx-auto mb-8 flex max-w-5xl flex-wrap items-center justify-between gap-4">
<Link
href="/"
className="inline-flex min-h-11 items-center rounded-md text-xl font-bold focus-visible:outline-2"
>
bracket<span className="text-gray-400">.build</span>
</Link>
<AccountControls />
</header>
<main id="shared-content" className="mx-auto max-w-5xl">
<Suspense fallback={<p>Loading shared bracket…</p>}>
<SharedBracket token={(await params).token} />
</Suspense>
</main>
</div>
</>
);
}
21 changes: 18 additions & 3 deletions src/components/account/AccountEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { nanoid } from "nanoid";
import { GameDialogProvider } from "@/contexts/GameDialogContext";
import { BracketProvider, useBracket } from "@/contexts/BracketContext";
import { Bracket } from "@/components/bracket/Bracket";
import { ShareBracket } from "./ShareBracket";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
Expand Down Expand Up @@ -76,6 +77,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
const [saved, setSaved] = useState(initial);
const [savedFingerprint, setSavedFingerprint] = useState(() => pickFingerprint(bracket));
const [busy, setBusy] = useState(false);
const [sharing, setSharing] = useState(false);
const [error, setError] = useState("");
const [conflict, setConflict] = useState(false);
const [notice, setNotice] = useState("");
Expand Down Expand Up @@ -110,6 +112,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
}, [error]);

async function save(copy = false) {
if (busy || sharing) return;
setBusy(true);
setError("");
setNotice("");
Expand Down Expand Up @@ -167,6 +170,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
<Input
id="account-bracket-name"
name="bracketName"
disabled={busy || sharing}
maxLength={100}
value={bracket.name}
onChange={(event) => setBracketName(event.target.value)}
Expand All @@ -180,6 +184,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
id="account-display-name"
name="displayName"
autoComplete="nickname"
disabled={busy || sharing}
maxLength={80}
value={bracket.userName}
onChange={(event) => setUserName(event.target.value)}
Expand All @@ -192,6 +197,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
<Input
id="account-subtitle"
name="subtitle"
disabled={busy || sharing}
maxLength={200}
value={bracket.subtitle ?? ""}
onChange={(event) => setSubtitle(event.target.value || null)}
Expand All @@ -200,7 +206,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
/>
</div>
<div className="flex flex-wrap items-center gap-3 sm:col-span-2">
<Button type="submit" disabled={busy} className="min-h-11">
<Button type="submit" disabled={busy || sharing} className="min-h-11">
{busy && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
Expand All @@ -223,7 +229,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
<Button
type="button"
className="min-h-11"
disabled={busy}
disabled={busy || sharing}
onClick={() => save(true)}
>
Save as a copy
Expand All @@ -244,7 +250,16 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
</div>
)}
</form>
<div className="max-w-full overflow-x-auto pb-8">
{saved && (
<ShareBracket
saved={saved}
dirty={dirty}
saving={busy}
onChange={setSaved}
onBusyChange={setSharing}
/>
)}
<div inert={busy || sharing} className="max-w-full overflow-x-auto pb-8">
<Bracket />
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/account/AccountLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function Library() {
const { userId } = useAuth();
const router = useRouter();
const params = useSearchParams();
const page = Math.max(0, Math.min(10000, Number(params.get("page")) || 0));
const page = Math.max(0, Math.min(10000, Math.floor(Number(params.get("page"))) || 0));
const [data, setData] = useState<{ brackets: AccountBracket[]; hasMore: boolean } | null>(null);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
Expand Down
143 changes: 143 additions & 0 deletions src/components/account/ShareBracket.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"use client";
import { useAuth } from "@clerk/nextjs";
import { useState } from "react";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { accountRequest } from "@/lib/account-client";
import type { AccountBracket } from "@/lib/bracket-document";

export function ShareBracket({
saved,
dirty,
saving,
onChange,
onBusyChange,
}: {
saved: AccountBracket;
dirty: boolean;
saving: boolean;
onBusyChange: (busy: boolean) => void;
onChange: (saved: AccountBracket) => void;
}) {
const { userId } = useAuth();
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const path = saved.shareToken ? `/s/${saved.shareToken}` : null;
const url = path && typeof window !== "undefined" ? `${window.location.origin}${path}` : "";
async function changeSharing(remove = false) {
if (busy || saving) return;
if (
!window.confirm(
remove
? "Stop sharing? Anyone with this link will lose access. A future shared link will have a new address."
: "Publish these saved picks, bracket name, subtitle, and display name? Anyone with the link can view them. You can revoke access later.",
)
)
return;
setBusy(true);
onBusyChange(true);
setError("");
setMessage("");
try {
const result = await accountRequest<AccountBracket>(
`/api/brackets/${saved.id}/share`,
remove ? "DELETE" : "POST",
{ revision: saved.revision },
undefined,
userId,
);
onChange(result);
setMessage(
remove
? "Sharing stopped. The old link is no longer available."
: "Your saved picks are published. Copy the link below.",
);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Sharing failed. Please retry.");
} finally {
setBusy(false);
onBusyChange(false);
}
}
async function copy() {
try {
await navigator.clipboard.writeText(url);
setMessage("Link copied.");
} catch {
setMessage("Select and copy the link below.");
}
}
return (
<section
aria-labelledby="share-heading"
className="mb-8 space-y-3 rounded-xl border border-gray-800 bg-gray-950 p-5"
>
<h2 id="share-heading" className="text-lg font-semibold">
Share with friends
</h2>
<p className="text-sm text-gray-400">
{path
? "Your link shows the last published snapshot. Private edits stay private until you publish again."
: "This bracket is private. Publish a read-only snapshot when you’re ready."}
</p>
{dirty && <p className="text-sm text-amber-200">Save your changes before publishing.</p>}
<div className="flex flex-wrap gap-3">
<Button
disabled={busy || saving || dirty}
className="min-h-11"
onClick={() => changeSharing()}
>
{busy && (
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
)}
{path ? "Publish saved changes" : "Create share link"}
</Button>
{path && (
<>
<Button asChild variant="outline" className="min-h-11">
<Link href={path}>View shared bracket</Link>
</Button>
<Button
variant="ghost"
className="min-h-11 text-red-300"
disabled={busy || saving}
onClick={() => changeSharing(true)}
>
Stop sharing
</Button>
</>
)}
</div>
{path && (
<div className="flex gap-2">
<Input
readOnly
aria-label="Share link"
value={url}
onFocus={(event) => event.target.select()}
className="min-w-0 min-h-11 text-base"
/>
<Button variant="outline" className="min-h-11" onClick={copy}>
Copy link
</Button>
</div>
)}
{message && (
<p role="status" className="text-sm text-green-200">
{message}
</p>
)}
{error && (
<p role="alert" className="text-sm text-red-300">
{error}
</p>
)}
</section>
);
}
Loading
Loading