diff --git a/src/app/brackets/[id]/edit/page.tsx b/src/app/brackets/[id]/edit/page.tsx new file mode 100644 index 0000000..29d89ef --- /dev/null +++ b/src/app/brackets/[id]/edit/page.tsx @@ -0,0 +1,6 @@ +import type { Metadata } from "next"; +import { AccountEditor } from "@/components/account/AccountEditor"; +export const metadata: Metadata = { title: "Edit bracket | bracket.build" }; +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + return ; +} diff --git a/src/app/brackets/layout.tsx b/src/app/brackets/layout.tsx new file mode 100644 index 0000000..ca5a864 --- /dev/null +++ b/src/app/brackets/layout.tsx @@ -0,0 +1,33 @@ +import { auth } from "@clerk/nextjs/server"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { AccountControls } from "@/components/AccountControls"; + +export default async function BracketsLayout({ children }: { children: React.ReactNode }) { + const { userId } = await auth(); + if (!userId) redirect("/sign-in?redirect_url=/brackets"); + return ( + <> + + Skip to content + +
+
+ + bracket.build + + +
+
+ {children} +
+
+ + ); +} diff --git a/src/app/brackets/new/page.tsx b/src/app/brackets/new/page.tsx new file mode 100644 index 0000000..861151f --- /dev/null +++ b/src/app/brackets/new/page.tsx @@ -0,0 +1,6 @@ +import type { Metadata } from "next"; +import { AccountEditor } from "@/components/account/AccountEditor"; +export const metadata: Metadata = { title: "New bracket | bracket.build" }; +export default function Page() { + return ; +} diff --git a/src/app/brackets/page.tsx b/src/app/brackets/page.tsx new file mode 100644 index 0000000..29b2680 --- /dev/null +++ b/src/app/brackets/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { AccountLibrary } from "@/components/account/AccountLibrary"; +export const metadata: Metadata = { + title: "My brackets | bracket.build", + robots: { index: false, follow: false }, +}; +export default function Page() { + return ( + <> +

My brackets

+ Loading saved brackets…

}> + +
+ + ); +} diff --git a/src/components/AccountControls.tsx b/src/components/AccountControls.tsx index 9f14b21..40b351a 100644 --- a/src/components/AccountControls.tsx +++ b/src/components/AccountControls.tsx @@ -1,6 +1,7 @@ "use client"; import { Show, SignInButton, SignUpButton, UserButton } from "@clerk/nextjs"; +import Link from "next/link"; import { Button } from "@/components/ui/button"; export function AccountControls() { @@ -17,6 +18,9 @@ export function AccountControls() { + ; +} + +function EditorLoader({ id }: { id?: string }) { + const { user } = useUser(); + const [saved, setSaved] = useState(null); + const [initial] = useState(() => + createInitialBracket(user?.firstName ?? user?.username ?? "Football fan"), + ); + const [error, setError] = useState(""); + const [retry, setRetry] = useState(0); + useEffect(() => { + if (!id) return; + const controller = new AbortController(); + setError(""); + accountRequest(`/api/brackets/${id}`, "GET", undefined, controller.signal) + .then(setSaved) + .catch((reason) => { + if (!controller.signal.aborted) setError(reason.message); + }); + return () => controller.abort(); + }, [id, retry]); + if (error) + return ( +
+

{error}

+ + + My brackets + +
+ ); + if (id && !saved) return

Loading bracket…

; + return ( + + + + + + ); +} + +function pickFingerprint(state: BracketState) { + const { liveResults: _liveResults, updatedAt: _updatedAt, ...picks } = state; + return JSON.stringify(picks); +} + +function Editor({ initial }: { initial: AccountBracket | null }) { + const { userId } = useAuth(); + const router = useRouter(); + const { bracket, setBracketName, setSubtitle, setUserName, loadBracket } = useBracket(); + const [saved, setSaved] = useState(initial); + const [savedFingerprint, setSavedFingerprint] = useState(() => pickFingerprint(bracket)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [conflict, setConflict] = useState(false); + const [notice, setNotice] = useState(""); + const errorRef = useRef(null); + const dirty = pickFingerprint(bracket) !== savedFingerprint; + useEffect(() => { + if (!dirty) return; + const beforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + }; + const click = (event: MouseEvent) => { + const link = (event.target as Element)?.closest?.("a[href]"); + if ( + link && + !event.ctrlKey && + !event.metaKey && + !window.confirm("Leave without saving your bracket changes?") + ) { + event.preventDefault(); + event.stopPropagation(); + } + }; + window.addEventListener("beforeunload", beforeUnload); + document.addEventListener("click", click, true); + return () => { + window.removeEventListener("beforeunload", beforeUnload); + document.removeEventListener("click", click, true); + }; + }, [dirty]); + useEffect(() => { + if (error) errorRef.current?.focus(); + }, [error]); + + async function save(copy = false) { + setBusy(true); + setError(""); + setNotice(""); + const snapshot = { ...bracket, id: copy ? nanoid() : bracket.id }; + try { + const result = await accountRequest( + saved && !copy ? `/api/brackets/${saved.id}` : "/api/brackets", + saved && !copy ? "PUT" : "POST", + { + seasonYear: PLAYOFF_SEASON_YEAR, + state: snapshot, + ...(saved && !copy ? { revision: saved.revision } : {}), + }, + undefined, + userId, + ); + setSaved(result); + setSavedFingerprint(pickFingerprint(result.state)); + loadBracket(result.state); + setConflict(false); + setNotice("Saved to your account."); + if (!saved || copy) router.replace(`/brackets/${result.id}/edit`); + } catch (reason) { + setConflict(reason instanceof AccountRequestError && reason.status === 409); + setError( + reason instanceof Error ? reason.message : "Save failed. Your edits are still here.", + ); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+

{initial ? "Edit bracket" : "New bracket"}

+

+ {PLAYOFF_SEASON_YEAR} postseason · Private until shared +

+
+ + My brackets + +
+
{ + event.preventDefault(); + void save(); + }} + className="mb-8 grid gap-4 rounded-xl border border-gray-800 bg-gray-950 p-5 sm:grid-cols-2" + > +
+ + setBracketName(event.target.value)} + placeholder="My championship picks…" + className="mt-2 min-h-11 text-base" + /> +
+
+ + setUserName(event.target.value)} + placeholder="Football fan…" + className="mt-2 min-h-11 text-base" + /> +
+
+ + setSubtitle(event.target.value || null)} + placeholder="A few bold predictions…" + className="mt-2 min-h-11 text-base" + /> +
+
+ + + {dirty ? "Unsaved changes" : notice || (saved ? "Saved" : "Not yet saved")} + +
+ {error && ( +
+

+ {error} +

+ {conflict && ( +
+ + +
+ )} +
+ )} +
+
+ +
+
+ ); +} diff --git a/src/components/account/AccountLibrary.tsx b/src/components/account/AccountLibrary.tsx new file mode 100644 index 0000000..ba56d7e --- /dev/null +++ b/src/components/account/AccountLibrary.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useAuth } from "@clerk/nextjs"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { PLAYOFF_SEASON_YEAR } from "@/data/teams"; +import { accountRequest } from "@/lib/account-client"; +import type { AccountBracket } from "@/lib/bracket-document"; +import { getCurrentBracket, getSavedBrackets } from "@/lib/storage"; +import { Button } from "@/components/ui/button"; + +export function AccountLibrary() { + const { userId } = useAuth(); + return ; +} + +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 [data, setData] = useState<{ brackets: AccountBracket[]; hasMore: boolean } | null>(null); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [busy, setBusy] = useState(false); + const [refresh, setRefresh] = useState(0); + useEffect(() => { + const controller = new AbortController(); + setData(null); + setError(""); + accountRequest<{ brackets: AccountBracket[]; hasMore: boolean }>( + `/api/brackets?page=${page}`, + "GET", + undefined, + controller.signal, + ) + .then(setData) + .catch((reason) => { + if (!controller.signal.aborted) setError(reason.message); + }); + return () => controller.abort(); + }, [page, refresh]); + + async function importBrowser() { + if ( + !window.confirm( + "Import this browser’s guest brackets into your account? Only import picks that belong to you. Existing account saves will not be overwritten.", + ) + ) + return; + setBusy(true); + setNotice(""); + setError(""); + try { + const current = getCurrentBracket(); + const saves = getSavedBrackets().map((saved) => saved.state); + if (current) saves.push(current); + const unique = new Map(saves.map((state) => [state.id, state])); + let imported = 0; + let failed = 0; + for (const state of unique.values()) { + try { + await accountRequest( + "/api/brackets", + "POST", + { seasonYear: PLAYOFF_SEASON_YEAR, state }, + undefined, + userId, + ); + imported++; + } catch { + failed++; + } + } + setNotice( + `${imported} bracket${imported === 1 ? "" : "s"} imported or already saved.${failed ? ` ${failed} could not be imported. Retry after checking the connection and season.` : ""} Browser originals were kept.`, + ); + setRefresh((value) => value + 1); + } catch { + setError("Unable to read this browser’s saved brackets. Your account saves are unchanged."); + } finally { + setBusy(false); + } + } + + async function remove(saved: AccountBracket) { + if ( + !window.confirm( + `Delete “${saved.state.name || "Untitled bracket"}”? Any shared link will stop working. This cannot be undone.`, + ) + ) + return; + setBusy(true); + setError(""); + try { + await accountRequest( + `/api/brackets/${saved.id}`, + "DELETE", + { revision: saved.revision }, + undefined, + userId, + ); + setRefresh((value) => value + 1); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Delete failed. Please retry."); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + +
+

+ Private by default. Saved to your account across devices. The editor currently supports the{" "} + {PLAYOFF_SEASON_YEAR} postseason. +

+ {notice && ( +

+ {notice} +

+ )} + {error && ( +
+

{error}

+ +
+ )} + {!data && !error &&

Loading saved brackets…

} + {data?.brackets.length === 0 && ( +
+

Your picks start here

+

+ Create a bracket, or import picks you saved in this browser before signing in. +

+
+ )} +
    + {data?.brackets.map((saved) => ( +
  • +
    +

    + {saved.state.name || "Untitled bracket"} +

    + + {saved.shareToken ? "Shared" : "Private"} + +
    +

    + {saved.seasonYear} playoffs · {saved.state.isComplete ? "Complete" : "In progress"} +

    +

    + Updated{" "} + {new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(saved.updatedAt))} +

    +
    + + +
    +
  • + ))} +
+ +
+ ); +} diff --git a/src/components/dialogs/LoadBracketDialog.tsx b/src/components/dialogs/LoadBracketDialog.tsx index d770bb8..f7c5848 100644 --- a/src/components/dialogs/LoadBracketDialog.tsx +++ b/src/components/dialogs/LoadBracketDialog.tsx @@ -1,5 +1,7 @@ "use client"; +import { useAuth } from "@clerk/nextjs"; +import Link from "next/link"; import { Calendar, FolderOpen, Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -21,14 +23,15 @@ interface LoadBracketDialogProps { } export function LoadBracketDialog({ open, onOpenChange }: LoadBracketDialogProps) { + const { userId } = useAuth(); const { loadBracket, bracket: activeBracket } = useBracket(); const [brackets, setBrackets] = useState([]); const [currentBracketId, setCurrentBracketId] = useState(null); useEffect(() => { if (open) { - const savedBrackets = getSavedBrackets(); - const currentBracket = getCurrentBracket(); + const savedBrackets = getSavedBrackets(userId ?? undefined); + const currentBracket = getCurrentBracket(userId ?? undefined); // Include the current (autosaved) bracket if it exists and isn't already in the saved list if (currentBracket && !savedBrackets.some((b) => b.id === currentBracket.id)) { @@ -48,7 +51,7 @@ export function LoadBracketDialog({ open, onOpenChange }: LoadBracketDialogProps setBrackets(savedBrackets); } } - }, [open]); + }, [open, userId]); const handleLoad = (saved: SavedBracket) => { loadBracket(saved.state); @@ -59,7 +62,8 @@ export function LoadBracketDialog({ open, onOpenChange }: LoadBracketDialogProps }; const handleDelete = (id: string, name: string) => { - deleteBracket(id); + if (!window.confirm("Delete this saved bracket? This cannot be undone.")) return; + deleteBracket(id, userId ?? undefined); setBrackets(brackets.filter((b) => b.id !== id)); toast.success("Bracket deleted", { description: `"${name}" has been deleted.`, @@ -89,6 +93,11 @@ export function LoadBracketDialog({ open, onOpenChange }: LoadBracketDialogProps + {userId && ( + + )}
{brackets.length === 0 ? (
diff --git a/src/components/dialogs/SaveBracketDialog.tsx b/src/components/dialogs/SaveBracketDialog.tsx index 69aea80..d920aff 100644 --- a/src/components/dialogs/SaveBracketDialog.tsx +++ b/src/components/dialogs/SaveBracketDialog.tsx @@ -1,6 +1,9 @@ "use client"; -import { Save } from "lucide-react"; +import { useAuth } from "@clerk/nextjs"; +import { accountRequest } from "@/lib/account-client"; +import { PLAYOFF_SEASON_YEAR } from "@/data/teams"; +import { Loader2, Save } from "lucide-react"; import { nanoid } from "nanoid"; import { useState } from "react"; import { toast } from "sonner"; @@ -24,12 +27,13 @@ interface SaveBracketDialogProps { } export function SaveBracketDialog({ open, onOpenChange }: SaveBracketDialogProps) { + const { userId } = useAuth(); const { bracket, setBracketName, setSubtitle } = useBracket(); const [name, setName] = useState(bracket.name); const [subtitle, setSubtitleValue] = useState(bracket.subtitle || ""); const [isSaving, setIsSaving] = useState(false); - const handleSave = () => { + const handleSave = async () => { setIsSaving(true); try { const trimmedName = name.trim(); @@ -46,7 +50,17 @@ export function SaveBracketDialog({ open, onOpenChange }: SaveBracketDialogProps setBracketName(trimmedName); setSubtitle(trimmedSubtitle); - saveBracket(bracketToSave); + if (userId) { + await accountRequest( + "/api/brackets", + "POST", + { seasonYear: PLAYOFF_SEASON_YEAR, state: bracketToSave }, + undefined, + userId, + ); + } else { + saveBracket(bracketToSave); + } toast.success("Bracket saved!", { description: trimmedName ? `"${trimmedName}" has been saved.` @@ -54,7 +68,7 @@ export function SaveBracketDialog({ open, onOpenChange }: SaveBracketDialogProps }); onOpenChange(false); } catch (error) { - toast.error("Failed to save bracket"); + toast.error(error instanceof Error ? error.message : "Failed to save bracket"); } finally { setIsSaving(false); } @@ -69,8 +83,9 @@ export function SaveBracketDialog({ open, onOpenChange }: SaveBracketDialogProps Save Bracket - Save your current bracket to access it later. You can save multiple versions with - different names. + {userId + ? "Save a private copy to your account and access it on any device." + : "Save a copy in this browser. Sign in to save brackets across devices."} @@ -131,7 +146,13 @@ export function SaveBracketDialog({ open, onOpenChange }: SaveBracketDialogProps disabled={isSaving} className="bg-gradient-to-r from-red-600 to-blue-600 hover:from-red-700 hover:to-blue-700 md:h-11 md:px-6 md:text-base" > - {isSaving ? "Saving..." : "Save Bracket"} + {isSaving && ( +