mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-03-21 17:54:09 -05:00
Initial mockup commit
This commit is contained in:
parent
65cf2d5a46
commit
64ee43d409
1644
package-lock.json
generated
1644
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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
21
src/app/discover/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
121
src/app/hack/[slug]/page.tsx
Normal file
121
src/app/hack/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
141
src/app/page.tsx
141
src/app/page.tsx
|
|
@ -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'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
17
src/app/roms/page.tsx
Normal 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
16
src/app/submit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
115
src/components/BaseRomCard.tsx
Normal file
115
src/components/BaseRomCard.tsx
Normal 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
55
src/components/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
90
src/components/Discover/DiscoverBrowser.tsx
Normal file
90
src/components/Discover/DiscoverBrowser.tsx
Normal 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
21
src/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
139
src/components/Hack/Gallery.tsx
Normal file
139
src/components/Hack/Gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
55
src/components/Hack/HackActions.tsx
Normal file
55
src/components/Hack/HackActions.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
71
src/components/Hack/StickyActionBar.tsx
Normal file
71
src/components/Hack/StickyActionBar.tsx
Normal 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
163
src/components/HackCard.tsx
Normal 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
61
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
39
src/components/Icons/PokeCommunityIcon.tsx
Normal file
39
src/components/Icons/PokeCommunityIcon.tsx
Normal 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
|
||||
12
src/components/Roms/BaseRomReadyCount.tsx
Normal file
12
src/components/Roms/BaseRomReadyCount.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
138
src/components/Roms/RomsInteractive.tsx
Normal file
138
src/components/Roms/RomsInteractive.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
422
src/components/Submit/SubmitForm.tsx
Normal file
422
src/components/Submit/SubmitForm.tsx
Normal 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} />;
|
||||
}
|
||||
|
||||
|
||||
320
src/contexts/BaseRomContext.tsx
Normal file
320
src/contexts/BaseRomContext.tsx
Normal 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
27
src/data/baseRoms.ts
Normal 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
265
src/data/hacks.ts
Normal 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 (20–45 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 remember—just 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
18
src/types/markdown.d.ts
vendored
Normal 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
6
src/utils/format.ts
Normal 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
13
src/utils/hash.ts
Normal 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
116
src/utils/idb.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user