225 lines
8.5 KiB
Plaintext
225 lines
8.5 KiB
Plaintext
---
|
|
import HackCard from '../components/HackCard.astro';
|
|
import FilterGroup from '../components/FilterGroup.astro';
|
|
import Layout from '../components/Layout.astro';
|
|
import { catalog, facetValueSlug, flagshipHacks, getFacetEntries, hacks } from '../lib/site-data';
|
|
import type { FacetName } from '../lib/types';
|
|
|
|
const platforms = getFacetEntries('platform');
|
|
const bases = getFacetEntries('base').slice(0, 12);
|
|
const types = getFacetEntries('type');
|
|
const statuses = getFacetEntries('status');
|
|
|
|
type StatItem = {
|
|
label: string;
|
|
count: number;
|
|
};
|
|
|
|
type StatGroup = {
|
|
label: string;
|
|
icon: string;
|
|
items: StatItem[];
|
|
};
|
|
|
|
type BrowseGroup = {
|
|
label: string;
|
|
facet: Extract<FacetName, 'platform' | 'type' | 'status'>;
|
|
entries: [string, number][];
|
|
};
|
|
|
|
function facetCount(facet: FacetName, value: string): number {
|
|
return catalog.facets[facet]?.[value] ?? 0;
|
|
}
|
|
|
|
function displayFacetValue(value: string): string {
|
|
return value === 'Unknown' ? 'Unclassified' : value;
|
|
}
|
|
|
|
function selectedItems(facet: FacetName, values: string[]): StatItem[] {
|
|
return values
|
|
.map((value) => ({ label: displayFacetValue(value), count: facetCount(facet, value) }))
|
|
.filter((item) => item.count > 0);
|
|
}
|
|
|
|
function otherItem(facet: FacetName, selectedValues: string[]): StatItem | undefined {
|
|
const selected = new Set([...selectedValues, 'Unknown']);
|
|
const count = getFacetEntries(facet)
|
|
.filter(([value]) => !selected.has(value))
|
|
.reduce((total, [, entryCount]) => total + entryCount, 0);
|
|
return count > 0 ? { label: 'Other', count } : undefined;
|
|
}
|
|
|
|
function summaryItems(facet: FacetName, values: string[]): StatItem[] {
|
|
const items = selectedItems(facet, values);
|
|
const other = otherItem(facet, values);
|
|
const unknown = facetCount(facet, 'Unknown');
|
|
if (other) items.push(other);
|
|
if (unknown > 0) items.push({ label: 'Unclassified', count: unknown });
|
|
return items;
|
|
}
|
|
|
|
const statGroups: StatGroup[] = [
|
|
{ label: 'Platforms', icon: 'platform', items: summaryItems('platform', ['GBA', 'NDS', 'PC', 'GBC']) },
|
|
{ label: 'Types', icon: 'type', items: summaryItems('type', ['Expansion', 'New Experience', 'Difficulty', 'QoL']) },
|
|
{ label: 'Status', icon: 'status', items: summaryItems('status', ['Complete', 'Ongoing', 'Beta']) },
|
|
];
|
|
|
|
const browseGroups: BrowseGroup[] = [
|
|
{ label: 'Platforms', facet: 'platform', entries: platforms },
|
|
{ label: 'Types', facet: 'type', entries: types },
|
|
{ label: 'Status', facet: 'status', entries: statuses },
|
|
];
|
|
---
|
|
|
|
<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>
|
|
<details class="hero-stats-panel" open>
|
|
<summary>Catalog breakdown</summary>
|
|
<div class="hero-stats" aria-label="Catalog totals">
|
|
<div class="stat-card stat-card-total"><strong>{catalog.count}</strong><span>hacks</span></div>
|
|
{statGroups.map((group) => (
|
|
<div class="stat-card stat-card-group">
|
|
<span class="stat-heading">{group.label}</span>
|
|
<ul class="stat-list">
|
|
{group.items.map((item) => (
|
|
<li>
|
|
<span class={`stat-icon stat-icon-${group.icon}`} aria-hidden="true"></span>
|
|
<span class="stat-label">{item.label}</span>
|
|
<strong>{item.count}</strong>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
</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 id="browse" class="browse-summary" aria-label="Browse by catalog facets">
|
|
<div class="section-heading">
|
|
<h2>Browse the archive</h2>
|
|
<p>Jump into grouped catalog views without privileging a single platform or play style.</p>
|
|
</div>
|
|
<div class="browse-summary-grid">
|
|
{browseGroups.map((group) => (
|
|
<section class="browse-facet" aria-labelledby={`browse-${group.facet}`}>
|
|
<h3 id={`browse-${group.facet}`}>{group.label}</h3>
|
|
<div class="browse-link-list">
|
|
{group.entries.map(([value, count]) => (
|
|
<a href={`/browse/${group.facet}/${facetValueSlug(value)}/`}>
|
|
<span>{displayFacetValue(value)}</span>{' '}
|
|
<em>{count}</em>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</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>
|