Add 500K celebration changes

This commit is contained in:
Jared Schoeny
2026-07-11 09:26:59 -06:00
parent 43aa0236e1
commit 4d07738144
16 changed files with 374 additions and 37 deletions

19
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"@types/serialize-javascript": "^5.0.4",
"@vercel/analytics": "^1.6.1",
"@vercel/edge-config": "^1.4.3",
"canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1",
"embla-carousel-react": "8.6.0",
"js-sha1": "^0.7.0",
@@ -45,6 +46,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/canvas-confetti": "^1.9.0",
"@types/mjml": "^5.0.0",
"@types/mjml-core": "^5.0.0",
"@types/node": "^20",
@@ -2779,6 +2781,13 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/canvas-confetti": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -3628,6 +3637,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas-confetti": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
"license": "ISC",
"funding": {
"type": "donate",
"url": "https://www.paypal.me/kirilvatev"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",

View File

@@ -19,6 +19,7 @@
"@types/serialize-javascript": "^5.0.4",
"@vercel/analytics": "^1.6.1",
"@vercel/edge-config": "^1.4.3",
"canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1",
"embla-carousel-react": "8.6.0",
"js-sha1": "^0.7.0",
@@ -46,6 +47,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/canvas-confetti": "^1.9.0",
"@types/mjml": "^5.0.0",
"@types/mjml-core": "^5.0.0",
"@types/node": "^20",

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -102,6 +102,23 @@ html { scroll-padding-top: var(--anchor-offset); }
border: 1px solid var(--border);
border-radius: 12px;
}
/* Interpolatable ready-ring for homepage hero hack cards (Tailwind ring-* does not transition cleanly) */
.hero-hack-card {
box-shadow: 0 0 0 1px var(--border);
transition: box-shadow 500ms ease;
}
.hero-hack-card[data-ready="true"] {
box-shadow: 0 0 0 1px rgb(52 211 153 / 0.5); /* emerald-400/50 */
}
@keyframes heroHackFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.hero-hack-image {
animation: heroHackFadeIn 500ms ease forwards;
}
.card-simple {
background: var(--surface-2);
border: 1px solid var(--border);
@@ -157,6 +174,25 @@ html { scroll-padding-top: var(--anchor-offset); }
.anim-pulse { animation: pulseSoft 2.4s ease-in-out infinite; }
.anim-pop { animation: popIn 220ms cubic-bezier(0.16, 1, 0.3, 1); }
@keyframes heroConnectorDash {
to { stroke-dashoffset: -24; }
}
.hero-connector-path {
stroke-dasharray: 6 6;
animation: heroConnectorDash 1.2s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.anim-float,
.anim-pulse,
.anim-pop,
.hero-connector-path,
.hero-hack-image {
animation: none !important;
}
}
.shine-wrap { position: relative; overflow: hidden; border-radius: inherit; }
.shine-wrap::after {
content: "";

View File

@@ -4,6 +4,9 @@ import { FaArrowRightLong } from "react-icons/fa6";
import { createClient } from "@/utils/supabase/server";
import HackCard from "@/components/HackCard";
import Button from "@/components/Button";
import MilestoneCelebration from "@/components/Home/MilestoneCelebration";
import HeroPatchDiagram from "@/components/Home/HeroPatchDiagram";
import HeroAuthCta from "@/components/Home/HeroAuthCta";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { HackCardAttributes } from "@/components/HackCard";
import { resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
@@ -146,51 +149,77 @@ export default async function Home() {
<div>
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-grid opacity-30 pointer-events-none" />
<div className="mx-auto max-w-screen-2xl px-6 py-10 sm:py-20">
<div className="max-w-2xl">
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
<span className="gradient-text">Discover</span> and share Pokémon romhacks
</h1>
<p className="mt-4 text-[15px] text-foreground/80">
Find community-made hacks and patch in-browser with your own legally-obtained base ROMs for Game Boy, Game Boy Color, Game Boy Advance, and Nintendo DS.
</p>
<div className="mt-12 sm:mt-8 mx-auto flex flex-col items-start max-w-[320px] sm:flex-row sm:items-center sm:max-w-none gap-3">
<Link
href="/discover"
className="inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md bg-[var(--accent)] px-5 text-base font-semibold sm:font-medium text-[var(--accent-foreground)] transition-colors hover:bg-[var(--accent-700)] elevate"
>
Explore hacks
</Link>
<Link
href="/submit"
className="inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md border border-white/10 bg-white/10 px-5 text-base font-semibold sm:font-medium text-foreground transition-colors hover:bg-white/15 elevate"
>
Submit a patch
</Link>
<Link
href="/login"
className="inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md sm:px-5 text-base font-medium text-foreground/90 hover:underline"
>
Already a creator? Log in
</Link>
<div className="mx-auto max-w-screen-2xl px-6 py-12 sm:py-20">
<div className="grid items-center gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,34rem)] xl:grid-cols-[minmax(0,1fr)_minmax(0,42rem)]">
<div>
<MilestoneCelebration />
<h1 className="mt-5 text-4xl font-bold tracking-tight sm:text-6xl text-balance">
Bring your ROM once.
<br />
<span className="gradient-text">Play everything it unlocks.</span>
</h1>
<p className="mt-4 max-w-xl text-[15px] leading-relaxed text-foreground/80">
Link your legally-obtained base ROM one time and Hackdex caches it on your
device. Patch any Pokémon ROM hack built for it, right in your browser,
without ever re-uploading. Your files never leave your device.
</p>
<div className="mt-8 mx-auto flex w-full max-w-[320px] flex-col items-start gap-3 sm:mx-0 sm:max-w-none sm:flex-row sm:items-center">
<Link
href="/discover"
className="inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md bg-[var(--accent)] px-5 text-base font-semibold sm:font-medium text-[var(--accent-foreground)] transition-colors hover:bg-[var(--accent-700)] elevate"
>
Explore hacks
</Link>
<Link
href="/submit"
className="inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md border border-white/10 bg-white/10 px-5 text-base font-semibold sm:font-medium text-foreground transition-colors hover:bg-white/15 elevate"
>
Submit a patch
</Link>
<HeroAuthCta />
</div>
<p className="mt-6 text-xs text-foreground/60">
Supports Game Boy, Game Boy Color, Game Boy Advance, and Nintendo DS.
</p>
</div>
<HeroPatchDiagram />
</div>
</div>
</section>
<section className={`mx-auto max-w-screen-2xl px-6 ${hackData.length > 0 ? "pt-6 sm:pt-12" : "py-6 sm:py-12"}`}>
<div className="grid gap-6 sm:grid-cols-3">
<div className="card p-5">
<div className="text-[15px] font-semibold tracking-tight">Curated discovery</div>
<p className="mt-1 text-sm text-foreground/70">Browse popular and trending patches across generations.</p>
<h2 className="text-sm font-semibold uppercase tracking-widest text-foreground/60">How it works</h2>
<div className="relative mt-4 grid gap-8 sm:grid-cols-3 sm:gap-6">
<div
className="pointer-events-none absolute top-3.5 right-[calc(16.666%-0.875rem)] left-3.5 hidden h-px bg-[var(--border)] sm:block"
aria-hidden="true"
/>
<div>
<span className="relative z-10 flex h-7 w-7 items-center justify-center rounded-full bg-[var(--background)] text-[13px] font-semibold text-[var(--accent)] shadow-[0_0_0_8px_var(--background)] ring-2 ring-[var(--accent)]/30">
1
</span>
<div className="mt-3 text-[15px] font-semibold tracking-tight">Link your base ROM</div>
<p className="mt-1 max-w-xs text-sm text-foreground/70">
Point Hackdex at your legally-obtained ROM file. It&apos;s verified and matched automatically.
</p>
</div>
<div className="card p-5">
<div className="text-[15px] font-semibold tracking-tight">Built-in patcher</div>
<p className="mt-1 text-sm text-foreground/70">Provide your base ROMs and patch in the browser.</p>
<div>
<span className="relative z-10 flex h-7 w-7 items-center justify-center rounded-full bg-[var(--background)] text-[13px] font-semibold text-[var(--accent)] shadow-[0_0_0_8px_var(--background)] ring-2 ring-[var(--accent)]/30">
2
</span>
<div className="mt-3 text-[15px] font-semibold tracking-tight">It stays on your device</div>
<p className="mt-1 max-w-xs text-sm text-foreground/70">
Your ROM is cached locally in your browser, never uploaded, and ready whenever you come back.
</p>
</div>
<div className="card p-5">
<div className="text-[15px] font-semibold tracking-tight">Submit your own</div>
<p className="mt-1 text-sm text-foreground/70">Join as a creator and submit your own rom hacks.</p>
<div>
<span className="relative z-10 flex h-7 w-7 items-center justify-center rounded-full bg-[var(--background)] text-[13px] font-semibold text-[var(--accent)] shadow-[0_0_0_8px_var(--background)] ring-2 ring-[var(--accent)]/30">
3
</span>
<div className="mt-3 text-[15px] font-semibold tracking-tight">Patch hack after hack</div>
<p className="mt-1 max-w-xs text-sm text-foreground/70">
Pick any hack built for your ROM and patch it in your browser in seconds, with no re-uploading between hacks.
</p>
</div>
</div>
<div className="my-6 mx-auto flex flex-col items-center max-w-[320px] sm:mt-10">

View File

@@ -0,0 +1,25 @@
"use client";
import Link from "next/link";
import { useAuthContext } from "@/contexts/AuthContext";
const linkClassName =
"inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md sm:px-5 text-base font-medium text-foreground/90 hover:underline";
export default function HeroAuthCta() {
const { user } = useAuthContext();
if (user) {
return (
<Link href="/dashboard" className={linkClassName}>
Go to dashboard
</Link>
);
}
return (
<Link href="/login" className={linkClassName}>
Already a creator? Log in
</Link>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { IoGameControllerOutline } from "react-icons/io5";
const HOMEPAGE_HACKS = [
{ title: "Too Many Types 2", src: "/homepage/too-many-types-2.png" },
{ title: "Pokémon Odyssey", src: "/homepage/pokemon-odyssey.png" },
{ title: "Super Mariomon", src: "/homepage/super-mariomon.png" },
{ title: "Sword and Shield Ultimate Plus", src: "/homepage/sword-and-shield-ultimate-plus.png" },
{ title: "Pokémon Crystal Advance Redux", src: "/homepage/pokemon-crystal-advance-redux.png" },
{ title: "Pokémon ROWE", src: "/homepage/pokemon-rowe.png" },
{ title: "Emerald Rogue", src: "/homepage/emerald-rogue.png" },
{ title: "Celia's Stupid Romhack", src: "/homepage/celias-stupid-romhack.gif" },
{ title: "Pokémon Unbound", src: "/homepage/pokemon-unbound.png" },
] as const;
type Pick = (typeof HOMEPAGE_HACKS)[number] & { delay: string };
export default function HeroPatchDiagram() {
const [picks, setPicks] = useState<Pick[]>([]);
useEffect(() => {
setPicks(
[...HOMEPAGE_HACKS]
.sort(() => Math.random() - 0.5)
.slice(0, 3)
.map((h, i) => ({ ...h, delay: `${i * 0.6}s` }))
);
}, []);
const slots: (Pick | null)[] = picks.length === 3 ? picks : [null, null, null];
return (
<div className="relative mx-auto w-full max-w-full" aria-hidden="true">
<div className="flex w-full flex-col items-center gap-0">
{/* Base ROM card */}
<div className="card relative z-10 w-full max-w-64 p-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[var(--surface-2)] ring-1 ring-[var(--border)]">
<IoGameControllerOutline size={18} className="text-foreground/70" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold tracking-tight">Your base ROM</div>
<div className="mt-0.5 flex items-center gap-1.5">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-400" />
<span className="truncate text-xs text-emerald-400">Cached on this device</span>
</div>
</div>
</div>
</div>
{/* Connectors + cards share one shrink-wrapped width so they stay centered together */}
<div className="flex w-full max-w-md flex-col items-center md:w-fit md:max-w-none">
<div className="relative h-14 w-full sm:h-[4.75rem]">
<svg
className="absolute inset-0 h-full w-full overflow-visible"
viewBox="0 0 416 76"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
>
<path
className="hero-connector-path"
d="M208 0 C208 28, 68 28, 68 56"
stroke="var(--border)"
strokeWidth="1.5"
fill="none"
/>
<path
className="hero-connector-path"
d="M208 0 C208 36, 208 36, 208 76"
stroke="var(--border)"
strokeWidth="1.5"
fill="none"
/>
<path
className="hero-connector-path"
d="M208 0 C208 28, 348 28, 348 56"
stroke="var(--border)"
strokeWidth="1.5"
fill="none"
/>
</svg>
</div>
<div className="relative z-10 flex w-full items-start gap-2 sm:gap-3 xl:gap-4">
{slots.map((hack, index) => (
<div
key={index}
data-ready={hack ? "true" : "false"}
className={`hero-hack-card card anim-float min-w-0 flex-1 p-2 sm:p-2.5 md:w-32 md:flex-none lg:w-36 xl:w-40 ${index === 1 ? "mt-3 sm:mt-5" : ""}`}
style={{ animationDelay: hack?.delay ?? `${index * 0.6}s` }}
>
<div className="relative aspect-[3/2] w-full overflow-hidden rounded-md bg-[var(--surface-2)]">
{hack && (
<Image
src={hack.src}
alt=""
fill
sizes="160px"
className="hero-hack-image object-cover"
unoptimized={hack.src.endsWith(".gif")}
/>
)}
</div>
<div data-ready={hack ? "true" : "false"} className="mt-1.5 truncate text-[11px] font-medium tracking-tight text-foreground/85 sm:mt-2 sm:text-xs opacity-0 data-[ready=true]:opacity-100 transition-opacity duration-500">
{hack?.title ?? "\u00A0" /* non-breaking space */}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { PiConfettiBold } from "react-icons/pi";
const STORAGE_KEY = "hackdex:500k-confetti";
const COLORS = ["#f43f5e", "#f97316", "#f59e0b", "#fb7185"];
const BURST_MS_DESKTOP = 2200;
const BURST_MS_MOBILE = 1400;
function prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function isMobileViewport(): boolean {
return window.matchMedia("(max-width: 640px)").matches;
}
export default function MilestoneCelebration() {
const frameRef = useRef<number | null>(null);
const stopConfetti = useCallback(() => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
}, []);
const fireConfetti = useCallback(async () => {
if (prefersReducedMotion()) return;
stopConfetti();
const { default: confetti } = await import("canvas-confetti");
const mobile = isMobileViewport();
const end = Date.now() + (mobile ? BURST_MS_MOBILE : BURST_MS_DESKTOP);
const particleCount = mobile ? 2 : 3;
const startVelocity = mobile ? 64 : 55;
const originY = mobile ? 0.95 : 0.65;
const frame = () => {
if (Date.now() > end) {
frameRef.current = null;
return;
}
confetti({
particleCount,
angle: mobile ? 75 : 60,
spread: mobile ? 40 : 50,
startVelocity,
origin: { x: 0, y: originY },
colors: COLORS,
disableForReducedMotion: true,
});
confetti({
particleCount,
angle: mobile ? 105 : 120,
spread: mobile ? 40 : 50,
startVelocity,
origin: { x: 1, y: originY },
colors: COLORS,
disableForReducedMotion: true,
});
frameRef.current = requestAnimationFrame(frame);
};
frameRef.current = requestAnimationFrame(frame);
}, [stopConfetti]);
useEffect(() => {
let alreadyShown = false;
try {
alreadyShown = localStorage.getItem(STORAGE_KEY) === "1";
} catch {
// Private browsing or storage blocked
}
if (alreadyShown || prefersReducedMotion()) return;
const timer = window.setTimeout(() => {
void fireConfetti();
try {
localStorage.setItem(STORAGE_KEY, "1");
} catch {
// Private browsing or storage blocked
}
}, 600);
return () => {
window.clearTimeout(timer);
stopConfetti();
};
}, [fireConfetti, stopConfetti]);
return (
<button
type="button"
onClick={() => void fireConfetti()}
className="inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-sm ring-1 ring-[var(--accent)]/30 bg-[var(--accent)]/10 text-foreground/90 elevate hover:ring-[var(--accent)]/50 transition cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
>
<PiConfettiBold size={16} className="text-[var(--accent)]" aria-hidden="true" />
<span className="font-semibold gradient-text">500,000+ downloads</span>
<span className="-ml-0.5">Thank you!</span>
<span className="sr-only">. Activate to replay the celebration confetti.</span>
</button>
);
}