feat: add romhack archive site

This commit is contained in:
ginnoir
2026-06-08 21:16:04 -05:00
commit c868800495
518 changed files with 46140 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
---
interface Props {
label?: string;
value: string;
kind?: string;
}
const { label, value, kind = 'meta' } = Astro.props;
---
<span class={`chip chip-${kind}`}>
{label && <span>{label}</span>}
<strong>{value}</strong>
</span>
+13
View File
@@ -0,0 +1,13 @@
---
interface Props {
title: string;
body: string;
}
const { title, body } = Astro.props;
---
<section class="detail-section">
<h2>{title}</h2>
<p>{body}</p>
</section>
+22
View File
@@ -0,0 +1,22 @@
---
interface Props {
title: string;
name: string;
entries: [string, number][];
}
const { title, name, entries } = Astro.props;
---
<fieldset class="filter-panel">
<legend>{title}</legend>
<div class="filter-list">
{entries.map(([value, count]) => (
<label>
<input type="checkbox" name={name} value={value} data-filter />
<span>{value}</span>
<em>{count}</em>
</label>
))}
</div>
</fieldset>
+42
View File
@@ -0,0 +1,42 @@
---
import Chip from './Chip.astro';
import type { SiteHack } from '../lib/types';
interface Props {
hack: SiteHack;
compact?: boolean;
}
const { hack, compact = false } = Astro.props;
const accent = hack.types[0] ?? 'Unknown';
const summary = hack.summary || hack.notability || 'No summary has been added yet.';
---
<article
class:list={['hack-card', compact && 'hack-card-compact']}
data-card
data-title={hack.title}
data-platform={hack.platform ?? 'Unknown'}
data-base={hack.base ?? 'Unknown'}
data-status={hack.status ?? 'Unknown'}
data-types={hack.types.join('|')}
data-release={hack.releaseDate ?? ''}
data-search={hack.searchText}
style={`--accent: var(--type-${accent.toLowerCase().replaceAll(' ', '-')}, var(--type-unknown));`}
>
<a class="card-link" href={`/hack/${hack.slug}/`} aria-label={`Open ${hack.title}`}>
<img src={hack.bannerLocal} alt="" loading="lazy" />
<div class="card-scrim"></div>
<div class="card-copy">
<div class="card-kicker">
{hack.platform && <Chip value={hack.platform} kind="platform" />}
{hack.base && <Chip value={hack.base} kind="base" />}
</div>
<h2>{hack.title}</h2>
<p>{summary}</p>
<div class="card-types" aria-label="Types">
{hack.types.slice(0, 3).map((type) => <Chip value={type} kind="type" />)}
</div>
</div>
</a>
</article>
+40
View File
@@ -0,0 +1,40 @@
---
interface Props {
title?: string;
description?: string;
}
const {
title = 'ROM Hack Archive',
description = 'Private Pokémon ROM-hack catalog for browsing hacks, notes, files, guides, and play status.',
} = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<title>{title}</title>
<link rel="stylesheet" href="/styles/global.css" />
</head>
<body>
<div class="crt"></div>
<header class="site-header">
<a class="brand" href="/">
<span class="brand-mark" aria-hidden="true"></span>
<span>ROM Hack Archive</span>
</a>
<nav aria-label="Primary navigation">
<a href="/">Catalog</a>
<a href="/browse/type/difficulty/">Difficulty</a>
<a href="/browse/platform/gba/">GBA</a>
<a href="/queue/">Play Queue</a>
</nav>
</header>
<main>
<slot />
</main>
</body>
</html>
+329
View File
@@ -0,0 +1,329 @@
{
"a-farfetch-d-story": "/assets/banners/a-farfetch-d-story.png",
"abstract": "/assets/banners/abstract.gif",
"acceptance": "/assets/banners/acceptance.png",
"acerion-re": "/assets/banners/acerion-re.webp",
"adventure-red-chapter": "/assets/banners/adventure-red-chapter.webp",
"aether-white-2": "/assets/banners/aether-white-2.webp",
"africanvs": "/assets/banners/africanvs.png",
"all-in": "/assets/banners/all-in.png",
"altair-sirius-vega": "/assets/banners/altair-sirius-vega.gif",
"ambrosia": "/assets/banners/ambrosia.webp",
"ashgray": "/assets/banners/ashgray.webp",
"asther-violet": "/assets/banners/asther-violet.png",
"astral-red": "/assets/banners/astral-red.png",
"attack-on-the-space-station": "/assets/banners/attack-on-the-space-station.png",
"autumn-orange": "/assets/banners/autumn-orange.jpg",
"awakening": "/assets/banners/awakening.png",
"azure-platinum": "/assets/banners/azure-platinum.jpg",
"a-il": "/assets/banners/a-il.jpg",
"battle-fire": "/assets/banners/battle-fire.png",
"bidoof-version": "/assets/banners/bidoof-version.jpg",
"big-blue": "/assets/banners/big-blue.png",
"binary-sun": "/assets/banners/binary-sun.png",
"birch-s-folly": "/assets/banners/birch-s-folly.jpg",
"black-2-silly-edition": "/assets/banners/black-2-silly-edition.png",
"black-boost": "/assets/banners/black-boost.png",
"black-pearl-emerald": "/assets/banners/black-pearl-emerald.png",
"black-and-white-3-genesis": "/assets/banners/black-and-white-3-genesis.webp",
"blaze-black-2-volt-white-2": "/assets/banners/blaze-black-2-volt-white-2.png",
"blaze-black-2-redux": "/assets/banners/blaze-black-2-redux.webp",
"blazing-emerald": "/assets/banners/blazing-emerald.png",
"blinding-white-2": "/assets/banners/blinding-white-2.png",
"bloody-fusion-platinum": "/assets/banners/bloody-fusion-platinum.jpg",
"bloody-fusion": "/assets/banners/bloody-fusion.png",
"blue-stars-4": "/assets/banners/blue-stars-4.webp",
"bronze-2": "/assets/banners/bronze-2.jpg",
"bronze": "/assets/banners/bronze.jpg",
"brown": "/assets/banners/brown.png",
"cawps": "/assets/banners/cawps.png",
"chaos-black": "/assets/banners/chaos-black.jpg",
"chaos-in-vesita": "/assets/banners/chaos-in-vesita.png",
"chrome-fr": "/assets/banners/chrome-fr.webp",
"classic": "/assets/banners/classic.jpg",
"cloud-white-2": "/assets/banners/cloud-white-2.png",
"cloud-white": "/assets/banners/cloud-white.webp",
"clover": "/assets/banners/clover.png",
"colors": "/assets/banners/colors.jpg",
"colosseum-green-adventure": "/assets/banners/colosseum-green-adventure.jpg",
"compass": "/assets/banners/compass.webp",
"conquest-ultimate": "/assets/banners/conquest-ultimate.jpg",
"cope": "/assets/banners/cope.png",
"coral": "/assets/banners/coral.webp",
"cross-stadium": "/assets/banners/cross-stadium.jpg",
"crystal-16-bit": "/assets/banners/crystal-16-bit.png",
"crystal-advance-redux": "/assets/banners/crystal-advance-redux.jpg",
"crystal-advance": "/assets/banners/crystal-advance.png",
"crystal-awakening": "/assets/banners/crystal-awakening.png",
"crystal-calm": "/assets/banners/crystal-calm.png",
"crystal-cappuccino-deluxe": "/assets/banners/crystal-cappuccino-deluxe.jpg",
"crystal-clear": "/assets/banners/crystal-clear.jpg",
"crystal-legacy": "/assets/banners/crystal-legacy.png",
"cubic-crystal": "/assets/banners/cubic-crystal.png",
"dark-knight": "/assets/banners/dark-knight.webp",
"dark-rising": "/assets/banners/dark-rising.webp",
"dark-violet": "/assets/banners/dark-violet.jpg",
"dark-worship": "/assets/banners/dark-worship.png",
"darkfire": "/assets/banners/darkfire.png",
"decay": "/assets/banners/decay.gif",
"delta-emerald": "/assets/banners/delta-emerald.jpg",
"delta-fusion": "/assets/banners/delta-fusion.png",
"deserted": "/assets/banners/deserted.jpg",
"divine-sword-blessed-shield": "/assets/banners/divine-sword-blessed-shield.png",
"doubles-fire-red": "/assets/banners/doubles-fire-red.jpg",
"dreams": "/assets/banners/dreams.jpg",
"dreamstone-mysteries": "/assets/banners/dreamstone-mysteries.png",
"elaf-gold": "/assets/banners/elaf-gold.jpg",
"elite-redux": "/assets/banners/elite-redux.png",
"elysium": "/assets/banners/elysium.webp",
"emerald-20": "/assets/banners/emerald-20.png",
"emerald-imperium": "/assets/banners/emerald-imperium.jpg",
"emerald-kaizo": "/assets/banners/emerald-kaizo.png",
"emerald-legacy": "/assets/banners/emerald-legacy.webp",
"emerald-party-randomizer-plus": "/assets/banners/emerald-party-randomizer-plus.jpg",
"emerald-rogue-2-0": "/assets/banners/emerald-rogue-2-0.png",
"emerald-seaglass": "/assets/banners/emerald-seaglass.png",
"empyrean": "/assets/banners/empyrean.png",
"energized-emerald": "/assets/banners/energized-emerald.jpg",
"ephemerald": "/assets/banners/ephemerald.png",
"eternal-x-wilting-y": "/assets/banners/eternal-x-wilting-y.gif",
"evolved": "/assets/banners/evolved.jpg",
"exceeded": "/assets/banners/exceeded.jpg",
"factory-adventure": "/assets/banners/factory-adventure.jpg",
"fakemon-fire-red": "/assets/banners/fakemon-fire-red.jpg",
"fire-red-898": "/assets/banners/fire-red-898.jpg",
"fire-red-extended": "/assets/banners/fire-red-extended.webp",
"fire-red-ok-version": "/assets/banners/fire-red-ok-version.jpg",
"firered-distorted": "/assets/banners/firered-distorted.jpg",
"firered-multiverse": "/assets/banners/firered-multiverse.webp",
"firered-reignited-leafgreen-regrown": "/assets/banners/firered-reignited-leafgreen-regrown.jpg",
"firered-rocket-edition": "/assets/banners/firered-rocket-edition.png",
"flora-sky": "/assets/banners/flora-sky.png",
"fool-s-gold": "/assets/banners/fool-s-gold.png",
"frigo-returns": "/assets/banners/frigo-returns.jpg",
"fused-adimensions": "/assets/banners/fused-adimensions.jpg",
"fusion-origins": "/assets/banners/fusion-origins.jpg",
"gs-97-reforged": "/assets/banners/gs-97-reforged.webp",
"gs-chronicles": "/assets/banners/gs-chronicles.png",
"gadir-deluxe": "/assets/banners/gadir-deluxe.png",
"gaia": "/assets/banners/gaia.webp",
"giratina-strikes-back": "/assets/banners/giratina-strikes-back.png",
"glazed-randomizer": "/assets/banners/glazed-randomizer.png",
"glazed": "/assets/banners/glazed.png",
"glimmering-emerald": "/assets/banners/glimmering-emerald.jpg",
"glowing-garnet-remix": "/assets/banners/glowing-garnet-remix.jpg",
"glowing-garnet": "/assets/banners/glowing-garnet.png",
"gold-sinnoh": "/assets/banners/gold-sinnoh.jpg",
"golden-glazed": "/assets/banners/golden-glazed.jpg",
"golden-shield": "/assets/banners/golden-shield.png",
"grand-colosseum": "/assets/banners/grand-colosseum.png",
"greninja-z": "/assets/banners/greninja-z.jpg",
"heart-and-soul": "/assets/banners/heart-and-soul.png",
"heartgold-generation": "/assets/banners/heartgold-generation.png",
"heartgold-reloaded": "/assets/banners/heartgold-reloaded.jpg",
"hisui-red": "/assets/banners/hisui-red.jpg",
"hoenn-white-2": "/assets/banners/hoenn-white-2.jpg",
"hollow-woods": "/assets/banners/hollow-woods.jpg",
"horror-white": "/assets/banners/horror-white.webp",
"hydro-bliss": "/assets/banners/hydro-bliss.png",
"hyper-emerald-lost-artifact": "/assets/banners/hyper-emerald-lost-artifact.webp",
"inclement-emerald": "/assets/banners/inclement-emerald.webp",
"infernal-legend": "/assets/banners/infernal-legend.jpg",
"infinite-black-2": "/assets/banners/infinite-black-2.jpg",
"infinite-fusion": "/assets/banners/infinite-fusion.jpg",
"insurgence": "/assets/banners/insurgence.jpg",
"inverse-emerald": "/assets/banners/inverse-emerald.jpg",
"ironmon-red-kaizo": "/assets/banners/ironmon-red-kaizo.jpg",
"jet-black": "/assets/banners/jet-black.png",
"johto-legends": "/assets/banners/johto-legends.png",
"kanto-black": "/assets/banners/kanto-black.webp",
"kanto-complete": "/assets/banners/kanto-complete.jpg",
"kanto-origins": "/assets/banners/kanto-origins.jpg",
"keishou": "/assets/banners/keishou.png",
"kosmo": "/assets/banners/kosmo.png",
"lavender-tales": "/assets/banners/lavender-tales.jpg",
"lazarus": "/assets/banners/lazarus.jpg",
"lazy-green": "/assets/banners/lazy-green.png",
"leaf-green-extreme-randomizer": "/assets/banners/leaf-green-extreme-randomizer.jpg",
"legends-kingdom-hearts": "/assets/banners/legends-kingdom-hearts.jpg",
"legends-x": "/assets/banners/legends-x.webp",
"let-s-go-peerless-pikachu-exquisite-eevee": "/assets/banners/let-s-go-peerless-pikachu-exquisite-eevee.jpg",
"let-s-go-lugia": "/assets/banners/let-s-go-lugia.jpg",
"let-s-beat-mew": "/assets/banners/let-s-beat-mew.jpg",
"light-platinum": "/assets/banners/light-platinum.webp",
"liquid-crystal": "/assets/banners/liquid-crystal.jpg",
"luminescent-platinum": "/assets/banners/luminescent-platinum.webp",
"mastersilver-woofgold": "/assets/banners/mastersilver-woofgold.jpg",
"mega-blue": "/assets/banners/mega-blue.png",
"mega-power": "/assets/banners/mega-power.png",
"meissa-moon": "/assets/banners/meissa-moon.jpg",
"metal-red": "/assets/banners/metal-red.jpg",
"minced-fusions": "/assets/banners/minced-fusions.jpg",
"mineral": "/assets/banners/mineral.jpg",
"miopidgey": "/assets/banners/miopidgey.png",
"modern-emerald": "/assets/banners/modern-emerald.webp",
"moon-black-2": "/assets/banners/moon-black-2.png",
"moon-emerald-extreme-randomizer": "/assets/banners/moon-emerald-extreme-randomizer.jpg",
"moon-silver": "/assets/banners/moon-silver.png",
"mu-magna": "/assets/banners/mu-magna.webp",
"mystery-dungeon-explorers-of-fortune": "/assets/banners/mystery-dungeon-explorers-of-fortune.png",
"mystery-dungeon-explorers-of-the-spirit": "/assets/banners/mystery-dungeon-explorers-of-the-spirit.jpg",
"mystery-dungeon-explorers-of-skies": "/assets/banners/mystery-dungeon-explorers-of-skies.jpg",
"mystery-dungeon-wigglytuff-s-bizarre-adventure": "/assets/banners/mystery-dungeon-wigglytuff-s-bizarre-adventure.png",
"mystery-mail-blooming-chaos": "/assets/banners/mystery-mail-blooming-chaos.png",
"myth": "/assets/banners/myth.png",
"mythic-silver": "/assets/banners/mythic-silver.webp",
"nameless": "/assets/banners/nameless.png",
"national-history-museum": "/assets/banners/national-history-museum.jpg",
"navy-sapphire": "/assets/banners/navy-sapphire.jpg",
"nebula-1": "/assets/banners/nebula-1.png",
"new-generations": "/assets/banners/new-generations.png",
"null": "/assets/banners/null.png",
"obscura": "/assets/banners/obscura.webp",
"obsidian-emerald": "/assets/banners/obsidian-emerald.jpg",
"odyssey": "/assets/banners/odyssey.png",
"omega-paradox-v2": "/assets/banners/omega-paradox-v2.jpg",
"onyx-blue": "/assets/banners/onyx-blue.webp",
"opalo": "/assets/banners/opalo.jpg",
"orange": "/assets/banners/orange.jpg",
"order-and-chaos": "/assets/banners/order-and-chaos.png",
"pefect-emerald": "/assets/banners/pefect-emerald.jpg",
"peppermint": "/assets/banners/peppermint.png",
"photonic-sun-prismatic-moon": "/assets/banners/photonic-sun-prismatic-moon.png",
"pisces": "/assets/banners/pisces.webp",
"pitch-black-pure-white": "/assets/banners/pitch-black-pure-white.png",
"platinum-kaizo": "/assets/banners/platinum-kaizo.jpg",
"platinum-red": "/assets/banners/platinum-red.jpg",
"platinum-redux": "/assets/banners/platinum-redux.jpg",
"platinum-unlocked": "/assets/banners/platinum-unlocked.jpg",
"pocket-gaiden": "/assets/banners/pocket-gaiden.webp",
"pokerogue": "/assets/banners/pokerogue.jpg",
"polished-crystal": "/assets/banners/polished-crystal.png",
"prism": "/assets/banners/prism.png",
"pure-heart-true-soul": "/assets/banners/pure-heart-true-soul.jpg",
"quarantine-crystal": "/assets/banners/quarantine-crystal.png",
"quetzal": "/assets/banners/quetzal.jpg",
"r-o-w-e": "/assets/banners/r-o-w-e.webp",
"re-verse": "/assets/banners/re-verse.jpg",
"radical-red": "/assets/banners/radical-red.jpg",
"rainbow-gold": "/assets/banners/rainbow-gold.png",
"re-universe": "/assets/banners/re-universe.webp",
"realidea-system": "/assets/banners/realidea-system.png",
"reborn": "/assets/banners/reborn.png",
"recharged-pink": "/assets/banners/recharged-pink.png",
"recharged-yellow-previously-known-as-pok-mon-yellow-cross": "/assets/banners/recharged-yellow-previously-known-as-pok-mon-yellow-cross.webp",
"recordkeepers": "/assets/banners/recordkeepers.png",
"red-kaizo": "/assets/banners/red-kaizo.jpg",
"rejuvenation": "/assets/banners/rejuvenation.jpg",
"reminiscencia": "/assets/banners/reminiscencia.png",
"resolute": "/assets/banners/resolute.png",
"revitalus-immortalis": "/assets/banners/revitalus-immortalis.png",
"rising-ruby-sinking-sapphire": "/assets/banners/rising-ruby-sinking-sapphire.webp",
"rising-sun-waning-moon": "/assets/banners/rising-sun-waning-moon.webp",
"roaring-red": "/assets/banners/roaring-red.png",
"rose-gold": "/assets/banners/rose-gold.png",
"royal-sapphire": "/assets/banners/royal-sapphire.webp",
"ruby-renev": "/assets/banners/ruby-renev.webp",
"ruby-sword-and-shield-randomizer": "/assets/banners/ruby-sword-and-shield-randomizer.jpg",
"runic-platinum": "/assets/banners/runic-platinum.webp",
"rusty": "/assets/banners/rusty.jpg",
"rutile-ruby-star-sapphire": "/assets/banners/rutile-ruby-star-sapphire.jpg",
"sacred-gold": "/assets/banners/sacred-gold.jpg",
"saffron": "/assets/banners/saffron.png",
"salt-shadow": "/assets/banners/salt-shadow.png",
"scarlet-violet": "/assets/banners/scarlet-violet.jpg",
"scarlet-sapphire": "/assets/banners/scarlet-sapphire.png",
"scorched-silver": "/assets/banners/scorched-silver.png",
"scrambled-scarlet": "/assets/banners/scrambled-scarlet.jpg",
"serene-crystal": "/assets/banners/serene-crystal.png",
"shadow-legends": "/assets/banners/shadow-legends.png",
"shadow-of-time": "/assets/banners/shadow-of-time.webp",
"shinju-adventures": "/assets/banners/shinju-adventures.webp",
"shiny-island": "/assets/banners/shiny-island.webp",
"silver-rival": "/assets/banners/silver-rival.jpg",
"silver-yellow": "/assets/banners/silver-yellow.jpg",
"smogon-emerald": "/assets/banners/smogon-emerald.jpg",
"snakewood-improved": "/assets/banners/snakewood-improved.webp",
"solar-light-lunar-dark": "/assets/banners/solar-light-lunar-dark.webp",
"solar-platinum": "/assets/banners/solar-platinum.png",
"soothing-silver": "/assets/banners/soothing-silver.png",
"sors-2": "/assets/banners/sors-2.webp",
"sors": "/assets/banners/sors.webp",
"soulsilver-fusion": "/assets/banners/soulsilver-fusion.jpg",
"sour-crystal": "/assets/banners/sour-crystal.jpg",
"sovereign-of-the-skies": "/assets/banners/sovereign-of-the-skies.webp",
"space-world-crystal": "/assets/banners/space-world-crystal.jpg",
"spirit-of-the-storm": "/assets/banners/spirit-of-the-storm.png",
"star-beasts-asteroid-version-en": "/assets/banners/star-beasts-asteroid-version-en.png",
"star": "/assets/banners/star.webp",
"steve-the-bibarel-heart-of-darkness": "/assets/banners/steve-the-bibarel-heart-of-darkness.jpg",
"stone-dragon-3": "/assets/banners/stone-dragon-3.jpg",
"storm-silver": "/assets/banners/storm-silver.webp",
"stunning-steel": "/assets/banners/stunning-steel.png",
"sun-sky-moon-galaxy": "/assets/banners/sun-sky-moon-galaxy.jpg",
"sunset-orange": "/assets/banners/sunset-orange.jpg",
"super-dark-worship": "/assets/banners/super-dark-worship.png",
"super-mariomon": "/assets/banners/super-mariomon.webp",
"supernova-sun-penumbra-moon": "/assets/banners/supernova-sun-penumbra-moon.png",
"supreme-ruby": "/assets/banners/supreme-ruby.webp",
"swsh-ultimate": "/assets/banners/swsh-ultimate.jpg",
"sword-and-shield-ultimate-plus": "/assets/banners/sword-and-shield-ultimate-plus.png",
"tcg-neo": "/assets/banners/tcg-neo.jpg",
"team-rocket-edition-2": "/assets/banners/team-rocket-edition-2.webp",
"team-rocket-edition": "/assets/banners/team-rocket-edition.jpg",
"team-rocket-jessie-james-edition": "/assets/banners/team-rocket-jessie-james-edition.jpg",
"team-rocket-prepare-for-trouble": "/assets/banners/team-rocket-prepare-for-trouble.webp",
"techno-emerald": "/assets/banners/techno-emerald.jpg",
"terra": "/assets/banners/terra.jpg",
"the-light-in-our-hearts": "/assets/banners/the-light-in-our-hearts.png",
"the-pit": "/assets/banners/the-pit.png",
"the-red-blue-sequel": "/assets/banners/the-red-blue-sequel.jpg",
"the-teal-mask": "/assets/banners/the-teal-mask.jpg",
"the-unown-king": "/assets/banners/the-unown-king.gif",
"the-wooper-who-saved-christmas": "/assets/banners/the-wooper-who-saved-christmas.png",
"theta-emerald-the-last-dance": "/assets/banners/theta-emerald-the-last-dance.png",
"this-gym-of-mine": "/assets/banners/this-gym-of-mine.png",
"thyme": "/assets/banners/thyme.jpg",
"too-many-types-2": "/assets/banners/too-many-types-2.jpg",
"too-many-types": "/assets/banners/too-many-types.png",
"touhoumon-another-world": "/assets/banners/touhoumon-another-world.jpg",
"transform": "/assets/banners/transform.png",
"two": "/assets/banners/two.webp",
"ultimate-fusion": "/assets/banners/ultimate-fusion.jpg",
"ultra-shiny-gold-sigma": "/assets/banners/ultra-shiny-gold-sigma.webp",
"ultra-something": "/assets/banners/ultra-something.webp",
"unbound": "/assets/banners/unbound.jpg",
"unbreakable-ties": "/assets/banners/unbreakable-ties.jpg",
"unova-emerald": "/assets/banners/unova-emerald.webp",
"uranium": "/assets/banners/uranium.png",
"utopia": "/assets/banners/utopia.png",
"valiant": "/assets/banners/valiant.jpg",
"vanguard": "/assets/banners/vanguard.png",
"vanishing-point": "/assets/banners/vanishing-point.jpg",
"victory-fire": "/assets/banners/victory-fire.jpg",
"vintage-white": "/assets/banners/vintage-white.jpg",
"violette": "/assets/banners/violette.png",
"void": "/assets/banners/void.jpg",
"volt-white-2-redux": "/assets/banners/volt-white-2-redux.jpg",
"voyager": "/assets/banners/voyager.gif",
"war-of-masters": "/assets/banners/war-of-masters.webp",
"wasteland": "/assets/banners/wasteland.png",
"water-blue": "/assets/banners/water-blue.jpg",
"waveblue": "/assets/banners/waveblue.webp",
"white-2-black-2-kaizo": "/assets/banners/white-2-black-2-kaizo.jpg",
"white-gold": "/assets/banners/white-gold.jpg",
"wings-of-chaos": "/assets/banners/wings-of-chaos.jpg",
"wob-version": "/assets/banners/wob-version.jpg",
"world-stadium": "/assets/banners/world-stadium.png",
"x-y-disruption": "/assets/banners/x-y-disruption.png",
"xd-dx-director-s-cut": "/assets/banners/xd-dx-director-s-cut.png",
"xg-next-gen": "/assets/banners/xg-next-gen.png",
"xenoverse": "/assets/banners/xenoverse.jpg",
"yellow-cramorant-edition-en": "/assets/banners/yellow-cramorant-edition-en.png",
"yellow-plus": "/assets/banners/yellow-plus.png",
"yu-gi-oh-pok-duel": "/assets/banners/yu-gi-oh-pok-duel.webp",
"z": "/assets/banners/z.png",
"bushido": "/assets/banners/bushido.gif",
"clockwork": "/assets/banners/clockwork.webp",
"trading-card-game-2-the-invasion-of-team-gr": "/assets/banners/trading-card-game-2-the-invasion-of-team-gr.png"
}
File diff suppressed because it is too large Load Diff
+199
View File
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest';
import {
buildFacets,
applyBannerManifest,
applyGeneratedFileLinks,
normalizeCatalog,
parseReleaseDate,
slugifyStem,
sortHacks,
} from './catalog';
import type { RawCatalog } from './types';
const baseCatalog: RawCatalog = {
generated: '2026-06-08',
count: 3,
image_base: 'https://romhacks-files.ginnoir.com',
facets: {
platform: { GBA: 2, Unknown: 1 },
base: { FireRed: 1, Unknown: 2 },
status: { Complete: 2, Unknown: 1 },
type: { Difficulty: 1, Expansion: 1 },
play_status: { Unplayed: 3 },
},
hacks: [
{
stem: 'Radical Red',
title: 'Pokémon Radical Red',
platform: 'GBA',
base: 'FireRed',
status: 'Complete',
version: '4.1',
developer: 'Soupercell',
release_date: '2024',
banner: 'https://romhacks-files.ginnoir.com/_meta/radical/banner.jpg',
source: 'https://example.com/radical',
library_path: 'roms/gba/Hacks/Pokemon - Radical Red (Hack).gba',
play_status: 'Unplayed',
rating: 5,
type: ['Difficulty', 'Expansion'],
tags: ['hack'],
summary: 'Fast, difficult FireRed expansion.',
notability: 'Flagship difficulty hack.',
story: '',
features: ['Expanded Pokédex'],
links: [{ label: 'Source / info', url: 'https://example.com/radical' }],
},
{
stem: 'A FarfetchΓÇÖD Story',
title: 'Pokémon A FarfetchD Story',
platform: 'PC',
base: null,
status: 'Complete',
version: 'completed',
developer: 'KennyCatches',
release_date: '2023-08-10',
scrape_dir: 'Pokemon_A_Farfetch_D_Story',
library_path: '/storage1/labdata/romhacks/library/Pokemon_A_Farfetch_D_Story/',
play_status: 'Unplayed',
type: ['New Experience'],
tags: [],
summary: 'A short RPGXP adventure.',
notability: '',
story: '',
features: [],
links: [],
},
{
stem: 'Unknown Base',
title: 'Pokémon Unknown Base',
platform: null,
base: '—',
status: null,
version: '',
library_path: '—',
play_status: 'Unplayed',
type: [],
tags: [],
summary: '',
notability: '',
story: '',
features: [],
links: [],
},
],
};
describe('catalog normalization', () => {
it('creates deterministic URL-safe slugs from stems', () => {
expect(slugifyStem('Radical Red')).toBe('radical-red');
expect(slugifyStem('A FarfetchΓÇÖD Story')).toBe('a-farfetch-d-story');
expect(slugifyStem('Pokémon: Crystal Clear!')).toBe('pokemon-crystal-clear');
});
it('normalizes records without rendering null-like values', () => {
const catalog = normalizeCatalog(baseCatalog);
const unknown = catalog.hacks.find((hack) => hack.stem === 'Unknown Base');
expect(catalog.count).toBe(3);
expect(catalog.facets.platform).toEqual(baseCatalog.facets.platform);
expect(unknown?.platform).toBeUndefined();
expect(unknown?.base).toBeUndefined();
expect(unknown?.status).toBeUndefined();
expect(unknown?.chips.map((chip) => chip.label)).not.toContain('—');
expect(unknown?.searchText).not.toMatch(/\bnull\b|—/i);
});
it('maps ROM and library paths to internal file-server links', () => {
const catalog = normalizeCatalog(baseCatalog);
const radical = catalog.hacks.find((hack) => hack.slug === 'radical-red');
const farfetchd = catalog.hacks.find((hack) => hack.slug === 'a-farfetch-d-story');
expect(radical?.fileLinks).toContainEqual({
label: 'ROM file',
url: 'https://romhacks-files.ginnoir.com/_roms/gba/Hacks/Pokemon%20-%20Radical%20Red%20%28Hack%29.gba',
kind: 'rom',
});
expect(farfetchd?.fileLinks).toContainEqual({
label: 'Browse related files',
url: 'https://romhacks-files.ginnoir.com/Pokemon_A_Farfetch_D_Story/',
kind: 'library',
});
});
it('builds facets from normalized records when counts are not supplied', () => {
const facets = buildFacets(normalizeCatalog(baseCatalog).hacks);
expect(facets.platform.GBA).toBe(1);
expect(facets.platform.PC).toBe(1);
expect(facets.platform.Unknown).toBe(1);
expect(facets.type.Difficulty).toBe(1);
expect(facets.type.Expansion).toBe(1);
});
it('applies downloaded banner manifest paths and placeholder fallback', () => {
const catalog = normalizeCatalog(baseCatalog);
const withBanners = applyBannerManifest(catalog, {
'radical-red': '/assets/banners/radical-red.jpg',
});
const radical = withBanners.hacks.find((hack) => hack.slug === 'radical-red');
const unknown = withBanners.hacks.find((hack) => hack.slug === 'unknown-base');
expect(radical?.bannerLocal).toBe('/assets/banners/radical-red.jpg');
expect(unknown?.bannerLocal).toBe('/assets/placeholders/hack.svg');
});
it('merges generated guide links without dropping ROM or browse links', () => {
const catalog = normalizeCatalog(baseCatalog);
const withFiles = applyGeneratedFileLinks(catalog, {
'a-farfetch-d-story': [
{
label: 'guide.pdf',
url: 'https://romhacks-files.ginnoir.com/Pokemon_A_Farfetch_D_Story/guide.pdf',
kind: 'guide',
},
],
});
const farfetchd = withFiles.hacks.find((hack) => hack.slug === 'a-farfetch-d-story');
expect(farfetchd?.fileLinks).toEqual([
{
label: 'Browse related files',
url: 'https://romhacks-files.ginnoir.com/Pokemon_A_Farfetch_D_Story/',
kind: 'library',
},
{
label: 'guide.pdf',
url: 'https://romhacks-files.ginnoir.com/Pokemon_A_Farfetch_D_Story/guide.pdf',
kind: 'guide',
},
]);
});
it('sorts by name, release date, and platform', () => {
const hacks = normalizeCatalog(baseCatalog).hacks;
expect(sortHacks(hacks, 'name').map((hack) => hack.slug)).toEqual([
'a-farfetch-d-story',
'radical-red',
'unknown-base',
]);
expect(sortHacks(hacks, 'release_date').map((hack) => hack.slug)).toEqual([
'radical-red',
'a-farfetch-d-story',
'unknown-base',
]);
expect(sortHacks(hacks, 'platform').map((hack) => hack.slug)).toEqual([
'radical-red',
'a-farfetch-d-story',
'unknown-base',
]);
});
it('parses bare years and ISO dates for descending date sort', () => {
expect(parseReleaseDate('2024')).toBe(new Date('2024-01-01').getTime());
expect(parseReleaseDate('2023-08-10')).toBe(new Date('2023-08-10').getTime());
expect(parseReleaseDate(null)).toBe(0);
});
});
+273
View File
@@ -0,0 +1,273 @@
import type {
FacetCounts,
FacetName,
FileLink,
HackChip,
RawCatalog,
RawHack,
SiteCatalog,
SiteHack,
SortKey,
} from './types';
const FILE_BASE = 'https://romhacks-files.ginnoir.com';
const PLACEHOLDER_BANNER = '/assets/placeholders/hack.svg';
export function assertRawCatalog(value: unknown): asserts value is RawCatalog {
if (!value || typeof value !== 'object') {
throw new Error('Catalog must be an object');
}
const catalog = value as Partial<RawCatalog>;
if (!Array.isArray(catalog.hacks)) {
throw new Error('Catalog is missing hacks[]');
}
if (typeof catalog.count !== 'number' || catalog.count !== catalog.hacks.length) {
throw new Error('Catalog count must match hacks.length');
}
for (const [index, hack] of catalog.hacks.entries()) {
if (!hack || typeof hack !== 'object') {
throw new Error(`Hack at index ${index} must be an object`);
}
if (!cleanText((hack as RawHack).stem) || !cleanText((hack as RawHack).title)) {
throw new Error(`Hack at index ${index} is missing stem/title`);
}
}
}
export function normalizeCatalog(raw: RawCatalog): SiteCatalog {
assertRawCatalog(raw);
const hacks = raw.hacks.map(normalizeHack);
return {
generated: raw.generated,
count: hacks.length,
imageBase: raw.image_base,
facets: raw.facets ?? buildFacets(hacks),
hacks,
};
}
export function normalizeHack(raw: RawHack): SiteHack {
const slug = slugifyStem(raw.stem);
const types = cleanList(raw.type);
const platform = cleanText(raw.platform);
const base = cleanText(raw.base);
const version = cleanText(raw.version);
const status = cleanText(raw.status);
const developer = cleanText(raw.developer);
const releaseDate = cleanText(raw.release_date);
const generation = cleanText(raw.generation);
const scrapeDir = cleanText(raw.scrape_dir);
const libraryPath = cleanText(raw.library_path);
const banner = cleanText(raw.banner);
const links = (raw.links ?? []).filter((link) => cleanText(link.label) && cleanText(link.url));
const features = cleanList(raw.features);
const summary = cleanText(raw.summary) ?? '';
const notability = cleanText(raw.notability) ?? '';
const story = cleanText(raw.story) ?? '';
const hack: SiteHack = {
stem: raw.stem,
slug,
title: cleanText(raw.title) ?? raw.stem,
platform,
base,
version,
status,
generation,
developer,
releaseDate,
banner,
bannerLocal: PLACEHOLDER_BANNER,
homepage: cleanText(raw.homepage),
source: cleanText(raw.source),
libraryPath,
playStatus: cleanText(raw.play_status),
scrapeDir,
rating: typeof raw.rating === 'number' ? raw.rating : undefined,
types,
tags: cleanList(raw.tags),
summary,
notability,
story,
features,
links,
chips: [],
fileLinks: [],
searchText: '',
};
hack.chips = buildChips(hack);
hack.fileLinks = mapFileLinks(hack);
hack.searchText = [
hack.title,
hack.platform,
hack.base,
hack.status,
hack.version,
hack.developer,
hack.releaseDate,
hack.summary,
hack.notability,
hack.story,
...hack.types,
...hack.features,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return hack;
}
export function slugifyStem(stem: string): string {
return stem
.replaceAll('ΓÇÖ', "'")
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase();
}
export function applyBannerManifest(catalog: SiteCatalog, manifest: Record<string, string>): SiteCatalog {
return {
...catalog,
hacks: catalog.hacks.map((hack) => ({
...hack,
bannerLocal: manifest[hack.slug] ?? PLACEHOLDER_BANNER,
})),
};
}
export function applyGeneratedFileLinks(catalog: SiteCatalog, manifest: Record<string, FileLink[]>): SiteCatalog {
return {
...catalog,
hacks: catalog.hacks.map((hack) => ({
...hack,
fileLinks: [...hack.fileLinks, ...(manifest[hack.slug] ?? [])],
})),
};
}
export function buildFacets(hacks: SiteHack[]): Record<FacetName, FacetCounts> {
return {
platform: countBy(hacks, (hack) => hack.platform ?? 'Unknown'),
base: countBy(hacks, (hack) => hack.base ?? 'Unknown'),
status: countBy(hacks, (hack) => hack.status ?? 'Unknown'),
type: countBy(hacks.flatMap((hack) => hack.types), (type) => type),
play_status: countBy(hacks, (hack) => hack.playStatus ?? 'Unknown'),
};
}
export function sortHacks(hacks: SiteHack[], key: SortKey): SiteHack[] {
const sorted = [...hacks];
if (key === 'release_date') {
return sorted.sort((a, b) => parseReleaseDate(b.releaseDate) - parseReleaseDate(a.releaseDate));
}
if (key === 'platform') {
return sorted.sort((a, b) => {
const byPlatform = (a.platform ?? 'ZZZ').localeCompare(b.platform ?? 'ZZZ');
return byPlatform || a.title.localeCompare(b.title);
});
}
return sorted.sort((a, b) => a.title.localeCompare(b.title));
}
export function parseReleaseDate(value?: string | null): number {
const cleaned = cleanText(value);
if (!cleaned) return 0;
if (/^\d{4}$/.test(cleaned)) {
return new Date(`${cleaned}-01-01`).getTime();
}
const parsed = Date.parse(cleaned);
return Number.isNaN(parsed) ? 0 : parsed;
}
export function mapFileLinks(hack: Pick<SiteHack, 'libraryPath' | 'scrapeDir'>): FileLink[] {
const links: FileLink[] = [];
const path = cleanText(hack.libraryPath);
const romRel = romRelativePath(path);
if (romRel) {
links.push({
label: 'ROM file',
url: `${FILE_BASE}/_roms/${encodePath(romRel)}`,
kind: 'rom',
});
}
const librarySlug = librarySlugFromPath(path) ?? cleanText(hack.scrapeDir);
if (librarySlug) {
links.push({
label: 'Browse related files',
url: `${FILE_BASE}/${encodePath(librarySlug)}/`,
kind: 'library',
});
}
return links;
}
function buildChips(hack: SiteHack): HackChip[] {
const chips: HackChip[] = [];
addChip(chips, 'platform', 'Platform', hack.platform);
addChip(chips, 'base', 'Base', hack.base);
addChip(chips, 'version', 'Version', hack.version);
addChip(chips, 'status', 'Status', hack.status);
addChip(chips, 'developer', 'Dev', hack.developer);
addChip(chips, 'release_date', 'Release', hack.releaseDate);
for (const type of hack.types) {
addChip(chips, 'type', 'Type', type);
}
return chips;
}
function addChip(chips: HackChip[], kind: HackChip['kind'], label: string, value?: string): void {
if (!value) return;
chips.push({ kind, label, value });
}
function cleanText(value?: string | null): string | undefined {
if (value === null || value === undefined) return undefined;
const text = String(value).trim();
if (!text || text === '—') return undefined;
return text;
}
function cleanList(value?: string[] | null): string[] {
return (value ?? []).map((item) => cleanText(item)).filter((item): item is string => Boolean(item));
}
function countBy<T>(items: T[], keyFn: (item: T) => string): FacetCounts {
const counts: FacetCounts = {};
for (const item of items) {
const key = keyFn(item);
counts[key] = (counts[key] ?? 0) + 1;
}
return Object.fromEntries(Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])));
}
function romRelativePath(path?: string): string | undefined {
if (!path) return undefined;
const normalized = path.replaceAll('\\', '/').replace(/^\/+/, '');
const storagePrefix = 'storage1/Emulation/roms/';
if (normalized.startsWith(storagePrefix)) return normalized.slice(storagePrefix.length);
if (normalized.startsWith('roms/')) return normalized.slice('roms/'.length);
return undefined;
}
function librarySlugFromPath(path?: string): string | undefined {
if (!path) return undefined;
const normalized = path.replaceAll('\\', '/').replace(/^\/+/, '');
const marker = 'storage1/labdata/romhacks/library/';
if (!normalized.startsWith(marker)) return undefined;
return normalized.slice(marker.length).split('/').filter(Boolean)[0];
}
function encodePath(path: string): string {
return path
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment).replaceAll('(', '%28').replaceAll(')', '%29'))
.join('/');
}
+72
View File
@@ -0,0 +1,72 @@
import rawCatalog from '../../data/catalog.json';
import bannerManifest from '../generated/banner-manifest.json';
import fileManifest from '../generated/file-manifest.json';
import {
applyBannerManifest,
applyGeneratedFileLinks,
normalizeCatalog,
slugifyStem,
sortHacks,
} from './catalog';
import type { FacetName, FileLink, SiteCatalog, SiteHack, SortKey } from './types';
export const catalog: SiteCatalog = applyGeneratedFileLinks(
applyBannerManifest(normalizeCatalog(rawCatalog), bannerManifest),
fileManifest as Record<string, FileLink[]>,
);
export const hacks: SiteHack[] = sortHacks(catalog.hacks, 'name');
export const flagshipSlugs = [
'radical-red',
'unbound',
'gaia',
'infinite-fusion',
'emerald-rogue',
'elite-redux',
];
export const flagshipHacks = flagshipSlugs
.map((slug) => catalog.hacks.find((hack) => hack.slug === slug))
.filter((hack): hack is SiteHack => Boolean(hack));
export function getHackBySlug(slug: string): SiteHack | undefined {
return catalog.hacks.find((hack) => hack.slug === slug);
}
export function getRelatedHacks(hack: SiteHack, limit = 6): SiteHack[] {
return sortHacks(
catalog.hacks.filter((candidate) => {
if (candidate.slug === hack.slug) return false;
return (
(hack.base && candidate.base === hack.base) ||
(hack.developer && candidate.developer === hack.developer) ||
candidate.types.some((type) => hack.types.includes(type))
);
}),
'name',
).slice(0, limit);
}
export function getFacetEntries(facet: FacetName): [string, number][] {
return Object.entries(catalog.facets[facet] ?? {}).filter(([value]) => value !== 'Unknown');
}
export function facetValueSlug(value: string): string {
return slugifyStem(value);
}
export function getHacksForFacet(facet: FacetName, value: string, sort: SortKey = 'name'): SiteHack[] {
const matches = catalog.hacks.filter((hack) => {
if (facet === 'type') return hack.types.includes(value);
if (facet === 'platform') return (hack.platform ?? 'Unknown') === value;
if (facet === 'base') return (hack.base ?? 'Unknown') === value;
if (facet === 'status') return (hack.status ?? 'Unknown') === value;
return (hack.playStatus ?? 'Unknown') === value;
});
return sortHacks(matches, sort);
}
export function displayValue(value?: string): string {
return value || 'Unknown';
}
+95
View File
@@ -0,0 +1,95 @@
export type FacetName = 'platform' | 'base' | 'status' | 'type' | 'play_status';
export type SortKey = 'name' | 'release_date' | 'platform';
export type FileLinkKind = 'rom' | 'library' | 'guide' | 'external';
export type FacetCounts = Record<string, number>;
export interface RawCatalog {
generated: string;
count: number;
image_base: string;
facets: Record<FacetName, FacetCounts>;
hacks: RawHack[];
}
export interface RawHack {
stem: string;
title: string;
platform?: string | null;
base?: string | null;
version?: string | null;
status?: string | null;
generation?: string | null;
developer?: string | null;
release_date?: string | null;
banner?: string | null;
homepage?: string | null;
source?: string | null;
library_path?: string | null;
play_status?: string | null;
scrape_dir?: string | null;
rating?: number | null;
type?: string[];
tags?: string[];
summary?: string;
notability?: string;
story?: string;
features?: string[];
links?: ExternalLink[];
}
export interface ExternalLink {
label: string;
url: string;
}
export interface HackChip {
label: string;
value: string;
kind: 'platform' | 'base' | 'version' | 'status' | 'developer' | 'release_date' | 'type';
}
export interface FileLink {
label: string;
url: string;
kind: FileLinkKind;
}
export interface SiteHack {
stem: string;
slug: string;
title: string;
platform?: string;
base?: string;
version?: string;
status?: string;
generation?: string;
developer?: string;
releaseDate?: string;
banner?: string;
bannerLocal: string;
homepage?: string;
source?: string;
libraryPath?: string;
playStatus?: string;
scrapeDir?: string;
rating?: number;
types: string[];
tags: string[];
summary: string;
notability: string;
story: string;
features: string[];
links: ExternalLink[];
chips: HackChip[];
fileLinks: FileLink[];
searchText: string;
}
export interface SiteCatalog {
generated: string;
count: number;
imageBase: string;
facets: Record<FacetName, FacetCounts>;
hacks: SiteHack[];
}
+33
View File
@@ -0,0 +1,33 @@
---
import HackCard from '../../../components/HackCard.astro';
import Layout from '../../../components/Layout.astro';
import { facetValueSlug, getFacetEntries, getHacksForFacet } from '../../../lib/site-data';
import type { FacetName } from '../../../lib/types';
export function getStaticPaths() {
const browsableFacets: FacetName[] = ['platform', 'base', 'type', 'status'];
return browsableFacets.flatMap((facet) =>
getFacetEntries(facet).map(([value]) => ({
params: { facet, value: facetValueSlug(value) },
props: { facet, value },
})),
);
}
const { facet, value } = Astro.props as { facet: FacetName; value: string };
const facetTitle = facet.replace('_', ' ');
const matches = getHacksForFacet(facet, value);
---
<Layout title={`${value} ${facetTitle} Hacks | ROM Hack Archive`}>
<section class="browse-header">
<a class="back-link" href="/">Catalog</a>
<h1>{value}</h1>
<p>{matches.length} hacks grouped by {facetTitle}.</p>
</section>
<section class="catalog-results browse-results">
<div class="hack-grid">
{matches.map((hack) => <HackCard hack={hack} />)}
</div>
</section>
</Layout>
+86
View File
@@ -0,0 +1,86 @@
---
import Chip from '../../components/Chip.astro';
import ContentSection from '../../components/ContentSection.astro';
import HackCard from '../../components/HackCard.astro';
import Layout from '../../components/Layout.astro';
import { getHackBySlug, getRelatedHacks, hacks } from '../../lib/site-data';
export function getStaticPaths() {
return hacks.map((hack) => ({ params: { slug: hack.slug } }));
}
const hack = getHackBySlug(Astro.params.slug ?? '');
if (!hack) throw new Error(`Unknown hack ${Astro.params.slug}`);
const related = getRelatedHacks(hack);
const description = hack.summary || `${hack.title} in the ROM Hack Archive.`;
---
<Layout title={`${hack.title} | ROM Hack Archive`} description={description}>
<article class="detail">
<header class="detail-hero" style={`--accent: var(--type-${(hack.types[0] ?? 'unknown').toLowerCase().replaceAll(' ', '-')}, var(--type-unknown));`}>
<img src={hack.bannerLocal} alt="" />
<div class="detail-hero-copy">
<a class="back-link" href="/">Catalog</a>
<h1>{hack.title}</h1>
<div class="chip-row">
{hack.chips.map((chip) => <Chip label={chip.label} value={chip.value} kind={chip.kind} />)}
{hack.playStatus && <Chip label="Play" value={hack.playStatus} kind="play" />}
{hack.rating && <Chip label="Rating" value={`${hack.rating}/5`} kind="rating" />}
</div>
</div>
</header>
<div class="detail-grid">
<div class="detail-main">
{hack.summary && <ContentSection title="Summary" body={hack.summary} />}
{hack.notability && <ContentSection title="Why It Stands Out" body={hack.notability} />}
{hack.story && <ContentSection title="Story" body={hack.story} />}
{hack.features.length > 0 && (
<section class="detail-section">
<h2>Features</h2>
<ul class="feature-list">
{hack.features.map((feature) => <li>{feature}</li>)}
</ul>
</section>
)}
</div>
<aside class="detail-side">
<section class="side-panel">
<h2>Files</h2>
{hack.fileLinks.length > 0 ? (
<ul class="link-list">
{hack.fileLinks.map((link) => (
<li><a href={link.url}>{link.label}</a><span>{link.kind}</span></li>
))}
</ul>
) : (
<p>No local files are mapped yet.</p>
)}
</section>
<section class="side-panel">
<h2>External Links</h2>
{hack.links.length > 0 ? (
<ul class="link-list">
{hack.links.map((link) => <li><a href={link.url}>{link.label}</a><span>link</span></li>)}
</ul>
) : (
<p>No external links are mapped yet.</p>
)}
</section>
</aside>
</div>
{related.length > 0 && (
<section class="related">
<div class="section-heading">
<h2>Related Hacks</h2>
<p>Shared base, developer, or type.</p>
</div>
<div class="flagship-grid">
{related.map((item) => <HackCard hack={item} compact />)}
</div>
</section>
)}
</article>
</Layout>
+126
View File
@@ -0,0 +1,126 @@
---
import HackCard from '../components/HackCard.astro';
import FilterGroup from '../components/FilterGroup.astro';
import Layout from '../components/Layout.astro';
import { catalog, flagshipHacks, getFacetEntries, hacks } from '../lib/site-data';
const platforms = getFacetEntries('platform');
const bases = getFacetEntries('base').slice(0, 12);
const types = getFacetEntries('type');
const statuses = getFacetEntries('status');
---
<Layout title="ROM Hack Archive">
<section class="hero">
<div class="hero-copy">
<h1>ROM Hack Archive</h1>
<p>A private catalog of {catalog.count} Pokémon ROM hacks, fan-games, patches, notes, guides, and files.</p>
</div>
<div class="hero-stats" aria-label="Catalog totals">
<div><strong>{catalog.count}</strong><span>hacks</span></div>
<div><strong>{catalog.facets.platform.GBA ?? 0}</strong><span>GBA</span></div>
<div><strong>{catalog.facets.type.Expansion ?? 0}</strong><span>expansions</span></div>
</div>
</section>
<section class="flagships" aria-labelledby="flagships-heading">
<div class="section-heading">
<h2 id="flagships-heading">Flagship Picks</h2>
<p>Known anchors from the collection.</p>
</div>
<div class="flagship-grid">
{flagshipHacks.map((hack) => <HackCard hack={hack} compact />)}
</div>
</section>
<section class="catalog-shell" aria-label="Hack catalog">
<aside class="filter-rail">
<div class="filter-panel">
<label class="search-label" for="search">Search hacks</label>
<input id="search" class="search-input" type="search" placeholder="Search hacks" data-search-input />
</div>
<div class="filter-panel">
<label class="search-label" for="sort">Sort</label>
<select id="sort" class="select-input" data-sort-select>
<option value="name">Name</option>
<option value="release_date">Release date</option>
<option value="platform">Platform</option>
</select>
</div>
<FilterGroup title="Platform" name="platform" entries={platforms} />
<FilterGroup title="Base" name="base" entries={bases} />
<FilterGroup title="Type" name="type" entries={types} />
<FilterGroup title="Status" name="status" entries={statuses} />
</aside>
<div class="catalog-results">
<div class="results-bar">
<h2>All Hacks</h2>
<span data-result-count>{hacks.length} shown</span>
</div>
<div class="hack-grid" data-card-grid>
{hacks.map((hack) => <HackCard hack={hack} />)}
</div>
<p class="empty-state" data-empty-state hidden>No hacks match those filters.</p>
</div>
</section>
<script is:inline>
const grid = document.querySelector('[data-card-grid]');
const cards = Array.from(grid.querySelectorAll('[data-card]'));
const searchInput = document.querySelector('[data-search-input]');
const sortSelect = document.querySelector('[data-sort-select]');
const resultCount = document.querySelector('[data-result-count]');
const emptyState = document.querySelector('[data-empty-state]');
const filterInputs = Array.from(document.querySelectorAll('[data-filter]'));
function selected(name) {
return filterInputs
.filter((input) => input.name === name && input.checked)
.map((input) => input.value);
}
function matches(card) {
const query = searchInput.value.trim().toLowerCase();
const platform = selected('platform');
const base = selected('base');
const type = selected('type');
const status = selected('status');
const cardTypes = card.dataset.types ? card.dataset.types.split('|') : [];
if (query && !card.dataset.search.includes(query)) return false;
if (platform.length && !platform.includes(card.dataset.platform)) return false;
if (base.length && !base.includes(card.dataset.base)) return false;
if (status.length && !status.includes(card.dataset.status)) return false;
if (type.length && !type.some((value) => cardTypes.includes(value))) return false;
return true;
}
function sortCards(visibleCards) {
const key = sortSelect.value;
return visibleCards.sort((a, b) => {
if (key === 'release_date') return (b.dataset.release || '').localeCompare(a.dataset.release || '');
if (key === 'platform') {
const byPlatform = (a.dataset.platform || 'ZZZ').localeCompare(b.dataset.platform || 'ZZZ');
return byPlatform || a.dataset.title.localeCompare(b.dataset.title);
}
return a.dataset.title.localeCompare(b.dataset.title);
});
}
function updateCatalog() {
const visible = [];
for (const card of cards) {
const isVisible = matches(card);
card.hidden = !isVisible;
if (isVisible) visible.push(card);
}
for (const card of sortCards(visible)) grid.append(card);
resultCount.textContent = `${visible.length} shown`;
emptyState.hidden = visible.length !== 0;
}
searchInput.addEventListener('input', updateCatalog);
sortSelect.addEventListener('change', updateCatalog);
for (const input of filterInputs) input.addEventListener('change', updateCatalog);
</script>
</Layout>
+28
View File
@@ -0,0 +1,28 @@
---
import HackCard from '../components/HackCard.astro';
import Layout from '../components/Layout.astro';
import { catalog } from '../lib/site-data';
import { sortHacks } from '../lib/catalog';
const queue = sortHacks(
catalog.hacks.filter((hack) => hack.playStatus && hack.playStatus !== 'Unplayed'),
'name',
);
---
<Layout title="Play Queue | ROM Hack Archive">
<section class="browse-header">
<a class="back-link" href="/">Catalog</a>
<h1>Play Queue</h1>
<p>{queue.length} hacks have an active play status or rating.</p>
</section>
<section class="catalog-results browse-results">
{queue.length > 0 ? (
<div class="hack-grid">
{queue.map((hack) => <HackCard hack={hack} />)}
</div>
) : (
<p class="empty-state">No active queue items yet.</p>
)}
</section>
</Layout>