Initial mockup commit

This commit is contained in:
Jared Schoeny 2025-10-06 15:26:37 -10:00
parent 65cf2d5a46
commit 64ee43d409
34 changed files with 4193 additions and 111 deletions

1644
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,16 +8,24 @@
"start": "next start"
},
"dependencies": {
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"embla-carousel-react": "8.6.0",
"next": "15.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.5.4"
"react-icons": "^5.5.0",
"react-markdown": "9.0.3",
"remark-gfm": "4.0.0"
},
"devDependencies": {
"typescript": "^5",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4"
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

21
src/app/discover/page.tsx Normal file
View File

@ -0,0 +1,21 @@
import DiscoverBrowser from "@/components/Discover/DiscoverBrowser";
export default function DiscoverPage() {
return (
<div className="mx-auto max-w-screen-2xl px-6 py-10">
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Discover ROM hacks</h1>
<p className="mt-2 text-[15px] text-foreground/80">
Browse curated patches. See what others are downloading.
</p>
</div>
</div>
<div className="mt-6">
<DiscoverBrowser />
</div>
</div>
);
}

View File

@ -1,8 +1,18 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--background: #ffffff;
--foreground: #171717;
--ring: #f43f5e;
--muted: rgba(0,0,0,0.20);
--border: rgba(0,0,0,0.12);
--accent: #f43f5e;
--accent-700: #be123c;
--accent-foreground: #ffffff;
--surface: #ffffff;
--surface-2: #fafafa;
--grid-dot-color: rgba(0,0,0,0.08);
}
@theme inline {
@ -16,6 +26,14 @@
:root {
--background: #0a0a0a;
--foreground: #ededed;
--muted: rgba(255,255,255,0.20);
--border: rgba(255,255,255,0.10);
--accent: #f43f5e;
--accent-700: #be123c;
--accent-foreground: #ffffff;
--surface: rgba(255,255,255,0.04);
--surface-2: rgba(255,255,255,0.06);
--grid-dot-color: rgba(255,255,255,0.08);
}
}
@ -24,3 +42,146 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
.bg-grid { background-image: radial-gradient(var(--grid-dot-color) 1px, transparent 1px); background-size: 22px 22px; }
.card {
background: linear-gradient(to bottom right, var(--surface-2), var(--surface));
border: 1px solid var(--border);
border-radius: 12px;
}
.frosted {
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
backdrop-filter: blur(8px);
}
.gradient-text {
background: linear-gradient(90deg, #f43f5e, #f97316 40%, #f59e0b);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.aurora {
position: absolute;
inset: -20% -10% -20% -10%;
background: radial-gradient(60% 60% at 20% 20%, rgba(244,63,94,0.20), transparent 60%),
radial-gradient(50% 50% at 80% 10%, rgba(249,115,22,0.18), transparent 60%),
radial-gradient(40% 40% at 50% 80%, rgba(245,158,11,0.18), transparent 60%);
filter: blur(60px);
opacity: 0.7;
pointer-events: none;
}
.elevate {
box-shadow: 0 10px 30px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.06);
}
/* Animations */
@keyframes floatY {
0% { transform: translateY(0); }
50% { transform: translateY(-4px); }
100% { transform: translateY(0); }
}
@keyframes pulseSoft {
0%, 100% { box-shadow: 0 0 0 0 rgba(244,63,94,0.35); }
50% { box-shadow: 0 0 0 8px rgba(244,63,94,0); }
}
@keyframes popIn {
0% { transform: scale(0.9); opacity: 0.6; }
60% { transform: scale(1.06); opacity: 1; }
100% { transform: scale(1); }
}
.anim-float:hover { animation: floatY 2.4s ease-in-out infinite; }
.anim-pulse { animation: pulseSoft 2.4s ease-in-out infinite; }
.anim-pop { animation: popIn 220ms cubic-bezier(0.16, 1, 0.3, 1); }
.shine-wrap { position: relative; overflow: hidden; border-radius: inherit; }
.shine-wrap::after {
content: "";
position: absolute;
inset: 0; pointer-events: none;
background: linear-gradient(120deg, transparent 46%, rgba(255,255,255,0.06) 50%, transparent 54%);
transform: translateX(-100%);
transition: transform 900ms ease;
border-radius: inherit;
}
.shine-wrap:hover::after { transform: translateX(100%); }
.shine-wrap:disabled::after { display: none; }
/* Gradient background similar to gradient-text but for surfaces */
.gradient-bg {
background: linear-gradient(90deg, #f43f5e, #f97316 40%, #f59e0b);
color: var(--accent-foreground);
}
/* Premium gradient button */
.btn-premium {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
padding-left: 1.25rem;
padding-right: 1.25rem;
color: #fff;
background: transparent;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12), inset 0 1px 0 rgba(255,255,255,0.22), 0 10px 22px rgba(244,63,94,0.22), 0 4px 10px rgba(249,115,22,0.15);
transition: transform 160ms ease, filter 160ms ease, box-shadow 160ms ease;
}
.btn-premium::before {
content: "";
position: absolute;
inset: 0;
/* Keep square so the reveal edge can be straight; parent clips corners */
border-radius: 0;
background: linear-gradient(135deg, #e11d48, #f43f5e 30%, #f97316 65%, #f59e0b);
/* Start fully hidden; reveal via clip-path animation */
clip-path: polygon(0 0, 0 0, 0 100%, 0 100%);
will-change: clip-path;
z-index: 0;
}
.btn-premium > * { position: relative; z-index: 2; }
.btn-premium:not(:disabled)::before { animation: gradientRevealClip 520ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
.btn-premium:hover {
filter: brightness(1.04);
transform: translateY(-1px);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.16), inset 0 1px 0 rgba(255,255,255,0.28), 0 14px 28px rgba(244,63,94,0.26), 0 6px 14px rgba(249,115,22,0.18);
}
.btn-premium:active {
transform: translateY(0);
filter: brightness(0.98);
}
.btn-premium:disabled {
background: transparent;
color: var(--muted);
box-shadow: 0 0 0 1px var(--muted);
filter: grayscale(35%) brightness(0.9);
transform: scale(0.98);
}
.btn-premium:not(:disabled) {
animation: popIn 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes gradientRevealClip {
0% { clip-path: polygon(0 0, 0 0, 0 100%, 0 100%); }
100% { clip-path: polygon(0 0, calc(100% + 16px) 0, 100% 100%, 0 100%); }
}
/* Typography plugin theme to match CSS variables in both light and dark */
.prose {
--tw-prose-body: var(--foreground);
--tw-prose-headings: var(--foreground);
--tw-prose-lead: var(--foreground);
--tw-prose-links: var(--accent);
--tw-prose-bold: var(--foreground);
--tw-prose-quotes: var(--foreground);
--tw-prose-code: var(--foreground);
--tw-prose-hr: var(--border);
--tw-prose-captions: color-mix(in oklab, var(--foreground) 70%, transparent);
}

View File

@ -0,0 +1,121 @@
import { hacks } from "@/data/hacks";
import { notFound } from "next/navigation";
import Gallery from "@/components/Hack/Gallery";
import HackActions from "@/components/Hack/HackActions";
import { formatCompactNumber } from "@/utils/format";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import Image from "next/image";
import { FaDiscord, FaTwitter } from "react-icons/fa6";
import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon";
interface HackDetailProps {
params: Promise<{ slug: string }>;
}
export default async function HackDetail({ params }: HackDetailProps) {
const { slug } = await params;
const hack = hacks.find((h) => h.slug === slug) ?? notFound();
return (
<div className="mx-auto max-w-screen-lg px-6 pb-28">
<HackActions title={hack.title} version={hack.version} author={hack.author} baseRom={hack.baseRom} />
<div className="pt-8 md:pt-10">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">{hack.title}</h1>
<span className="rounded-full bg-[var(--surface-2)] px-3 py-1 text-xs font-medium text-foreground/85 ring-1 ring-[var(--border)]">
{hack.version}
</span>
</div>
<p className="mt-1 text-[15px] text-foreground/70">By {hack.author}</p>
<p className="mt-2 text-sm text-foreground/75">{hack.summary}</p>
<div className="mt-2 flex flex-wrap gap-2">
{hack.tags.map((t) => (
<span key={t} className="rounded-full bg-[var(--surface-2)] px-2.5 py-1 text-xs ring-1 ring-[var(--border)]">
{t}
</span>
))}
</div>
</div>
<div className="inline-flex items-center gap-2 rounded-full ring-1 ring-[var(--border)] bg-[var(--surface-2)] px-3 py-1 text-sm text-foreground/85">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span>{formatCompactNumber(hack.downloads)}</span>
</div>
</div>
</div>
<div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="space-y-6">
<Gallery images={hack.covers} title={hack.title} />
<div className="card p-5">
<h2 className="text-xl font-semibold tracking-tight">About this hack</h2>
<div className="prose prose-sm mt-3 max-w-none text-foreground/80">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{hack.description}</ReactMarkdown>
</div>
</div>
</div>
<aside className="space-y-6 self-start w-full lg:w-auto">
<div className="card p-5">
<h3 className="text-[15px] font-semibold tracking-tight">Details</h3>
<ul className="mt-3 grid gap-2 text-sm text-foreground/75">
<li>Base ROM: {hack.baseRom}</li>
<li>Format: BPS</li>
<li>Created: {new Date(hack.createdAt).toLocaleDateString()}</li>
{hack.updatedAt && (
<li>Last updated: {new Date(hack.updatedAt).toLocaleDateString()}</li>
)}
{hack.socialLinks && (
<li className="flex flex-wrap items-center justify-center gap-4 mt-4">
{hack.socialLinks.discord && (
<a className="underline underline-offset-2 hover:text-foreground/90 hover:scale-110 transition-transform duration-300" href={hack.socialLinks.discord} target="_blank" rel="noreferrer">
<FaDiscord size={32} />
</a>
)}
{hack.socialLinks.twitter && (
<a className="underline underline-offset-2 hover:text-foreground/90 hover:scale-110 transition-transform duration-300" href={hack.socialLinks.twitter} target="_blank" rel="noreferrer">
<FaTwitter size={32} />
</a>
)}
{hack.socialLinks.pokecommunity && (
<a className="underline underline-offset-2 hover:text-foreground/90 hover:scale-110 transition-transform duration-300" href={hack.socialLinks.pokecommunity} target="_blank" rel="noreferrer">
<PokeCommunityIcon width={32} height={32} color="currentColor" />
</a>
)}
</li>
)}
</ul>
</div>
{hack.boxArt && (
<div className="card overflow-hidden pb-6 lg:pb-0">
<div className="flex items-center justify-between">
<div className="px-5 py-3 text-[15px] font-semibold tracking-tight">Box art</div>
<a
className="px-5 py-3 text-[15px] tracking-tight text-foreground/70 hover:underline"
href={hack.boxArt}
download
target="_blank"
rel="noreferrer"
>
Download
</a>
</div>
<div className="relative aspect-square w-full max-h-[340px]">
<Image src={hack.boxArt} alt={`${hack.title} box art`} fill className="object-contain" unoptimized />
</div>
</div>
)}
</aside>
</div>
</div>
);
}

View File

@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { BaseRomProvider } from "@/contexts/BaseRomContext";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -13,8 +16,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Hackdex",
description: "Discover and share Pokémon ROM hack patches.",
};
export default function RootLayout({
@ -27,7 +30,14 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<BaseRomProvider>
<div className="fixed inset-0 -z-10">
<div className="aurora" />
</div>
<Header />
<main>{children}</main>
<Footer />
</BaseRomProvider>
</body>
</html>
);

View File

@ -1,103 +1,52 @@
import Image from "next/image";
import Link from "next/link";
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
<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-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 ROM hack patches
</h1>
<p className="mt-4 text-[15px] text-foreground/80">
Find community-made hacks, view download counts, and patch in-browser with your own legally-obtained base ROMs.
</p>
<div className="mt-8 flex items-center gap-3">
<Link
href="/discover"
className="inline-flex h-12 items-center justify-center rounded-md bg-[var(--accent)] px-5 text-base font-medium text-[var(--accent-foreground)] transition-colors hover:bg-[var(--accent-700)] elevate"
>
Explore hacks
</Link>
<Link
href="/submit"
className="inline-flex h-12 items-center justify-center rounded-md border border-white/10 bg-white/10 px-5 text-base font-medium text-foreground transition-colors hover:bg-white/15 elevate"
>
Submit a patch
</Link>
</div>
</div>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</section>
<section className="mx-auto max-w-screen-2xl px-6 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>
</div>
<div className="card p-5">
<div className="text-[15px] font-semibold tracking-tight">Download insights</div>
<p className="mt-1 text-sm text-foreground/70">See what&apos;s popular at a glance with download counts.</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>
</div>
</section>
</div>
);
}

17
src/app/roms/page.tsx Normal file
View File

@ -0,0 +1,17 @@
import RomsInteractive from "@/components/Roms/RomsInteractive";
import BaseRomReadyCount from "@/components/Roms/BaseRomReadyCount";
export default function RomsPage() {
return (
<div className="mx-auto max-w-screen-lg px-6 py-10">
<h1 className="text-3xl font-bold tracking-tight">Your Base ROMs <BaseRomReadyCount /></h1>
<p className="mt-2 text-[15px] text-foreground/80">
Link your legally-obtained base ROM files from your device so the patcher can auto-detect them. Files never leave your
device.
</p>
<RomsInteractive />
</div>
);
}

16
src/app/submit/page.tsx Normal file
View File

@ -0,0 +1,16 @@
import SubmitForm from "@/components/Submit/SubmitForm";
export default function SubmitPage() {
return (
<div className="mx-auto max-w-screen-lg px-6 py-10">
<h1 className="text-3xl font-bold tracking-tight">Submit your ROM hack</h1>
<p className="mt-2 text-[15px] text-foreground/80">Share your hack so others can discover and play it.</p>
<div className="mt-8">
<SubmitForm />
</div>
</div>
);
}

View File

@ -0,0 +1,115 @@
import React from "react";
type Status = "granted" | "prompt" | "denied" | "error";
export default function BaseRomCard({
name,
platform,
region,
isLinked,
status,
isCached,
onRemoveCache,
onUnlink,
onEnsurePermission,
onImportCache,
}: {
name: string;
platform: "GB" | "GBC" | "GBA" | "NDS";
region: string;
isLinked: boolean;
status: Status;
isCached: boolean;
onRemoveCache?: () => void;
onUnlink?: () => void;
onEnsurePermission?: () => void;
onImportCache?: () => void;
}) {
const ringAndBg = isCached
? "ring-emerald-400/40 bg-emerald-500/10"
: isLinked
? status === "granted"
? "ring-emerald-400/40 bg-emerald-500/10"
: status === "prompt"
? "ring-amber-400/40 bg-amber-500/10"
: "ring-rose-400/40 bg-rose-500/10"
: "card ring-[var(--border)]";
const statusText = isCached
? "Cached copy available"
: isLinked
? status === "granted"
? "Linked and ready"
: status === "prompt"
? "Linked, permission required"
: status === "denied"
? "Linked, permission denied"
: "Link error"
: "Not linked";
const dotColor = isCached
? "bg-emerald-400"
: isLinked
? status === "granted"
? "bg-emerald-400"
: status === "prompt"
? "bg-amber-400"
: "bg-rose-400"
: "bg-white/30";
return (
<div className={`rounded-[12px] text-foreground p-4 ring-1 flex flex-col ${ringAndBg}`}>
<div className="flex flex-1 items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-[10px]">
<span className="rounded-full bg-[var(--surface-2)] px-2 py-0.5 ring-1 ring-[var(--border)]">{platform}</span>
<span className="rounded-full bg-[var(--surface-2)] px-2 py-0.5 ring-1 ring-[var(--border)]">{region}</span>
</div>
<div className="mt-2 text-[15px] font-semibold tracking-tight">{name}</div>
<div className="mt-1 text-xs text-foreground/60">{statusText}</div>
</div>
<span className={`h-2 w-2 rounded-full ${dotColor}`} title={isLinked ? status : "Not linked"} />
</div>
{(isCached || isLinked) && (
<div className="mt-4 flex min-h-[44px] items-center gap-2">
{isCached ? (
<button
onClick={onRemoveCache}
className="inline-flex h-9 items-center justify-center rounded-md border border-red-400/30 bg-red-400/20 px-3 text-sm font-medium text-foreground transition-colors hover:bg-red-400/30"
>
Remove cache
</button>
) : isLinked ? (
<button
onClick={onUnlink}
className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-sm font-medium text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10"
>
Unlink
</button>
) : null}
{!isCached && isLinked && status !== "granted" && (
<button
onClick={onEnsurePermission}
className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-sm font-medium text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10"
>
Re-authorize
</button>
)}
{!isCached && isLinked && status === "granted" && (
<button
onClick={onImportCache}
className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-sm font-medium text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10"
>
Cache copy
</button>
)}
</div>
)}
</div>
);
}

55
src/components/Button.tsx Normal file
View File

@ -0,0 +1,55 @@
"use client";
import React from "react";
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
};
const base =
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:opacity-60 disabled:cursor-not-allowed";
const variants: Record<string, string> = {
primary:
"bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)]",
secondary:
"bg-white/10 text-foreground border border-white/10 hover:bg-white/15",
ghost: "bg-transparent text-foreground hover:bg-white/5",
};
const sizes: Record<string, string> = {
sm: "h-9 px-3 text-sm",
md: "h-11 px-4 text-sm",
lg: "h-12 px-5 text-base",
};
export default function Button({
className,
variant = "primary",
size = "md",
isLoading,
children,
...props
}: ButtonProps) {
return (
<button
className={`${base} ${variants[variant]} ${sizes[size]} ${className ?? ""}`}
{...props}
>
{isLoading ? (
<span className="relative">
<span className="opacity-0">{children}</span>
<span className="absolute inset-0 flex items-center justify-center">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</span>
</span>
) : (
children
)}
</button>
);
}

View File

@ -0,0 +1,90 @@
"use client";
import React from "react";
import { hacks as allHacks } from "@/data/hacks";
import HackCard from "@/components/HackCard";
export default function DiscoverBrowser() {
const [query, setQuery] = React.useState("");
const [tag, setTag] = React.useState<string | null>(null);
const [sort, setSort] = React.useState("popular");
const tags = React.useMemo(() => {
const set = new Set<string>();
allHacks.forEach((h) => h.tags.forEach((t) => set.add(t)));
return Array.from(set).sort();
}, []);
const hacks = React.useMemo(() => {
let filtered = allHacks.filter((h) => {
const q = query.toLowerCase();
const matchesQ =
!q ||
h.title.toLowerCase().includes(q) ||
h.author.toLowerCase().includes(q) ||
h.description.toLowerCase().includes(q);
const matchesTag = !tag || h.tags.includes(tag);
return matchesQ && matchesTag;
});
if (sort === "popular") filtered = filtered.sort((a, b) => b.downloads - a.downloads);
if (sort === "new") filtered = filtered; // mock
return filtered;
}, [query, tag, sort]);
return (
<div>
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by title, author, or keyword"
className="h-11 w-full rounded-md bg-[var(--surface-2)] px-3 text-sm text-foreground placeholder:text-foreground/60 ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
</div>
<select
value={sort}
onChange={(e) => setSort(e.target.value)}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
<option value="popular">Most popular</option>
<option value="new">Newest</option>
</select>
</div>
<div className="mt-6 flex flex-wrap gap-2">
<button
onClick={() => setTag(null)}
className={`rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors shadow-sm ${
tag === null
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35 shadow-[inset_0_1px_0_rgba(0,0,0,0.04)]"
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10 shadow-[inset_0_1px_0_rgba(0,0,0,0.03)]"
}`}
>
All
</button>
{tags.map((t) => (
<button
key={t}
onClick={() => setTag(t)}
className={`rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors shadow-sm ${
tag === t
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35 shadow-[inset_0_1px_0_rgba(0,0,0,0.04)]"
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10 shadow-[inset_0_1px_0_rgba(0,0,0,0.03)]"
}`}
>
{t}
</button>
))}
</div>
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{hacks.map((hack) => (
<HackCard key={hack.slug} hack={hack} />
))}
</div>
</div>
);
}

21
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,21 @@
import Link from "next/link";
export default function Footer() {
return (
<footer className="border-t border-white/10">
<div className="mx-auto flex max-w-screen-2xl items-center justify-between px-6 py-8 text-sm text-foreground/70">
<p>© {new Date().getFullYear()} Hackdex</p>
<div className="flex items-center gap-4">
<Link href="/discover" className="hover:underline">
Discover
</Link>
<Link href="/submit" className="hover:underline">
Submit
</Link>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,139 @@
"use client";
import Image from "next/image";
import React from "react";
import useEmblaCarousel from "embla-carousel-react";
export default function Gallery({ images, title }: { images: string[]; title: string }) {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [lightboxOpen, setLightboxOpen] = React.useState(false);
React.useEffect(() => {
if (!emblaApi) return;
const onSelect = () => setSelectedIndex(emblaApi.selectedScrollSnap());
emblaApi.on("select", onSelect);
onSelect();
return () => {
if (emblaApi) emblaApi.off("select", onSelect);
};
}, [emblaApi]);
return (
<div className="card p-4">
<div className="relative aspect-[16/9] w-full overflow-hidden" ref={emblaRef}>
<div className="flex h-full">
{images.map((src, idx) => (
<div key={`${src}-${idx}`} className="relative h-full flex-[0_0_100%]">
<button onClick={() => setLightboxOpen(true)} className="absolute inset-0">
<Image src={src} alt={title} fill className="object-contain" unoptimized />
</button>
</div>
))}
</div>
<div className="pointer-events-auto absolute inset-y-0 left-0 flex items-center">
<button
aria-label="Previous image"
onClick={() => emblaApi && emblaApi.scrollPrev()}
className="m-2 rounded-full bg-[color-mix(in_oklab,black_30%,transparent)] p-2 text-white ring-1 ring-white/30 hover:bg-[color-mix(in_oklab,black_50%,transparent)] dark:bg-black/30 dark:hover:bg-black/50"
>
</button>
</div>
<div className="pointer-events-auto absolute inset-y-0 right-0 flex items-center">
<button
aria-label="Next image"
onClick={() => emblaApi && emblaApi.scrollNext()}
className="m-2 rounded-full bg-[color-mix(in_oklab,black_30%,transparent)] p-2 text-white ring-1 ring-white/30 hover:bg-[color-mix(in_oklab,black_50%,transparent)] dark:bg-black/30 dark:hover:bg-black/50"
>
</button>
</div>
</div>
<div className="mt-3 flex gap-2 overflow-x-auto">
{images.map((src, i) => (
<button
key={`${src}-${i}`}
onClick={() => emblaApi && emblaApi.scrollTo(i)}
className={`relative h-16 w-28 overflow-hidden rounded ring-1 ${
i === selectedIndex ? "ring-[var(--accent)]" : "ring-[var(--border)]"
}`}
aria-label={`Show image ${i + 1}`}
>
<Image src={src} alt={`${title} screenshot ${i + 1}`} fill className="object-cover" unoptimized />
</button>
))}
</div>
{lightboxOpen && (
<Lightbox images={images} startIndex={selectedIndex} title={title} onClose={() => setLightboxOpen(false)} />
)}
</div>
);
}
function Lightbox({ images, startIndex, title, onClose }: { images: string[]; startIndex: number; title: string; onClose: () => void }) {
const [index, setIndex] = React.useState(startIndex);
const closeRef = React.useRef<HTMLButtonElement | null>(null);
const onPrev = React.useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]);
const onNext = React.useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowRight") onNext();
if (e.key === "ArrowLeft") onPrev();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, onNext, onPrev]);
React.useEffect(() => {
closeRef.current?.focus();
}, []);
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={`Screenshots for ${title}`}>
<div className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm" onClick={onClose} />
<div className="relative mx-auto flex h-full max-w-6xl items-center px-4" onClick={(e) => e.stopPropagation()}>
<div className="relative w-full">
<div
className="relative aspect-[16/9] w-full overflow-hidden rounded"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<Image src={images[index]} alt={`${title} screenshot ${index + 1}`} fill className="object-contain" unoptimized />
</div>
<div className="mt-3 flex items-center justify-center">
<div className="rounded-full bg-[var(--surface-2)] px-3 py-1 text-xs text-foreground ring-1 ring-[var(--border)]" aria-live="polite">
{index + 1} / {images.length}
</div>
</div>
<button
type="button"
ref={closeRef}
className="absolute right-3 top-3 rounded bg-[var(--surface-2)] px-3 py-1 text-sm text-foreground ring-1 ring-[var(--border)] hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
onClick={onClose}
aria-label="Close lightbox"
>
Close
</button>
</div>
<button
type="button"
aria-label="Previous image"
onClick={onPrev}
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-[var(--surface-2)] p-3 text-foreground ring-1 ring-[var(--border)] hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
</button>
<button
type="button"
aria-label="Next image"
onClick={onNext}
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-[var(--surface-2)] p-3 text-foreground ring-1 ring-[var(--border)] hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
"use client";
import React from "react";
import StickyActionBar from "@/components/Hack/StickyActionBar";
import { useBaseRoms } from "@/contexts/BaseRomContext";
export default function HackActions({ title, version, author, baseRom }: { title: string; version?: string; author: string; baseRom: string }) {
const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, linkRom, getFileBlob, supported } = useBaseRoms();
const [file, setFile] = React.useState<File | null>(null);
const [status, setStatus] = React.useState<"idle" | "ready" | "patching" | "done">("idle");
React.useEffect(() => {
if (isLinked(baseRom) && (hasPermission(baseRom) || hasCached(baseRom))) {
setStatus("ready");
}
}, [baseRom, isLinked, hasPermission, hasCached]);
function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0] ?? null;
setFile(f);
setStatus(f ? "ready" : "idle");
if (f) importUploadedBlob(f);
}
async function onPatch() {
if (!file) {
if (!isLinked(baseRom) && !hasCached(baseRom)) return;
if (!hasCached(baseRom)) {
const perm = await ensurePermission(baseRom, true);
if (perm !== "granted") return;
}
const linkedFile = await getFileBlob(baseRom);
if (!linkedFile) return;
}
setStatus("patching");
setTimeout(() => setStatus("done"), 1200);
}
return (
<StickyActionBar
title={title}
version={version}
author={author}
onPatch={onPatch}
status={status}
isLinked={isLinked(baseRom)}
ready={hasPermission(baseRom) || hasCached(baseRom)}
onClickLink={() => (isLinked(baseRom) ? ensurePermission(baseRom, true) : linkRom(baseRom))}
supported={supported}
onUploadChange={onSelectFile}
/>
);
}

View File

@ -0,0 +1,71 @@
"use client";
import React from "react";
export default function StickyActionBar({ title, version, author, onPatch, status, isLinked, ready, onClickLink, supported, onUploadChange }: {
title: string;
version?: string;
author: string;
onPatch: () => void;
status: "idle" | "ready" | "patching" | "done";
isLinked: boolean;
ready: boolean;
onClickLink: () => void;
supported: boolean;
onUploadChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
const isDisabled = status === "patching" || (mounted && !ready && !isLinked && !supported);
return (
<div className="sticky top-18 z-30">
<div className="mx-auto flex w-full lg:max-w-screen-lg items-center justify-between gap-4 rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)]">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-sm font-medium">{title}</div>
{version && (
<span className="shrink-0 rounded-full bg-[var(--surface-2)] px-2 py-0.5 text-[11px] font-medium text-foreground/85 ring-1 ring-[var(--border)]">{version}</span>
)}
</div>
<div className="truncate text-xs text-foreground/60">By {author}</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs ring-1 ${
ready
? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90"
: isLinked
? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90"
: "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"
}`}>
{ready ? "Ready" : isLinked ? "Permission needed" : "Base ROM needed"}
</span>
{!ready && !isLinked && (
<label className="inline-flex items-center gap-2 text-xs text-foreground/80">
<input type="file" onChange={onUploadChange} className="rounded-md bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-inset ring-[var(--border)]" />
<span>Upload base ROM</span>
</label>
)}
{!ready && isLinked && (
<button
type="button"
onClick={onClickLink}
disabled={!supported}
className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-60"
>
Grant permission
</button>
)}
<button
onClick={onPatch}
disabled={isDisabled}
className="shine-wrap btn-premium h-9 min-w-[7.5rem] text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-70"
>
<span>{status === "patching" ? "Patching…" : status === "done" ? "Patched" : "Patch Now"}</span>
</button>
</div>
</div>
</div>
);
}

163
src/components/HackCard.tsx Normal file
View File

@ -0,0 +1,163 @@
import Image from "next/image";
import Link from "next/link";
import { formatCompactNumber } from "@/utils/format";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import type { Hack } from "@/data/hacks";
import { useEffect, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { usePathname } from "next/navigation";
// Using shared Hack type from data
export default function HackCard({ hack, clickable = true, className = "" }: { hack: Hack; clickable?: boolean; className?: string }) {
const { isLinked, hasPermission, hasCached } = useBaseRoms();
const linked = isLinked(hack.baseRom);
const ready = hasPermission(hack.baseRom) || hasCached(hack.baseRom);
const images = (hack.covers && hack.covers.length > 0 ? hack.covers : []).filter(Boolean);
const isCarousel = images.length > 1;
const pathname = usePathname();
const showTitlePlaceholder = (pathname || "").startsWith("/submit") && images.length === 0;
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
if (!emblaApi) return;
const onSelect = () => setSelectedIndex(emblaApi.selectedScrollSnap());
emblaApi.on("select", onSelect);
onSelect();
return () => {
emblaApi.off("select", onSelect);
};
}, [emblaApi]);
const cardClass = `rounded-[12px] overflow-hidden ${
clickable ? "transition-transform duration-300 hover:-translate-y-0.5 hover:shadow-xl anim-float" : ""
} ring-1 ${ready ? "ring-emerald-400/50 bg-emerald-500/10" : "card ring-[var(--border)]"}`;
const content = (
<div className={cardClass}>
<div className="relative aspect-[3/2] w-full">
{showTitlePlaceholder ? (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
<span
className="text-[8rem] font-extrabold leading-tight text-white/20 select-none text-center uppercase tracking-tight"
style={{
textShadow: "0 2px 24px rgba(0,0,0,0.25)",
lineHeight: 0.9,
}}
>
{hack.title}
</span>
</div>
) : isCarousel ? (
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
{images.map((src, idx) => (
<div className="relative h-full flex-[0_0_100%]" key={`${src}-${idx}`}>
<Image
src={src}
alt={hack.title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className={`w-full h-full object-cover ${clickable ? "transition-transform duration-300 group-hover:scale-[1.03]" : ""}`}
unoptimized
/>
</div>
))}
</div>
</div>
) : images[0] ? (
<Image
src={images[0]}
alt={hack.title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className={`object-cover ${clickable ? "transition-transform duration-300 group-hover:scale-[1.03]" : ""}`}
unoptimized
/>
) : null}
{!showTitlePlaceholder && <div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent dark:from-black/50" />}
<div className="absolute left-3 top-3 z-10 flex gap-2">
{hack.tags.slice(0, 2).map((t) => (
<span
key={t}
className="rounded-full px-2 py-0.5 text-xs ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/90 backdrop-blur-md"
>
{t}
</span>
))}
<span
className={`rounded-full px-2 py-0.5 text-xs ring-1 backdrop-blur-md ${
ready
? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90"
: linked
? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90"
: "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"
}`}
>
{ready ? "Ready" : linked ? "Permission needed" : "Base ROM needed"}
</span>
</div>
{isCarousel && (
<div className="absolute inset-x-0 bottom-2 z-10 flex items-center justify-center gap-3">
{images.map((_, i) => (
<button
key={i}
aria-label={`Show image ${i + 1}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
emblaApi && emblaApi.scrollTo(i);
}}
className={`h-1.5 w-1.5 rounded-full ring-1 transition-all ${
i === selectedIndex
? "bg-[var(--foreground)]/80 ring-[var(--foreground)]/60"
: "bg-[var(--foreground)]/30 ring-[var(--foreground)]/30 hover:bg-[var(--foreground)]/50"
}`}
/>
))}
</div>
)}
</div>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="line-clamp-1 text-[15px] font-semibold tracking-tight">
{hack.title}
</h3>
<span className="shrink-0 rounded-full bg-[var(--surface-2)] px-2 py-0.5 text-[11px] font-medium text-foreground/85 ring-1 ring-[var(--border)]">
{hack.version}
</span>
</div>
<p className="mt-1 text-xs text-foreground/60">By {hack.author}</p>
</div>
<div className="flex items-center gap-1 text-sm text-foreground/70">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span>{formatCompactNumber(hack.downloads)}</span>
</div>
</div>
<p className="mt-2 line-clamp-2 text-sm text-foreground/70">
{(() => {
const text = (hack as any).summary ?? (hack as any).description ?? "";
return text.length > 120 ? text.slice(0, 120).trimEnd() + "…" : text;
})()}
</p>
<div className="mt-3 text-xs text-foreground/60">Base: {hack.baseRom}</div>
</div>
</div>
);
if (clickable) {
return (
<Link href={`/hack/${hack.slug}`} className={`group block ${className}`.trim()}>
{content}
</Link>
);
}
return <div className={`group block ${className}`.trim()}>{content}</div>;
}

61
src/components/Header.tsx Normal file
View File

@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import React from "react";
import { usePathname } from "next/navigation";
import { useBaseRoms } from "@/contexts/BaseRomContext";
function NavLink({ href, label, className = "" }: { href: string; label: React.ReactNode; className?: string }) {
const pathname = usePathname();
const isActive = pathname === href;
return (
<Link
href={href}
className={`rounded-md px-3 py-2 text-sm transition-colors ${
isActive ? "bg-[var(--surface-2)] text-[var(--foreground)] ring-1 ring-[var(--border)]" : "text-foreground/80 hover:bg-[var(--surface-2)]"
} ${className}`}
>
{label}
</Link>
);
}
export default function Header() {
const { countReady } = useBaseRoms();
const pathname = usePathname();
return (
<header className="sticky top-0 z-40 w-full border-b border-[var(--border)] backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-16 max-w-screen-2xl items-center justify-between px-6">
<Link href="/" className="flex items-center gap-2">
<div className="h-7 w-7 rounded-md bg-gradient-to-br from-[var(--accent)] to-[var(--accent-700)] ring-1 ring-[var(--border)]" />
<span className="text-[15px] font-semibold tracking-tight hover:opacity-90 transition-opacity">Hackdex</span>
</Link>
<nav className="hidden items-center gap-2 md:flex">
<NavLink href="/discover" label="Discover" />
<NavLink
href="/roms"
label={
<span className="inline-flex items-center gap-2">
<span>My Base ROMs</span>
<span className="inline-flex items-center rounded-full bg-emerald-600/60 px-2 py-0.5 text-xs text-white ring-1 ring-emerald-700/80 dark:bg-emerald-500/20 dark:text-emerald-300 dark:ring-emerald-400/30">
{countReady}
</span>
</span>
}
className="border border-[var(--border)] bg-[var(--surface-2)] text-foreground"
/>
<Link
href="/submit"
className={`inline-flex h-9 items-center justify-center rounded-md px-3 text-sm font-semibold transition-colors bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] ${
pathname === "/submit" ? "ring-2 ring-[var(--ring)] ring-offset-2 ring-offset-[var(--background)] brightness-110" : ""
}`}
>
Upload
</Link>
</nav>
</div>
</header>
);
}

View File

@ -0,0 +1,39 @@
import * as React from "react"
const PokeCommunityIcon: React.FC<React.SVGProps<SVGSVGElement>> = ({
color = "currentColor",
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
style={{
fillRule: "evenodd",
clipRule: "evenodd",
strokeLinejoin: "round",
strokeMiterlimit: 2,
}}
viewBox="0 0 128 128"
{...props}
>
<path
d="M0 0h128v128H0z"
style={{
fill: "none",
}}
/>
<path
d="M34.632 112H127l-15-19.875V68H89.5v9.524c0 6.61-5.366 11.976-11.976 11.976H50.476c-6.61 0-11.976-5.366-11.976-11.976V68H16v25.368C16 103.651 24.349 112 34.632 112ZM16 60h22.5v-9.524c0-6.61 5.366-11.976 11.976-11.976h27.048c6.61 0 11.976 5.366 11.976 11.976V60H112V34.632C112 24.349 103.651 16 93.368 16H34.632C24.349 16 16 24.349 16 34.632V60Z"
style={{
fill: color,
}}
/>
<path
d="M81.5 54.376a7.88 7.88 0 0 0-7.876-7.876H54.376a7.88 7.88 0 0 0-7.876 7.876v19.248a7.88 7.88 0 0 0 7.876 7.876h19.248a7.88 7.88 0 0 0 7.876-7.876V54.376Z"
style={{
fill: color,
}}
/>
</svg>
)
export default PokeCommunityIcon

View File

@ -0,0 +1,12 @@
"use client";
import { useBaseRoms } from "@/contexts/BaseRomContext";
export default function BaseRomReadyCount() {
const { countReady } = useBaseRoms();
return (
<span className="ml-2 inline-flex items-center rounded-full bg-emerald-600/60 text-white ring-emerald-700/80 px-2 py-0.5 text-base align-text-top dark:bg-emerald-500/20 dark:text-emerald-300 ring-1 dark:ring-emerald-400/30">{countReady}</span>
);
}

View File

@ -0,0 +1,138 @@
"use client";
import React from "react";
import { FaTriangleExclamation } from "react-icons/fa6";
import { baseRoms } from "@/data/baseRoms";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import BaseRomCard from "@/components/BaseRomCard";
export default function RomsInteractive() {
const { supported, linked, statuses, cached, totalCachedBytes, importUploadedBlob, importToCache, removeFromCache, unlinkRom, ensurePermission, countReady } = useBaseRoms();
const [uploadMsg, setUploadMsg] = React.useState<string | null>(null);
const [dragActive, setDragActive] = React.useState(false);
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const name = await importUploadedBlob(file);
if (name) setUploadMsg(`Recognized and cached: ${name}`);
else setUploadMsg("Unrecognized ROM. Not cached.");
e.target.value = "";
}
const linkedOrCached = baseRoms.filter(({ name }) => Boolean(cached[name]) || Boolean(linked[name]));
const notLinked = baseRoms.filter(({ name }) => !cached[name] && !linked[name]);
return (
<>
{!supported && (
<div className="mt-4 rounded-md border border-yellow-500/30 bg-yellow-500/10 p-4 text-sm text-yellow-200">
Your browser may not support local file linking for large ROMs. Try Chrome or Edge on desktop if you have issues.
</div>
)}
<div className="mt-6 grid gap-3 text-sm text-foreground/70">
<div
className={`rounded-md border-2 border-dashed p-6 sm:p-8 min-h-[140px] ${
dragActive
? "border-[var(--accent)] bg-[var(--accent)]/8 ring-2 ring-[var(--accent)]/30"
: "border-[var(--border)] bg-[var(--surface-2)]"
}`}
onDragEnter={(e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(true);
}}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(true);
}}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
}}
onDrop={async (e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const name = await importUploadedBlob(file);
if (name) setUploadMsg(`Recognized and cached: ${name}`);
else setUploadMsg("Unrecognized ROM. Not cached.");
}}
>
<div className="flex flex-col items-center justify-center gap-3 text-center sm:flex-row sm:justify-between sm:text-left">
<div className="flex items-start gap-3">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" className="text-foreground/70">
<path d="M12 16v-8m0 0l-3 3m3-3l3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M20 16.5V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
<div>
<div className="text-[14px] font-medium">Drag & drop a base ROM here</div>
<p className="mt-1 text-xs text-foreground/70">Or click to choose a file. Recognized ROMs are cached locally and never uploaded.</p>
</div>
</div>
<label className="inline-flex cursor-pointer items-center justify-center rounded-md bg-[var(--accent)] px-3 py-2 text-sm font-medium text-[var(--accent-foreground)] transition-colors hover:bg-[var(--accent-700)]">
<input type="file" onChange={onUpload} className="hidden" />
Choose file
</label>
</div>
{uploadMsg && <div className="mt-2 text-xs text-foreground/70">{uploadMsg}</div>}
</div>
<div className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-4 text-xs text-foreground/70">
<FaTriangleExclamation size={16} className="inline-block mr-1 text-foreground/30" /> Files are processed locally in your browser and never uploaded.</div>
<div>Cached size: {(totalCachedBytes / (1024 * 1024)).toFixed(1)} MB</div>
</div>
{linkedOrCached.length > 0 && (
<div className="mt-6">
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-foreground/70">Linked or cached</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{linkedOrCached.map(({ name, platform, region }) => {
const isLinked = Boolean(linked[name]);
const status = statuses[name] ?? (isLinked ? "prompt" : "denied");
const isCached = Boolean(cached[name]);
return (
<BaseRomCard
key={name}
name={name}
platform={platform}
region={region}
isLinked={isLinked}
status={status}
isCached={isCached}
onRemoveCache={() => removeFromCache(name)}
onUnlink={() => unlinkRom(name)}
onEnsurePermission={() => ensurePermission(name, true)}
onImportCache={() => importToCache(name)}
/>
);
})}
</div>
</div>
)}
<div className="mt-8">
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-foreground/70">Not linked</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{notLinked.map(({ name, platform, region }) => (
<BaseRomCard
key={name}
name={name}
platform={platform}
region={region}
isLinked={false}
status={"denied"}
isCached={false}
/>
))}
</div>
</div>
</>
);
}

View File

@ -0,0 +1,422 @@
"use client";
import React from "react";
import Image from "next/image";
import { baseRoms } from "@/data/baseRoms";
import HackCard from "@/components/HackCard";
import type { Hack } from "@/data/hacks";
import { hacks as allHacks } from "@/data/hacks";
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
function SortableCoverItem({ id, index, url, onRemove }: { id: string; index: number; url: string; onRemove: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} className="rounded-md">
<div className={`h-16 flex items-center justify-between gap-3 p-2 bg-[var(--surface-2)] ring-1 ring-inset ring-[var(--border)] ${isDragging ? "opacity-60" : ""}`}>
<div className="flex items-center gap-3">
<div className="cursor-grab select-none pr-1 text-foreground/60" title="Drag to reorder" {...attributes} {...listeners}></div>
<div className="relative h-12 w-20 overflow-hidden rounded">
<Image src={url} alt={`Cover ${index + 1}`} fill className="object-cover" unoptimized />
</div>
<div className="min-w-0">
<div className="truncate text-xs text-foreground/80">{url}</div>
{index === 0 && <div className="text-[10px] text-emerald-400/90">Primary</div>}
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={onRemove}
className="inline-flex h-8 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 text-xs text-red-600 transition-colors hover:bg-black/5 dark:text-red-300 dark:hover:bg-white/10"
>
Remove
</button>
</div>
</div>
</div>
);
}
export default function SubmitForm() {
const MAX_COVERS = 10;
const [title, setTitle] = React.useState("");
const [author, setAuthor] = React.useState("");
const [summary, setSummary] = React.useState("");
const [description, setDescription] = React.useState("");
const [coverUrls, setCoverUrls] = React.useState<string[]>([]);
const [newCoversInput, setNewCoversInput] = React.useState("");
const [baseRom, setBaseRom] = React.useState("Pokemon Emerald");
const [version, setVersion] = React.useState("v0.1.0");
const [boxArt, setBoxArt] = React.useState("");
const [discord, setDiscord] = React.useState("");
const [twitter, setTwitter] = React.useState("");
const [pokecommunity, setPokecommunity] = React.useState("");
const [tags, setTags] = React.useState<string[]>([]);
const [tagsInput, setTagsInput] = React.useState("");
const [showMdPreview, setShowMdPreview] = React.useState(false);
const parseUrls = (text: string) =>
text
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
const addFromInput = () => {
const urls = parseUrls(newCoversInput);
if (urls.length === 0) return;
setCoverUrls((prev) => [...prev, ...urls]);
setNewCoversInput("");
};
const overLimit = coverUrls.length > MAX_COVERS;
const overBy = Math.max(0, coverUrls.length - MAX_COVERS);
const removeAt = (index: number) => {
setCoverUrls((prev) => prev.filter((_, i) => i !== index));
};
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
const onDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = coverUrls.map((url, i) => `${url}-${i}`);
const oldIndex = ids.indexOf(active.id as string);
const newIndex = ids.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
setCoverUrls((prev) => arrayMove(prev, oldIndex, newIndex));
};
const existingTags = React.useMemo(() => {
const set = new Set<string>();
allHacks.forEach((h) => h.tags.forEach((t) => set.add(t)));
return Array.from(set).sort();
}, []);
const suggestedTags = React.useMemo(() => {
const q = tagsInput.trim().toLowerCase();
if (!q) return [] as string[];
return existingTags.filter((t) => t.toLowerCase().startsWith(q) && !tags.includes(t)).slice(0, 6);
}, [existingTags, tags, tagsInput]);
const addTag = (value: string) => {
const tag = value.trim();
if (!tag) return;
if (tags.includes(tag)) return;
setTags((prev) => [...prev, tag]);
setTagsInput("");
};
const removeTagAt = (index: number) => {
setTags((prev) => prev.filter((_, i) => i !== index));
};
const onTagsKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(tagsInput);
} else if (e.key === "Backspace" && !tagsInput && tags.length > 0) {
// Quick backspace to remove last
e.preventDefault();
setTags((prev) => prev.slice(0, prev.length - 1));
}
};
const slugify = (text: string) =>
text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const slug = slugify(title || "");
const summaryLimit = 120;
const summaryTooLong = summary.length > summaryLimit;
const urlLike = (s: string) => !s || /^https?:\/\//i.test(s);
const isValid =
!!title.trim() &&
!!author.trim() &&
!!baseRom.trim() &&
!!version.trim() &&
coverUrls.length > 0 &&
!!summary.trim() &&
!summaryTooLong &&
urlLike(boxArt) &&
urlLike(discord) &&
urlLike(twitter) &&
urlLike(pokecommunity) &&
!overLimit;
const preview: Hack = {
slug: slug || "preview",
title: title || "Your hack title",
author: author || "Your name",
summary: (summary || "Short description, max 100 characters.") as string,
description: (description || "Write a longer markdown description here.") as string,
covers: coverUrls,
baseRom: baseRom || "Pokemon Emerald",
downloads: 0,
version: version || "v0.1.0",
tags,
...(boxArt ? { boxArt } : {}),
socialLinks:
discord || twitter || pokecommunity
? { discord: discord || undefined, twitter: twitter || undefined, pokecommunity: pokecommunity || undefined }
: undefined,
createdAt: new Date().toISOString(),
};
return (
<div className="grid gap-8 lg:grid-cols-[1fr_.9fr]">
<div>
<form className="grid gap-5">
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Title</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
<div className="mt-1 text-xs text-foreground/60">URL preview: <span className="text-foreground/80">/hack/{slug || "your-title"}</span></div>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Author</label>
<input
value={author}
onChange={(e) => setAuthor(e.target.value)}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
</div>
<div className="grid gap-1">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Short summary</label>
<span className={`text-[11px] ${summaryTooLong ? "text-red-300" : "text-foreground/60"}`}>{summary.length}/{summaryLimit}</span>
</div>
<input
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="<= 100 characters"
className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${summaryTooLong ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : "bg-[var(--surface-2)] ring-[var(--border)]"}`}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Long description</label>
<div className="flex items-center gap-1 text-xs">
<button type="button" onClick={() => setShowMdPreview(false)} className={`px-2 py-1 rounded ${!showMdPreview ? "bg-[var(--surface-2)] ring-1 ring-[var(--border)]" : "text-foreground/70"}`}>Write</button>
<button type="button" onClick={() => setShowMdPreview(true)} className={`px-2 py-1 rounded ${showMdPreview ? "bg-[var(--surface-2)] ring-1 ring-[var(--border)]" : "text-foreground/70"}`}>Preview</button>
</div>
</div>
{!showMdPreview ? (
<textarea
rows={6}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Supports Markdown"
className="rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
) : (
<div className="prose max-w-none rounded-md bg-[var(--surface-2)] px-3 py-2 ring-1 ring-inset ring-[var(--border)]">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{description || "Nothing to preview yet."}</ReactMarkdown>
</div>
)}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Version</label>
<input
value={version}
onChange={(e) => setVersion(e.target.value)}
placeholder="e.g. v1.2.0"
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Tags</label>
<div className="rounded-md ring-1 ring-inset ring-[var(--border)] bg-[var(--surface-2)] px-2 py-2">
<div className="flex flex-wrap gap-2">
{tags.map((t, i) => (
<span key={`${t}-${i}`} className="inline-flex items-center gap-1 rounded-full bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-[var(--border)]">
{t}
<button type="button" onClick={() => removeTagAt(i)} className="ml-1 text-foreground/70 hover:text-foreground">×</button>
</span>
))}
<input
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
onKeyDown={onTagsKeyDown}
placeholder={tags.length ? "Add tag" : "Add tags (e.g. QoL, Challenge)"}
className="flex-1 min-w-[8rem] bg-transparent px-2 text-sm placeholder:text-foreground/50 focus:outline-none"
/>
</div>
{suggestedTags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{suggestedTags.map((s) => (
<button
key={s}
type="button"
onClick={() => addTag(s)}
className="rounded-full bg-[var(--surface-2)] px-2 py-1 text-[11px] text-foreground/85 ring-1 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/5"
>
{s}
</button>
))}
</div>
)}
</div>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Cover images</label>
<div className="space-y-3">
<textarea
rows={2}
value={newCoversInput}
onChange={(e) => setNewCoversInput(e.target.value)}
placeholder="Paste one or multiple URLs (comma or newline separated)"
className="w-full rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={addFromInput}
disabled={coverUrls.length >= MAX_COVERS}
className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-xs font-medium text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
</button>
<button
type="button"
onClick={() => setNewCoversInput("")}
className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-xs font-medium text-foreground/80 transition-colors hover:bg-black/5 dark:hover:bg-white/10"
>
Clear
</button>
</div>
<div className="text-xs text-foreground/60 flex justify-between">
<p>Images: <span className={overLimit ? "text-red-300 font-bold" : "text-foreground/60"}>{coverUrls.length}</span>/{MAX_COVERS}</p>
{overLimit && <p className="text-red-300/80 italic">Remove some to submit.</p>}
</div>
<div className="grid gap-2">
{coverUrls.length === 0 ? (
<p className="text-xs text-foreground/60">No images added yet. Add at least one to preview.</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext
items={coverUrls.map((url, i) => `${url}-${i}`)}
strategy={verticalListSortingStrategy}
>
{coverUrls.map((url, i) => (
<SortableCoverItem
key={`${url}-${i}`}
id={`${url}-${i}`}
index={i}
url={url}
onRemove={() => removeAt(i)}
/>
))}
</SortableContext>
</DndContext>
)}
</div>
</div>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Base ROM</label>
<select
value={baseRom}
onChange={(e) => setBaseRom(e.target.value)}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
{baseRoms.map(({ name, region }) => (
<option key={name} value={name}>
{name} ({region})
</option>
))}
</select>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Box art URL <span className="text-foreground/60">(optional)</span></label>
<input
value={boxArt}
onChange={(e) => setBoxArt(e.target.value)}
placeholder="https://..."
className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${boxArt && !urlLike(boxArt) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : "bg-[var(--surface-2)] ring-[var(--border)]"}`}
/>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Social links <span className="text-foreground/60">(optional)</span></label>
<div className="grid gap-2 sm:grid-cols-3">
<input
value={discord}
onChange={(e) => setDiscord(e.target.value)}
placeholder="Discord invite URL"
className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${discord && !urlLike(discord) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : "bg-[var(--surface-2)] ring-[var(--border)]"}`}
/>
<input
value={twitter}
onChange={(e) => setTwitter(e.target.value)}
placeholder="Twitter/X profile URL"
className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${twitter && !urlLike(twitter) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : "bg-[var(--surface-2)] ring-[var(--border)]"}`}
/>
<input
value={pokecommunity}
onChange={(e) => setPokecommunity(e.target.value)}
placeholder="PokeCommunity thread URL"
className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${pokecommunity && !urlLike(pokecommunity) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : "bg-[var(--surface-2)] ring-[var(--border)]"}`}
/>
</div>
<p className="text-xs text-foreground/60">Use full URLs starting with http:// or https://</p>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Upload patch file</label>
<input type="file" className="rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm italic text-foreground/50 ring-1 ring-inset ring-[var(--border)] file:bg-black/10 dark:file:bg-[var(--surface-2)] file:text-foreground/80 file:text-sm file:font-medium file:not-italic file:rounded-md file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer" />
<p className="text-xs text-foreground/60">BPS only for verification purposes.</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
disabled={!isValid}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
>
<span>Submit</span>
</button>
{!isValid && (
<span className="text-xs text-red-600/90">Fill required fields, fix errors, and add at least one cover.</span>
)}
</div>
</form>
</div>
<aside className="flex flex-col gap-5 lg:sticky lg:top-20 self-start">
<PreviewCard hack={preview} />
<div className="card h-max p-5">
<div className="text-[15px] font-semibold tracking-tight">Submission tips</div>
<ul className="mt-3 list-disc space-y-2 pl-5 text-sm text-foreground/75">
<li>Use a reliable image URL (e.g. `imgur`).</li>
<li>Include the exact expected base ROM name.</li>
<li>Describe notable features, difficulty, and target players.</li>
</ul>
</div>
</aside>
</div>
);
}
function PreviewCard({ hack }: { hack: Hack }) {
return <HackCard hack={hack} clickable={false} />;
}

View File

@ -0,0 +1,320 @@
"use client";
import React from "react";
import { getAllRomEntries, setRomHandle, deleteRomHandle, getRomBlob, setRomBlob, deleteRomBlob, getAllBlobEntries } from "@/utils/idb";
import { sha1Hex } from "@/utils/hash";
import { baseRoms } from "@/data/baseRoms";
type ContextValue = {
supported: boolean;
linked: Record<string, any>;
statuses: Record<string, "granted" | "prompt" | "denied" | "error">;
cached: Record<string, boolean>;
countLinked: number;
countGranted: number;
countReady: number;
totalCachedBytes: number;
isLinked: (name: string) => boolean;
hasPermission: (name: string) => boolean;
hasCached: (name: string) => boolean;
getHandle: (name: string) => any | null;
linkRom: (name: string) => Promise<void>;
unlinkRom: (name: string) => Promise<void>;
ensurePermission: (name: string, request?: boolean) => Promise<"granted" | "prompt" | "denied" | "error">;
getFileBlob: (name: string) => Promise<File | null>;
importToCache: (name: string) => Promise<void>;
removeFromCache: (name: string) => Promise<void>;
importUploadedBlob: (file: File) => Promise<string | null>; // returns matched name
};
const BaseRomContext = React.createContext<ContextValue | null>(null);
export function BaseRomProvider({ children }: { children: React.ReactNode }) {
const supported = typeof window !== "undefined" && "showOpenFilePicker" in window;
const [linked, setLinked] = React.useState<Record<string, any>>({});
const [statuses, setStatuses] = React.useState<Record<string, "granted" | "prompt" | "denied" | "error">>({});
const [cached, setCached] = React.useState<Record<string, boolean>>({});
const [totalCachedBytes, setTotalCachedBytes] = React.useState(0);
React.useEffect(() => {
(async () => {
try {
const rows = await getAllRomEntries();
const map: Record<string, any> = {};
const st: Record<string, "granted" | "prompt" | "denied" | "error"> = {};
const cacheState: Record<string, boolean> = {};
for (const r of rows) {
map[r.name] = r.handle;
try {
const perm = r.handle?.queryPermission?.({ mode: "read" });
let state: any = "prompt";
if (perm && typeof perm.then === "function") {
const result = await perm;
state = result;
}
st[r.name] = state === "granted" ? "granted" : state === "denied" ? "denied" : "prompt";
} catch {
st[r.name] = "error";
}
}
// Check existing blobs for all known bases (and linked ones)
const blobRows = await getAllBlobEntries();
let total = 0;
for (const row of blobRows) {
cacheState[row.name] = true;
total += row.blob?.size ?? 0;
}
setLinked(map);
setStatuses(st);
setCached(cacheState);
setTotalCachedBytes(total);
} catch (e) {
// noop
}
})();
}, []);
function isLinked(name: string) {
return Boolean(linked[name]);
}
function getHandle(name: string) {
return linked[name] ?? null;
}
function hasPermission(name: string) {
return statuses[name] === "granted";
}
function hasCached(name: string) {
return Boolean(cached[name]);
}
async function linkRom(name: string) {
if (!supported) {
// Fallback: use an <input type="file"> to allow selecting a ROM and cache it if recognized
try {
const input = document.createElement("input");
input.type = "file";
input.accept = ".gba,.gbc,.gb,.nds,application/octet-stream";
input.multiple = false;
input.style.position = "fixed";
input.style.left = "-9999px";
input.onchange = async () => {
const file = input.files?.[0];
if (file) {
try {
const hash = await sha1Hex(file);
const match = baseRoms.find((r) => r.sha1 && r.sha1.toLowerCase() === hash.toLowerCase());
if (match) {
await setRomBlob(match.name, file);
setCached((prev) => ({ ...prev, [match.name]: true }));
setTotalCachedBytes((n) => n + file.size);
}
} catch {}
}
if (input.parentNode) input.parentNode.removeChild(input);
};
document.body.appendChild(input);
input.click();
} catch {}
return;
}
try {
// @ts-ignore - File System Access types
const [handle] = await (window as any).showOpenFilePicker({
multiple: false,
types: [
{
description: "ROM files",
accept: {
"application/octet-stream": [".gba", ".gbc", ".gb", ".nds"],
},
},
],
});
if (!handle) return;
// Ensure read permission
if (handle.queryPermission) {
const q = await handle.queryPermission({ mode: "read" });
if (q !== "granted" && handle.requestPermission) {
await handle.requestPermission({ mode: "read" });
}
}
await setRomHandle(name, handle);
setLinked((prev) => ({ ...prev, [name]: handle }));
try {
const q = await handle.queryPermission?.({ mode: "read" });
setStatuses((prev) => ({ ...prev, [name]: q === "granted" ? "granted" : q === "denied" ? "denied" : "prompt" }));
} catch {
setStatuses((prev) => ({ ...prev, [name]: "error" }));
}
} catch (e) {
// canceled or failed
}
}
async function unlinkRom(name: string) {
try {
await deleteRomHandle(name);
setLinked((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
setStatuses((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
// Note: keep cached copy unless explicitly removed
} catch (e) {
// noop
}
}
async function ensurePermission(name: string, request = false) {
const handle = linked[name];
if (!handle) return "error";
try {
let state = await handle.queryPermission?.({ mode: "read" });
if (state !== "granted" && request && handle.requestPermission) {
state = await handle.requestPermission({ mode: "read" });
}
const mapped = state === "granted" ? "granted" : state === "denied" ? "denied" : "prompt";
setStatuses((prev) => ({ ...prev, [name]: mapped }));
return mapped;
} catch {
setStatuses((prev) => ({ ...prev, [name]: "error" }));
return "error";
}
}
async function getFileBlob(name: string): Promise<File | null> {
// Prefer cached
const cachedBlob = await getRomBlob(name);
if (cachedBlob) return new File([cachedBlob], name);
const handle = linked[name];
if (!handle) return null;
try {
const file = await handle.getFile();
return file as File;
} catch {
// maybe moved/permission revoked
return null;
}
}
async function importToCache(name: string) {
const handle = linked[name];
if (!handle) return;
try {
const file = await handle.getFile();
await setRomBlob(name, file);
setCached((prev) => ({ ...prev, [name]: true }));
setTotalCachedBytes((n) => n + file.size);
} catch {
// noop
}
}
async function removeFromCache(name: string) {
try {
await deleteRomBlob(name);
setCached((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
// We cannot easily know the blob size now; recalc total
try {
const rows = await getAllBlobEntries();
let total = 0;
for (const r of rows) total += r.blob?.size ?? 0;
setTotalCachedBytes(total);
} catch {}
} catch {
// noop
}
}
// Accept a user-uploaded base ROM, hash it, and if it matches a known base ROM, cache it under that name
async function importUploadedBlob(file: File): Promise<string | null> {
try {
const hash = await sha1Hex(file);
const match = baseRoms.find((r) => r.sha1 && r.sha1.toLowerCase() === hash.toLowerCase());
if (!match) return null;
await setRomBlob(match.name, file);
setCached((prev) => ({ ...prev, [match.name]: true }));
setTotalCachedBytes((n) => n + file.size);
return match.name;
} catch {
return null;
}
}
// Guardrailed auto-cache: cache on link if within quota and not huge
React.useEffect(() => {
const names = Object.keys(linked);
(async () => {
try {
const estimate = await (navigator.storage?.estimate?.() ?? Promise.resolve(undefined));
const quota = estimate?.quota ?? Infinity;
const usage = estimate?.usage ?? totalCachedBytes;
const headroom = quota - usage;
for (const name of names) {
if (cached[name]) continue;
const handle = linked[name];
if (!handle) continue;
try {
const file = await handle.getFile();
const size = file.size;
const smallEnough = size <= 128 * 1024 * 1024; // 128MB default
const hasRoom = headroom > size * 1.2 && headroom > 64 * 1024 * 1024; // some buffer
if (smallEnough && hasRoom) {
await setRomBlob(name, file);
setCached((prev) => ({ ...prev, [name]: true }));
setTotalCachedBytes((n) => n + size);
}
} catch {}
}
} catch {}
})();
}, [linked, cached, totalCachedBytes]);
const readyNames = new Set<string>();
Object.entries(cached).forEach(([n, v]) => v && readyNames.add(n));
Object.entries(statuses).forEach(([n, s]) => s === "granted" && readyNames.add(n));
const value: ContextValue = {
supported,
linked,
statuses,
cached,
countLinked: Object.keys(linked).length,
countGranted: Object.values(statuses).filter((s) => s === "granted").length,
countReady: readyNames.size,
totalCachedBytes,
isLinked,
hasPermission,
hasCached,
getHandle,
linkRom,
unlinkRom,
ensurePermission,
getFileBlob,
importToCache,
removeFromCache,
importUploadedBlob,
};
return <BaseRomContext.Provider value={value}>{children}</BaseRomContext.Provider>;
}
export function useBaseRoms() {
const ctx = React.useContext(BaseRomContext);
if (!ctx) throw new Error("useBaseRoms must be used within BaseRomProvider");
return ctx;
}

27
src/data/baseRoms.ts Normal file
View File

@ -0,0 +1,27 @@
export type BaseRom = {
name: string;
platform: "GB" | "GBC" | "GBA" | "NDS";
region: string;
sha1?: string;
};
export const baseRoms: BaseRom[] = [
{ name: "Pokemon FireRed", platform: "GBA", region: "USA, Europe", sha1: "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc" },
{ name: "Pokemon FireRed (Rev 1)", platform: "GBA", region: "USA, Europe", sha1: "dd5945db9b930750cb39d00c84da8571feebf417" },
{ name: "Pokemon LeafGreen", platform: "GBA", region: "USA, Europe" },
{ name: "Pokemon Ruby", platform: "GBA", region: "USA, Europe" },
{ name: "Pokemon Sapphire", platform: "GBA", region: "USA, Europe" },
{ name: "Pokemon Emerald", platform: "GBA", region: "USA, Europe", sha1: "f3ae088181bf583e55daf962a92bb46f4f1d07b7" },
{ name: "Pokemon Crystal", platform: "GBC", region: "USA, Europe" },
{ name: "Pokemon Diamond", platform: "NDS", region: "USA" },
{ name: "Pokemon Pearl", platform: "NDS", region: "USA" },
{ name: "Pokemon Platinum", platform: "NDS", region: "USA", sha1: "0862EC35B24DE5C7E2DCB88C9EEA0873110D755C" },
{ name: "Pokemon HeartGold", platform: "NDS", region: "USA", sha1: "4FCDED0E2713DC03929845DE631D0932EA2B5A37" },
{ name: "Pokemon SoulSilver", platform: "NDS", region: "USA" },
{ name: "Pokemon Black Version", platform: "NDS", region: "USA" },
{ name: "Pokemon White Version", platform: "NDS", region: "USA" },
{ name: "Pokemon Black Version 2", platform: "NDS", region: "USA" },
{ name: "Pokemon White Version 2", platform: "NDS", region: "USA" },
];

265
src/data/hacks.ts Normal file
View File

@ -0,0 +1,265 @@
export type SocialLinks = {
discord?: string;
twitter?: string;
pokecommunity?: string;
};
export type Hack = {
slug: string;
title: string;
author: string;
covers: string[];
summary: string; // <= 120 chars (enforced in UI)
description: string; // markdown
tags: string[];
downloads: number;
baseRom: string;
version: string;
createdAt: string;
updatedAt?: string;
socialLinks?: SocialLinks;
boxArt?: string;
};
export const hacks: Hack[] = [
{
slug: "emerald-redux",
title: "Emerald Redux",
author: "Oakwood",
covers: [
"https://images.unsplash.com/photo-1542751110-97427bbecf20?q=80&w=1200&auto=format&fit=crop",
],
summary:
"A modernized take on Emerald with QoL features, new encounters, and streamlined balancing for a fresh replay.",
description: `## Overview
A modernized take on Pokemon Emerald that preserves its classic feel while smoothing out rough edges.
### Highlights
- Quality-of-life menus and improved learnsets
- New encounter tables and curated trainer teams
- Streamlined difficulty for smoother pacing
### Notes
Built for replayability with minimal grinding. Great for casual, challenge, and Nuzlocke runs.`,
tags: ["QoL", "Rebalance"],
downloads: 823,
baseRom: "Pokemon Emerald",
version: "v2.1.0",
createdAt: "2025-10-01",
updatedAt: "2025-10-01",
socialLinks: {
discord: "https://discord.gg/emeraldredux",
twitter: "https://x.com/emeraldredux",
pokecommunity: "https://www.pokecommunity.com/showthread.php?t=520000",
},
},
{
slug: "crystal-clear-plus",
title: "Crystal Clear+",
author: "Lumi",
covers: [
"https://images.unsplash.com/photo-1511512578047-dfb367046420?q=80&w=1200&auto=format&fit=crop",
],
summary:
"Open world Crystal with level scaling, multi-starter support, and an expanded post-game.",
description: `## Overview
Pokemon Crystal reimagined as an open world adventure.
### Highlights
- Level scaling across Johto and Kanto
- Choose from multiple starters at the outset
- Expanded, replayable post-game content
### Tips
Explore gyms in any order. The world adapts to your team and progress.`,
tags: ["Open World", "Scaling"],
downloads: 692,
baseRom: "Pokemon Crystal",
version: "v1.4.3",
createdAt: "2025-10-01",
updatedAt: "2025-10-01",
},
{
slug: "firered-gauntlet",
title: "FireRed Gauntlet",
author: "Mira",
covers: [
"https://images.unsplash.com/photo-1505740420928-5e560c06d30e?q=80&w=1200&auto=format&fit=crop",
],
summary:
"A tough but fair challenge hack with smarter AI, improved movepools, and curated boss teams.",
description: `## Overview
FireRed with a refined challenge curve that rewards planning over grinding.
### Highlights
- Smarter AI with better switching and coverage
- Improved movepools to increase viable strategies
- Hand-tuned boss teams with clear counterplay
### Difficulty
Challenging but fair. Expect to adjust teams and items between bosses.`,
tags: ["Hard Mode", "AI"],
downloads: 1102,
baseRom: "Pokemon FireRed",
version: "v3.0.0",
createdAt: "2025-10-01",
updatedAt: "2025-10-01",
},
{
slug: "platinum-harmonia",
title: "Platinum Harmonia",
author: "Nova",
covers: [
"https://images.unsplash.com/photo-1472457897821-70d3819a0e24?q=80&w=1200&auto=format&fit=crop",
],
summary:
"Lore-friendly enhancements, improved routes, and tasteful difficulty tuning for Platinum enjoyers.",
description: `## Overview
A lore-friendly enhancement for Pokemon Platinum that polishes routes and pacing.
### Highlights
- Route and encounter refreshes that fit Sinnoh's tone
- Tasteful difficulty tuning that respects the original
- Small mechanical tweaks for smoother play
### Who it's for
Players who want a definitive Platinum experience without losing the heart of the original.`,
tags: ["Lore", "Rebalance"],
downloads: 512,
baseRom: "Pokemon Platinum",
version: "v0.9.2",
createdAt: "2025-10-01",
},
{
slug: "emerald-rogue-lite",
title: "Emerald Rogue Lite",
author: "Rook",
covers: [
"https://images.unsplash.com/photo-1542751110-97427bbecf20?q=80&w=1200&auto=format&fit=crop",
"https://images.unsplash.com/photo-1511512578047-dfb367046420?q=80&w=1200&auto=format&fit=crop",
"https://images.unsplash.com/photo-1511739001486-6bfe10ce785f?q=80&w=1200&auto=format&fit=crop",
"https://images.unsplash.com/photo-1472457897821-70d3819a0e24?q=80&w=1200&auto=format&fit=crop",
"https://images.unsplash.com/photo-1512428559087-560fa5ceab42?q=80&w=1200&auto=format&fit=crop",
"https://images.unsplash.com/photo-1508057198894-247b23fe5ade?q=80&w=1200&auto=format&fit=crop",
"https://images.unsplash.com/photo-1498050108023-c5249f4df085?q=80&w=1200&auto=format&fit=crop",
"https://images.unsplash.com/photo-1461749280684-dccba630e2f6?q=80&w=1200&auto=format&fit=crop",
],
boxArt: 'https://images.launchbox-app.com/5ea7e17d-6e1e-47b5-84ba-c827324d851e.png',
summary:
"Short-session roguelite runs with randomized paths, meta-progression, and quick builds.",
description: `## Overview
Pick-up-and-play roguelite runs in the Emerald engine with quick builds and high replayability.
### Highlights
- Randomized routes and events each run
- Meta-progression to unlock perks and options
- Fast team-building with meaningful choices
### Session length
Runs are designed for short play sessions (2045 minutes).`,
tags: ["Roguelite", "Randomizer"],
downloads: 1341,
baseRom: "Pokemon Emerald",
version: "v0.8.0",
createdAt: "2025-10-01",
socialLinks: {
discord: "https://discord.gg/emeraldrogue",
twitter: "https://twitter.com/emeraldrogue",
pokecommunity: "https://www.pokecommunity.com/showthread.php?t=520000",
},
},
{
slug: "black-2-reforged",
title: "Black 2 Reforged",
author: "Atlas",
covers: [
"https://images.unsplash.com/photo-1512428559087-560fa5ceab42?q=80&w=1200&auto=format&fit=crop",
],
summary:
"Expanded Unova dex, smarter opponents, and quality-of-life menus for a definitive B2 experience.",
description: `## Overview
A refined take on Black 2 with an expanded Unova dex and slick QoL.
### Highlights
- Carefully selected regional and cross-gen additions
- Trainer AI and team updates that respect original balance
- QoL menus, learnset corrections, and encounter polish
### Goal
Deliver a "what-if definitive edition" feel for repeat Unova playthroughs.`,
tags: ["Expanded Dex", "QoL"],
downloads: 431,
baseRom: "Pokemon Black Version 2",
version: "v1.2.0",
createdAt: "2025-10-01",
},
{
slug: "heartgold-balance-patch",
title: "HeartGold Balance Patch",
author: "Sol",
covers: [
"https://images.unsplash.com/photo-1508057198894-247b23fe5ade?q=80&w=1200&auto=format&fit=crop",
],
summary:
"Rebalances Johto with fairer gym fights, better movepools, and more viable team options.",
description: `## Overview
An approachable rebalance that keeps Johto's charm while addressing pain points.
### Highlights
- Fairer gym difficulty curves with clearer counters
- Learnset updates to improve early- and mid-game variety
- Wider pool of viable team options without power creep
### Philosophy
Challenge through strategy, not grind.`,
tags: ["Rebalance", "Johto"],
downloads: 377,
baseRom: "Pokemon HeartGold",
version: "v1.0.5",
createdAt: "2025-10-01",
},
{
slug: "emerald-vanilla-plus",
title: "Emerald Vanilla+",
author: "Kai",
covers: [
"https://images.unsplash.com/photo-1498050108023-c5249f4df085?q=80&w=1200&auto=format&fit=crop",
],
summary:
"Keeps the original charm while smoothing rough edges and adding unobtrusive conveniences.",
description:
"Keeps the original charm while smoothing rough edges and adding unobtrusive conveniences.",
tags: ["Vanilla+", "QoL"],
downloads: 845,
baseRom: "Pokemon Emerald",
version: "v1.3.1",
createdAt: "2025-10-01",
},
{
slug: "sapphire-neo",
title: "Sapphire Neo",
author: "Iris",
covers: [
"https://images.unsplash.com/photo-1461749280684-dccba630e2f6?q=80&w=1200&auto=format&fit=crop",
],
summary:
"A faithful remake with smarter trainers, refreshed encounters, and pacing improvements.",
description: `## Overview
A faithful Sapphire remix that updates encounters, trainer logic, and pacing.
### Highlights
- Refreshed wild encounters to reduce dead zones
- Smarter trainers with modest AI improvements
- Pacing tweaks for gyms and key routes
### Result
Feels like the Sapphire you rememberjust tighter and more replayable.`,
tags: ["Remix", "AI"],
downloads: 264,
baseRom: "Pokemon Sapphire",
version: "v0.7.0",
createdAt: "2025-10-01",
},
];

18
src/types/markdown.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
declare module "react-markdown" {
import * as React from "react";
export interface ReactMarkdownProps {
children?: React.ReactNode;
remarkPlugins?: any[];
className?: string;
[key: string]: any;
}
const ReactMarkdown: React.FC<ReactMarkdownProps>;
export default ReactMarkdown;
}
declare module "remark-gfm" {
const remarkGfm: any;
export default remarkGfm;
}

6
src/utils/format.ts Normal file
View File

@ -0,0 +1,6 @@
export function formatCompactNumber(value: number): string {
if (Number.isNaN(value) || !Number.isFinite(value)) return "0";
return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value);
}

13
src/utils/hash.ts Normal file
View File

@ -0,0 +1,13 @@
export async function sha1Hex(blob: Blob): Promise<string> {
const buf = await blob.arrayBuffer();
// @ts-ignore - subtle may be undefined in non-browser
const digest = await (crypto.subtle as SubtleCrypto).digest("SHA-1", buf);
const bytes = new Uint8Array(digest);
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, "0");
}
return hex;
}

116
src/utils/idb.ts Normal file
View File

@ -0,0 +1,116 @@
// Minimal IndexedDB helpers for storing FileSystemFileHandle references
const DB_NAME = "romhaven";
const DB_VERSION = 2;
const STORE = "roms";
const BLOB_STORE = "rom_blobs";
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: "name" });
}
if (!db.objectStoreNames.contains(BLOB_STORE)) {
db.createObjectStore(BLOB_STORE, { keyPath: "name" });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function setRomHandle(name: string, handle: any): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
store.put({ name, handle, updatedAt: Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function getRomHandle(name: string): Promise<any | null> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const store = tx.objectStore(STORE);
const req = store.get(name);
req.onsuccess = () => resolve(req.result?.handle ?? null);
req.onerror = () => reject(req.error);
});
}
export async function getAllRomEntries(): Promise<Array<{ name: string; handle: any }>> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const store = tx.objectStore(STORE);
const req = store.getAll();
req.onsuccess = () => {
const rows = (req.result as any[]) || [];
resolve(rows.map((r) => ({ name: r.name, handle: r.handle })));
};
req.onerror = () => reject(req.error);
});
}
export async function deleteRomHandle(name: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
store.delete(name);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function setRomBlob(name: string, blob: Blob): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(BLOB_STORE, "readwrite");
const store = tx.objectStore(BLOB_STORE);
store.put({ name, blob, updatedAt: Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function getRomBlob(name: string): Promise<Blob | null> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(BLOB_STORE, "readonly");
const store = tx.objectStore(BLOB_STORE);
const req = store.get(name);
req.onsuccess = () => resolve(req.result?.blob ?? null);
req.onerror = () => reject(req.error);
});
}
export async function deleteRomBlob(name: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(BLOB_STORE, "readwrite");
const store = tx.objectStore(BLOB_STORE);
store.delete(name);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function getAllBlobEntries(): Promise<Array<{ name: string; blob: Blob; updatedAt?: number }>> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(BLOB_STORE, "readonly");
const store = tx.objectStore(BLOB_STORE);
const req = store.getAll();
req.onsuccess = () => resolve((req.result as any[]) || []);
req.onerror = () => reject(req.error);
});
}