diff --git a/README.md b/README.md index e2083ca..815213c 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the - [React 19](https://react.dev/) - [Tailwind CSS 4](https://tailwindcss.com/) - [Radix UI](https://www.radix-ui.com/) primitives -- [html-to-image](https://github.com/bubkoo/html-to-image) for bracket export +- [@vercel/og](https://vercel.com/docs/functions/edge-functions/og-image-generation) for server-side bracket image generation ## License diff --git a/package-lock.json b/package-lock.json index 1e434f0..5e175a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,9 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slot": "^1.2.4", "@vercel/analytics": "^1.6.1", + "@vercel/og": "^0.8.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "html-to-image": "^1.11.13", "lucide-react": "^0.562.0", "nanoid": "^5.1.6", "next": "16.1.1", @@ -1632,6 +1632,31 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@resvg/resvg-wasm": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.4.0.tgz", + "integrity": "sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@shuding/opentype.js": { + "version": "1.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", + "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", + "license": "MIT", + "dependencies": { + "fflate": "^0.7.3", + "string.prototype.codepointat": "^0.2.1" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1980,6 +2005,19 @@ } } }, + "node_modules/@vercel/og": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@vercel/og/-/og-0.8.6.tgz", + "integrity": "sha512-hBcWIOppZV14bi+eAmCZj8Elj8hVSUZJTpf1lgGBhVD85pervzQ1poM/qYfFUlPraYSZYP+ASg6To5BwYmUSGQ==", + "license": "MPL-2.0", + "dependencies": { + "@resvg/resvg-wasm": "2.4.0", + "satori": "0.16.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -2002,6 +2040,15 @@ "@babel/types": "^7.26.0" } }, + "node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.14", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", @@ -2011,6 +2058,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001763", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", @@ -2058,6 +2114,53 @@ "node": ">=6" } }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/css-background-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", + "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==", + "license": "MIT" + }, + "node_modules/css-box-shadow": { + "version": "1.0.0-3", + "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", + "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==", + "license": "MIT" + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-gradient-parser": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.16.tgz", + "integrity": "sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2081,6 +2184,15 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz", + "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -2095,6 +2207,18 @@ "node": ">=10.13.0" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "license": "MIT" + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -2111,11 +2235,17 @@ "dev": true, "license": "ISC" }, - "node_modules/html-to-image": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", - "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", - "license": "MIT" + "node_modules/hex-rgb": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", + "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/jiti": { "version": "2.6.1", @@ -2388,6 +2518,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, "node_modules/lucide-react": { "version": "0.562.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", @@ -2534,6 +2674,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/parse-css-color": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", + "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.1.4", + "hex-rgb": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2569,6 +2725,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/postcss/node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2678,6 +2840,28 @@ } } }, + "node_modules/satori": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/satori/-/satori-0.16.0.tgz", + "integrity": "sha512-ZvHN3ygzZ8FuxjSNB+mKBiF/NIoqHzlBGbD0MJiT+MvSsFOvotnWOhdTjxKzhHRT2wPC1QbhLzx2q/Y83VhfYQ==", + "license": "MPL-2.0", + "dependencies": { + "@shuding/opentype.js": "1.4.0-beta.0", + "css-background-parser": "^0.1.0", + "css-box-shadow": "1.0.0-3", + "css-gradient-parser": "^0.0.16", + "css-to-react-native": "^3.0.0", + "emoji-regex-xs": "^2.0.1", + "escape-html": "^1.0.3", + "linebreak": "^1.1.0", + "parse-css-color": "^0.2.1", + "postcss-value-parser": "^4.2.0", + "yoga-layout": "^3.2.1" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2761,6 +2945,12 @@ "node": ">=0.10.0" } }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -2815,6 +3005,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2852,6 +3048,16 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -2894,6 +3100,12 @@ "optional": true } } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" } } } diff --git a/package.json b/package.json index d477937..b8f8721 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,9 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slot": "^1.2.4", "@vercel/analytics": "^1.6.1", + "@vercel/og": "^0.8.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "html-to-image": "^1.11.13", "lucide-react": "^0.562.0", "nanoid": "^5.1.6", "next": "16.1.1", diff --git a/src/app/api/generate-bracket-image/route.tsx b/src/app/api/generate-bracket-image/route.tsx new file mode 100644 index 0000000..c2f4e17 --- /dev/null +++ b/src/app/api/generate-bracket-image/route.tsx @@ -0,0 +1,757 @@ +import { ImageResponse } from "@vercel/og"; +import { type NextRequest, NextResponse } from "next/server"; +import type { BracketState, Matchup, SeededTeam } from "@/types"; + +export const runtime = "edge"; + +interface TeamCardData { + team: SeededTeam | null; + isWinner?: boolean; + isLoser?: boolean; +} + +/** + * Convert ArrayBuffer to base64 string (Edge-compatible, no Buffer needed) + */ +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** + * Fetches an image and returns it as a base64 data URI + */ +async function fetchImageAsBase64(url: string): Promise { + try { + const response = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; NFLBracket/1.0)", + }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + const arrayBuffer = await response.arrayBuffer(); + const base64 = arrayBufferToBase64(arrayBuffer); + const contentType = response.headers.get("content-type") || "image/png"; + return `data:${contentType};base64,${base64}`; + } catch (error) { + console.error("Failed to fetch image:", url, error); + // Return a transparent 1x1 pixel as fallback + return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + } +} + +/** + * Collects all unique team logo URLs from the bracket + */ +function collectTeamLogos(bracket: BracketState): string[] { + const urls = new Set(); + + const addTeamLogo = (team: SeededTeam | null) => { + if (team?.logoUrl) { + urls.add(team.logoUrl); + } + }; + + // AFC + bracket.afc.wildCard.forEach((m) => { + addTeamLogo(m.homeTeam); + addTeamLogo(m.awayTeam); + addTeamLogo(m.winner); + }); + bracket.afc.divisional.forEach((m) => { + addTeamLogo(m.homeTeam); + addTeamLogo(m.awayTeam); + addTeamLogo(m.winner); + }); + if (bracket.afc.championship) { + addTeamLogo(bracket.afc.championship.homeTeam); + addTeamLogo(bracket.afc.championship.awayTeam); + addTeamLogo(bracket.afc.championship.winner); + } + + // NFC + bracket.nfc.wildCard.forEach((m) => { + addTeamLogo(m.homeTeam); + addTeamLogo(m.awayTeam); + addTeamLogo(m.winner); + }); + bracket.nfc.divisional.forEach((m) => { + addTeamLogo(m.homeTeam); + addTeamLogo(m.awayTeam); + addTeamLogo(m.winner); + }); + if (bracket.nfc.championship) { + addTeamLogo(bracket.nfc.championship.homeTeam); + addTeamLogo(bracket.nfc.championship.awayTeam); + addTeamLogo(bracket.nfc.championship.winner); + } + + // Super Bowl + if (bracket.superBowl) { + addTeamLogo(bracket.superBowl.homeTeam); + addTeamLogo(bracket.superBowl.awayTeam); + addTeamLogo(bracket.superBowl.winner); + } + + return Array.from(urls); +} + +function TeamCard({ + team, + isWinner = false, + isLoser = false, + logoMap, +}: TeamCardData & { logoMap: Map }) { + if (!team) { + return ( +
+
+ ? +
+ TBD +
+ ); + } + + const logoSrc = logoMap.get(team.logoUrl) || team.logoUrl; + + return ( +
+ {/* Team Logo - using img because @vercel/og doesn't support next/image */} +
+ {/* biome-ignore lint/performance/noImgElement: @vercel/og requires native img element */} + {team.name} +
+ + {/* Seed Badge */} +
+ {team.seed} +
+ + {/* Team Info */} +
+ + {team.name} + + + {team.city} + +
+ + {/* Winner Checkmark */} + {isWinner && ( +
+ +
+ )} +
+ ); +} + +function MatchupComponent({ + matchup, + logoMap, +}: { + matchup: Matchup; + logoMap: Map; +}) { + const { homeTeam, awayTeam, winner } = matchup; + + return ( +
+ + +
+ ); +} + +function ConferenceBracketComponent({ + conference, + confState, + logoMap, + isReversed = false, +}: { + conference: "AFC" | "NFC"; + confState: BracketState["afc"] | BracketState["nfc"]; + logoMap: Map; + isReversed?: boolean; +}) { + const isAFC = conference === "AFC"; + + const roundsContent = ( +
+ {/* Wild Card Round */} +
+
+ Wild Card +
+ {confState.wildCard.map((matchup) => ( + + ))} +
+ + {/* Divisional Round */} +
+
+ Divisional +
+ {confState.divisional.map((matchup) => ( + + ))} +
+ + {/* Conference Championship */} +
+
+ {conference} Champ +
+ {confState.championship && ( + + )} +
+
+ ); + + return ( +
+ {/* Conference Header */} +
+
+ {conference} +
+
+ + {roundsContent} +
+ ); +} + +function SuperBowlComponent({ + superBowl, + logoMap, +}: { + superBowl: Matchup | null; + logoMap: Map; +}) { + if (!superBowl) return null; + + const { homeTeam, awayTeam, winner } = superBowl; + + return ( +
+ {/* Trophy emoji */} +
🏆
+ + {/* Super Bowl Header */} +
+ Super Bowl LX +
+ + {/* Matchup Container */} +
+ {/* AFC Champion */} +
+
+ AFC Champion +
+ +
+ + {/* VS */} +
+ VS +
+ + {/* NFC Champion */} +
+
+ NFC Champion +
+ +
+ + {/* Champion Display */} + {winner && ( +
+
🏆
+
+
+ Super Bowl Champion +
+
+ {winner.city} {winner.name} +
+
+
+ )} +
+
+ ); +} + +function BracketImage({ + bracket, + logoMap, +}: { + bracket: BracketState; + logoMap: Map; +}) { + return ( +
+ {/* Header with gradient */} +
+
+ + {bracket.userName} + {"'"}s Playoff Bracket + + {bracket.name && ( + + {bracket.name} + + )} +
+
+ + {/* Main Bracket Content */} +
+ {/* AFC Bracket */} + + + {/* Super Bowl */} +
+ +
+ + {/* NFC Bracket */} + +
+ + {/* Footer */} +
+ + bracket.build • NFL Playoffs 2025-26 + +
+
+ ); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const bracket: BracketState = body.bracket; + + if (!bracket) { + return NextResponse.json( + { error: "Bracket data is required" }, + { status: 400 }, + ); + } + + // Collect all team logo URLs and fetch them as base64 + const logoUrls = collectTeamLogos(bracket); + const logoDataPromises = logoUrls.map(async (url) => { + const dataUri = await fetchImageAsBase64(url); + return [url, dataUri] as const; + }); + + const logoEntries = await Promise.all(logoDataPromises); + const logoMap = new Map(logoEntries); + + // Calculate dimensions based on bracket content + // Width: AFC (190*3 + 20*2 gaps) + Super Bowl (230) + NFC (190*3 + 20*2 gaps) + padding + // = 610 + 230 + 610 + 48 = 1498 ~ 1500 + const width = 1500; + // Height: Header (70) + content (~550) + footer (36) + padding + const height = 700; + + return new ImageResponse( + , + { + width, + height, + }, + ); + } catch (error) { + console.error("Error generating bracket image:", error); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json( + { error: `Failed to generate image: ${errorMessage}` }, + { status: 500 }, + ); + } +} + +// Also support GET for debugging/testing +export async function GET() { + return NextResponse.json({ + message: "Use POST with bracket data to generate an image", + example: { + bracket: "BracketState object", + }, + }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index e640c50..8f198ad 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -22,6 +22,8 @@ function BracketApp() { } }, []); + // Note: bracketRef is still used for the Bracket component's forwardRef + if (!isHydrated) { return (
@@ -49,10 +51,7 @@ function BracketApp() { {/* Controls */}
- setShowWelcome(true)} - /> + setShowWelcome(true)} />
{/* Bracket */} @@ -78,7 +77,7 @@ function BracketApp() { {/* Mobile/Tablet Action Bar - fixed to bottom on mobile and tablet */} - + ); } diff --git a/src/components/BracketControls.tsx b/src/components/BracketControls.tsx index 712caf6..b427422 100644 --- a/src/components/BracketControls.tsx +++ b/src/components/BracketControls.tsx @@ -18,14 +18,10 @@ import { SaveBracketDialog } from "./dialogs/SaveBracketDialog"; import { ShareMenu } from "./ShareMenu"; interface BracketControlsProps { - bracketRef: React.RefObject; onResetName?: () => void; } -export function BracketControls({ - bracketRef, - onResetName, -}: BracketControlsProps) { +export function BracketControls({ onResetName }: BracketControlsProps) { const { resetBracket, setUserName } = useBracket(); const [loadDialogOpen, setLoadDialogOpen] = useState(false); const [saveDialogOpen, setSaveDialogOpen] = useState(false); @@ -104,7 +100,7 @@ export function BracketControls({ Load - +
{/* Mobile/Tablet: Show reset dropdown at top - larger on tablets */} diff --git a/src/components/MobileActionBar.tsx b/src/components/MobileActionBar.tsx index 26b9fe5..8884577 100644 --- a/src/components/MobileActionBar.tsx +++ b/src/components/MobileActionBar.tsx @@ -1,6 +1,6 @@ "use client"; -import { Download, FolderOpen, Loader2, Save, Share2 } from "lucide-react"; +import { FolderOpen, Loader2, Save, Share2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { useBracket } from "@/contexts/BracketContext"; @@ -13,10 +13,6 @@ import { cn } from "@/lib/utils"; import { LoadBracketDialog } from "./dialogs/LoadBracketDialog"; import { SaveBracketDialog } from "./dialogs/SaveBracketDialog"; -interface MobileActionBarProps { - bracketRef: React.RefObject; -} - function ActionButton({ onClick, disabled, @@ -65,25 +61,16 @@ function ActionButton({ ); } -export function MobileActionBar({ bracketRef }: MobileActionBarProps) { +export function MobileActionBar() { const { bracket } = useBracket(); const [loadDialogOpen, setLoadDialogOpen] = useState(false); const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [isGenerating, setIsGenerating] = useState(false); - const [isSaving, setIsSaving] = useState(false); const generateImage = async (): Promise => { - if (!bracketRef.current) { - toast.error("Cannot generate image"); - return null; - } - setIsGenerating(true); try { - const blob = await generateBracketImage(bracketRef.current, { - userName: bracket.userName, - bracketName: bracket.name, - }); + const blob = await generateBracketImage(bracket); return blob; } catch { toast.error("Failed to generate image"); @@ -93,17 +80,6 @@ export function MobileActionBar({ bracketRef }: MobileActionBarProps) { } }; - const handleSaveScreenshot = async () => { - setIsSaving(true); - const blob = await generateImage(); - if (blob) { - const filename = `${bracket.userName.replace(/\s+/g, "-")}-bracket-${Date.now()}.png`; - await downloadImage(blob, filename); - toast.success("Bracket saved as image!"); - } - setIsSaving(false); - }; - const handleShare = async () => { const blob = await generateImage(); if (blob) { diff --git a/src/components/ShareMenu.tsx b/src/components/ShareMenu.tsx index 2800d3e..abb71c6 100644 --- a/src/components/ShareMenu.tsx +++ b/src/components/ShareMenu.tsx @@ -18,28 +18,16 @@ import { shareImage, } from "@/lib/image-generator"; -interface ShareMenuProps { - bracketRef: React.RefObject; -} - -export function ShareMenu({ bracketRef }: ShareMenuProps) { +export function ShareMenu() { const { bracket } = useBracket(); const [isGenerating, setIsGenerating] = useState(false); const generateImage = async (): Promise => { - if (!bracketRef.current) { - toast.error("Cannot generate image"); - return null; - } - setIsGenerating(true); try { - const blob = await generateBracketImage(bracketRef.current, { - userName: bracket.userName, - bracketName: bracket.name, - }); + const blob = await generateBracketImage(bracket); return blob; - } catch (err) { + } catch { toast.error("Failed to generate image"); return null; } finally { diff --git a/src/lib/image-generator.ts b/src/lib/image-generator.ts index 15e9bed..3614a42 100644 --- a/src/lib/image-generator.ts +++ b/src/lib/image-generator.ts @@ -1,4 +1,4 @@ -import * as htmlToImage from "html-to-image"; +import type { BracketState } from "@/types"; export interface GenerateImageOptions { userName: string; @@ -6,432 +6,50 @@ export interface GenerateImageOptions { } /** - * Convert an image URL to a data URI by fetching through our proxy + * Generates a bracket image by calling the server-side API + * This ensures consistent rendering across all devices, including iOS Safari */ -async function imageToDataUri(url: string): Promise { - try { - // Use our proxy to avoid CORS issues - const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(url)}`; - const response = await fetch(proxyUrl); - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status}`); - } - const blob = await response.blob(); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - } catch (error) { - console.error("Failed to convert image to data URI:", url, error); - // Return a transparent 1x1 pixel as fallback - return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; - } -} - -/** - * Replace all external image sources with data URIs to avoid CORS issues during capture - * Returns a cleanup function to restore original sources - */ -async function replaceImagesWithDataUris( - element: HTMLElement, -): Promise<() => void> { - const restoreFunctions: (() => void)[] = []; - - // Find all img elements (including those inside Next.js Image components) - const images = element.querySelectorAll("img"); - - // Create a map of unique URLs to avoid fetching duplicates - const urlToDataUri = new Map(); - const urlsToFetch: string[] = []; - - for (const img of images) { - const src = img.src; - // Only process external ESPN CDN images - if (src && src.includes("espncdn.com") && !urlToDataUri.has(src)) { - urlsToFetch.push(src); - } - } - - // Fetch all unique images in parallel - const dataUris = await Promise.all(urlsToFetch.map(imageToDataUri)); - urlsToFetch.forEach((url, index) => { - urlToDataUri.set(url, dataUris[index]); - }); - - // Replace all image sources - for (const img of images) { - const originalSrc = img.src; - const dataUri = urlToDataUri.get(originalSrc); - if (dataUri) { - // Also store the original srcset if any - const originalSrcset = img.srcset; - - img.src = dataUri; - img.srcset = ""; // Clear srcset to prevent Next.js Image from overriding - img.removeAttribute("data-nimg"); // Remove Next.js image marker - - restoreFunctions.push(() => { - img.src = originalSrc; - if (originalSrcset) { - img.srcset = originalSrcset; - } - }); - } - } - - return () => { - for (let i = restoreFunctions.length - 1; i >= 0; i--) { - restoreFunctions[i](); - } - }; -} - -/** - * Temporarily applies desktop/landscape layout styles to the bracket - * Returns a cleanup function to restore original styles - */ -function forceDesktopLayout(element: HTMLElement): () => void { - const restoreFunctions: (() => void)[] = []; - - // Hide the in-bracket header (we have our own in the exported image) - const bracketHeader = element.querySelector(".text-center") as HTMLElement; - if (bracketHeader && bracketHeader.querySelector("h2")) { - const originalDisplay = bracketHeader.style.display; - bracketHeader.style.display = "none"; - restoreFunctions.push(() => { - bracketHeader.style.display = originalDisplay; - }); - } - - // Force the main layout container to be horizontal (desktop layout) - // Find the flex container that has flex-col on mobile, flex-row on desktop - const layoutContainer = element.querySelector( - ".flex.flex-col.lg\\:flex-row", - ) as HTMLElement; - if (layoutContainer) { - const originalFlexDirection = layoutContainer.style.flexDirection; - const originalAlignItems = layoutContainer.style.alignItems; - const originalJustifyContent = layoutContainer.style.justifyContent; - const originalGap = layoutContainer.style.gap; - const originalWidth = layoutContainer.style.width; - - layoutContainer.style.flexDirection = "row"; - layoutContainer.style.alignItems = "flex-start"; - layoutContainer.style.justifyContent = "center"; - layoutContainer.style.gap = "2.5rem"; - layoutContainer.style.width = "auto"; - - restoreFunctions.push(() => { - layoutContainer.style.flexDirection = originalFlexDirection; - layoutContainer.style.alignItems = originalAlignItems; - layoutContainer.style.justifyContent = originalJustifyContent; - layoutContainer.style.gap = originalGap; - layoutContainer.style.width = originalWidth; - }); - } - - // Fix Super Bowl ordering (remove order-last, make it center) - const superBowlContainer = element.querySelector( - ".order-last.lg\\:order-none", - ) as HTMLElement; - if (superBowlContainer) { - const originalOrder = superBowlContainer.style.order; - const originalAlignSelf = superBowlContainer.style.alignSelf; - superBowlContainer.style.order = "0"; - superBowlContainer.style.alignSelf = "center"; - restoreFunctions.push(() => { - superBowlContainer.style.order = originalOrder; - superBowlContainer.style.alignSelf = originalAlignSelf; - }); - } - - // Fix NFC bracket direction - should be row-reverse on desktop (mirrored from AFC) - // The NFC bracket has lg:flex-row-reverse class which we need to apply - const nfcBracketRounds = element.querySelectorAll(".lg\\:flex-row-reverse"); - for (const bracketRounds of nfcBracketRounds) { - if (bracketRounds instanceof HTMLElement) { - const originalFlexDirection = bracketRounds.style.flexDirection; - bracketRounds.style.flexDirection = "row-reverse"; - restoreFunctions.push(() => { - bracketRounds.style.flexDirection = originalFlexDirection; - }); - } - } - - // Fix NFC conference header alignment (should be right-aligned on desktop) - const nfcHeader = element.querySelector(".lg\\:self-end") as HTMLElement; - if (nfcHeader) { - const originalAlignSelf = nfcHeader.style.alignSelf; - nfcHeader.style.alignSelf = "flex-end"; - restoreFunctions.push(() => { - nfcHeader.style.alignSelf = originalAlignSelf; - }); - } - - // Hide mobile-only UI elements (scroll hints, gradients, arrows) - const mobileOnlyElements = element.querySelectorAll(".lg\\:hidden"); - for (const el of mobileOnlyElements) { - if (el instanceof HTMLElement) { - const originalDisplay = el.style.display; - el.style.display = "none"; - restoreFunctions.push(() => { - el.style.display = originalDisplay; - }); - } - } - - // Show desktop-only elements (like completion status) - const desktopOnlyElements = element.querySelectorAll(".hidden.lg\\:flex"); - for (const el of desktopOnlyElements) { - if (el instanceof HTMLElement) { - const originalDisplay = el.style.display; - el.style.display = "flex"; - restoreFunctions.push(() => { - el.style.display = originalDisplay; - }); - } - } - - // Fix scroll wrapper widths (remove w-full, set auto width, clear max-width) - const scrollWrappers = element.querySelectorAll(".relative.w-full"); - for (const wrapper of scrollWrappers) { - if (wrapper instanceof HTMLElement) { - const originalWidth = wrapper.style.width; - const originalMaxWidth = wrapper.style.maxWidth; - wrapper.style.width = "auto"; - wrapper.style.maxWidth = "none"; - restoreFunctions.push(() => { - wrapper.style.width = originalWidth; - wrapper.style.maxWidth = originalMaxWidth; - }); - } - } - - // Fix scroll containers inside wrappers - const scrollContainers = element.querySelectorAll(".overflow-x-auto"); - for (const container of scrollContainers) { - if (container instanceof HTMLElement) { - const originalOverflow = container.style.overflow; - const originalWidth = container.style.width; - container.style.overflow = "visible"; - container.style.width = "auto"; - restoreFunctions.push(() => { - container.style.overflow = originalOverflow; - container.style.width = originalWidth; - }); - } - } - - // Fix Super Bowl container - ensure proper width and spacing - const superBowlInner = element.querySelector( - ".flex.flex-col.items-center.gap-3", - ) as HTMLElement; - if (superBowlInner) { - const originalMinWidth = superBowlInner.style.minWidth; - superBowlInner.style.minWidth = "220px"; - restoreFunctions.push(() => { - superBowlInner.style.minWidth = originalMinWidth; - }); - } - - // Fix column widths - the bracket round columns need to be wider for export - // These have classes like w-44, lg:w-36 - we need to override to a proper width - const roundColumns = element.querySelectorAll(".flex-shrink-0.flex-col"); - for (const col of roundColumns) { - if (col instanceof HTMLElement) { - const originalWidth = col.style.width; - const originalMinWidth = col.style.minWidth; - col.style.width = "180px"; - col.style.minWidth = "180px"; - restoreFunctions.push(() => { - col.style.width = originalWidth; - col.style.minWidth = originalMinWidth; - }); - } - } - - // Fix team card sizes - force medium size for export (not small) - // Cards have h-10 (sm), h-12 (md), h-14 (lg) - force h-12 (48px) minimum - const teamCards = element.querySelectorAll("button.rounded-lg.border-2"); - for (const card of teamCards) { - if (card instanceof HTMLElement) { - const originalHeight = card.style.height; - const originalPadding = card.style.padding; - const originalMinWidth = card.style.minWidth; - card.style.height = "48px"; - card.style.paddingLeft = "12px"; - card.style.paddingRight = "12px"; - card.style.minWidth = "160px"; - restoreFunctions.push(() => { - card.style.height = originalHeight; - card.style.padding = originalPadding; - card.style.minWidth = originalMinWidth; - }); - } - } - - // Also fix empty/TBD cards - const emptyCards = element.querySelectorAll(".border-dashed.border-gray-600"); - for (const card of emptyCards) { - if (card instanceof HTMLElement) { - const originalHeight = card.style.height; - const originalMinWidth = card.style.minWidth; - card.style.height = "48px"; - card.style.minWidth = "160px"; - restoreFunctions.push(() => { - card.style.height = originalHeight; - card.style.minWidth = originalMinWidth; - }); - } - } - - return () => { - // Restore in reverse order - for (let i = restoreFunctions.length - 1; i >= 0; i--) { - restoreFunctions[i](); - } - }; -} - export async function generateBracketImage( - element: HTMLElement, - options: GenerateImageOptions, + bracket: BracketState, ): Promise { - // Replace external images with data URIs to avoid CORS issues - const restoreImages = await replaceImagesWithDataUris(element); - - // Force desktop/landscape layout - const restoreLayout = forceDesktopLayout(element); + const response = await fetch("/api/generate-bracket-image", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ bracket }), + }); - // Find all elements with overflow that might clip content and temporarily disable - const overflowElements: { el: HTMLElement; original: string }[] = []; - const scrollContainers = element.querySelectorAll( - '.overflow-x-auto, .overflow-hidden, [style*="overflow"]', - ); - for (const el of scrollContainers) { - if (el instanceof HTMLElement) { - overflowElements.push({ el, original: el.style.overflow }); - el.style.overflow = "visible"; + if (!response.ok) { + // Try to get error details from response + let errorMessage = "Failed to generate image"; + try { + const errorData = await response.json(); + errorMessage = errorData.error || errorMessage; + } catch { + // If response isn't JSON, try text + try { + const text = await response.text(); + if (text) { + errorMessage = text.slice(0, 200); // Limit error message length + } + } catch { + // Ignore - use default message + } } + console.error("Image generation failed:", response.status, errorMessage); + throw new Error(errorMessage); } - // Also handle the main element - const originalOverflow = element.style.overflow; - element.style.overflow = "visible"; - - // Small delay to ensure layout reflow is complete - await new Promise((resolve) => setTimeout(resolve, 150)); - - // Get the full scroll dimensions (content may be wider than visible area) - const fullWidth = Math.max(element.scrollWidth, element.offsetWidth); - const fullHeight = Math.max(element.scrollHeight, element.offsetHeight); - let canvas: HTMLCanvasElement; - try { - // Use html-to-image which has better CSS support - canvas = await htmlToImage.toCanvas(element, { - backgroundColor: "#000000", // Pure black for OLED - pixelRatio: 2, // Higher resolution - cacheBust: true, // Avoid cache issues - width: fullWidth, - height: fullHeight, - fetchRequestInit: { - mode: "cors", - credentials: "omit", - }, - }); - } catch (captureError) { - // Restore everything before throwing - element.style.overflow = originalOverflow; - for (const { el, original } of overflowElements) { - el.style.overflow = original; - } - restoreLayout(); - restoreImages(); - throw captureError; - } + const blob = await response.blob(); - // Restore overflow after successful capture - element.style.overflow = originalOverflow; - for (const { el, original } of overflowElements) { - el.style.overflow = original; + // Verify we got an image + if (!blob.type.startsWith("image/")) { + console.error("Unexpected response type:", blob.type); + throw new Error("Server returned non-image response"); } - // Restore layout and images - restoreLayout(); - restoreImages(); - - // Add header with user info - const finalCanvas = document.createElement("canvas"); - const ctx = finalCanvas.getContext("2d")!; - const headerHeight = 80; - const padding = 40; - - finalCanvas.width = canvas.width + padding * 2; - finalCanvas.height = canvas.height + headerHeight + padding * 2; - - // Background - pure black for OLED - ctx.fillStyle = "#000000"; - ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height); - - // Header - subtle gradient bar with NFL colors - const gradient = ctx.createLinearGradient(0, 0, finalCanvas.width, 0); - gradient.addColorStop(0, "#DC2626"); // red-600 - gradient.addColorStop(0.5, "#1f1f1f"); // Dark center - gradient.addColorStop(1, "#2563EB"); // blue-600 - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, finalCanvas.width, headerHeight); - - // Header text - ctx.fillStyle = "#FFFFFF"; - ctx.font = "bold 32px system-ui, -apple-system, sans-serif"; - ctx.textAlign = "center"; - ctx.fillText( - `${options.userName}'s Playoff Bracket`, - finalCanvas.width / 2, - 35, - ); - - // Only show bracket name subtitle if one was set - if (options.bracketName) { - ctx.font = "20px system-ui, -apple-system, sans-serif"; - ctx.fillStyle = "#A1A1AA"; // Muted gray - ctx.fillText(options.bracketName, finalCanvas.width / 2, 62); - } - - // Draw the bracket - ctx.drawImage(canvas, padding, headerHeight + padding); - - // Footer with branding - ctx.font = "bold 14px system-ui, -apple-system, sans-serif"; - ctx.fillStyle = "#6B7280"; - ctx.textAlign = "right"; - ctx.fillText( - "bracket.build • NFL Playoffs 2025-26", - finalCanvas.width - padding, - finalCanvas.height - 15, - ); - - return new Promise((resolve, reject) => { - try { - finalCanvas.toBlob( - (blob) => { - if (blob) { - resolve(blob); - } else { - reject( - new Error("Failed to generate image - toBlob returned null"), - ); - } - }, - "image/png", - 1.0, - ); - } catch (toBlobError) { - reject(toBlobError); - } - }); + return blob; } export async function downloadImage( @@ -455,7 +73,7 @@ export async function copyImageToClipboard(blob: Blob): Promise { "image/png": blob, }), ]); - } catch (error) { + } catch { throw new Error("Failed to copy image to clipboard"); } } @@ -482,7 +100,7 @@ export async function shareImage( files: [file], }); return true; - } catch (error) { + } catch { // User cancelled or share failed return false; }