feat(web): add bgm viewer navigation
This commit is contained in:
@@ -4,6 +4,12 @@ import ModPage from './pages/ModPage';
|
|||||||
import FullSetupPage from './pages/FullSetupPage';
|
import FullSetupPage from './pages/FullSetupPage';
|
||||||
import BgmPage from './pages/BgmPage';
|
import BgmPage from './pages/BgmPage';
|
||||||
import BgmPackagePage from './pages/BgmPackagePage';
|
import BgmPackagePage from './pages/BgmPackagePage';
|
||||||
|
import PartsPage from './pages/PartsPage';
|
||||||
|
import PartPage from './pages/PartPage';
|
||||||
|
import SurfacesPage from './pages/SurfacesPage';
|
||||||
|
import SurfacePage from './pages/SurfacePage';
|
||||||
|
import SetupsPage from './pages/SetupsPage';
|
||||||
|
import SetupPage from './pages/SetupPage';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -30,6 +36,12 @@ export default function App() {
|
|||||||
<Route path="/mod/:id/setup" element={<FullSetupPage />} />
|
<Route path="/mod/:id/setup" element={<FullSetupPage />} />
|
||||||
<Route path="/bgm" element={<BgmPage />} />
|
<Route path="/bgm" element={<BgmPage />} />
|
||||||
<Route path="/bgm/:id" element={<BgmPackagePage />} />
|
<Route path="/bgm/:id" element={<BgmPackagePage />} />
|
||||||
|
<Route path="/bgm/:id/parts" element={<PartsPage />} />
|
||||||
|
<Route path="/bgm/:id/parts/:type/:part" element={<PartPage />} />
|
||||||
|
<Route path="/bgm/:id/surfaces" element={<SurfacesPage />} />
|
||||||
|
<Route path="/bgm/:id/surfaces/:type/:surface" element={<SurfacePage />} />
|
||||||
|
<Route path="/bgm/:id/setups" element={<SetupsPage />} />
|
||||||
|
<Route path="/bgm/:id/setups/:type/:setup" element={<SetupPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export interface Crumb {
|
||||||
|
label: string;
|
||||||
|
/** Where the crumb links; omit for the current (last) crumb. */
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A breadcrumb navigation trail. The last crumb is the current page and is
|
||||||
|
* rendered as plain text; earlier crumbs link to their routes.
|
||||||
|
*/
|
||||||
|
export default function Breadcrumbs({ crumbs }: { crumbs: Crumb[] }) {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-sm text-zinc-400">
|
||||||
|
{crumbs.map((crumb, i) => {
|
||||||
|
const last = i === crumbs.length - 1;
|
||||||
|
return (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="text-zinc-600">/</span>}
|
||||||
|
{crumb.to && !last ? (
|
||||||
|
<Link to={crumb.to} className="hover:text-zinc-100">
|
||||||
|
{crumb.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className={last ? 'text-zinc-100' : ''}>{crumb.label}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from './Breadcrumbs';
|
||||||
|
|
||||||
|
/** Shown when a bgm package id doesn't resolve to a known package. */
|
||||||
|
export default function PackageMissing({ id }: { id: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Breadcrumbs crumbs={[{ label: 'Board games', to: '/bgm' }, { label: id }]} />
|
||||||
|
<p className="text-sm text-zinc-400">No package “{id}”.</p>
|
||||||
|
<Link to="/bgm" className="text-sm text-zinc-300 underline">
|
||||||
|
Back to all packages
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,36 +1,47 @@
|
|||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import packages from 'virtual:bgm/packages';
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detail view for a single bgm package, looked up by id from the
|
* Summary view for a single bgm package: its metadata and counts of parts,
|
||||||
* `virtual:bgm/packages` module's list of all discovered packages.
|
* surfaces, and setups, each linking to its collection page.
|
||||||
*/
|
*/
|
||||||
export default function BgmPackagePage() {
|
export default function BgmPackagePage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const pkg = packages.find((p) => p.meta.id === id);
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
if (!pkg) {
|
const partCount = Object.keys(pkg.parts).length;
|
||||||
return (
|
const surfaceCount = Object.keys(pkg.surfaces).length;
|
||||||
<div className="space-y-4">
|
const setupCount = Object.keys(pkg.setups).length;
|
||||||
<p className="text-sm text-zinc-400">No package “{id}”.</p>
|
|
||||||
<Link to="/bgm" className="text-sm text-zinc-300 underline">
|
|
||||||
Back to all packages
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = Object.values(pkg.parts);
|
const sections = [
|
||||||
const surfaces = Object.values(pkg.surfaces);
|
{
|
||||||
const setups = Object.values(pkg.setups);
|
label: 'Parts',
|
||||||
|
count: partCount,
|
||||||
|
to: `/bgm/${pkg.meta.id}/parts`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Surfaces',
|
||||||
|
count: surfaceCount,
|
||||||
|
to: `/bgm/${pkg.meta.id}/surfaces`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Setups',
|
||||||
|
count: setupCount,
|
||||||
|
to: `/bgm/${pkg.meta.id}/setups`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[{ label: 'Board games', to: '/bgm' }, { label: pkg.meta.title ?? pkg.meta.id }]}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Link to="/bgm" className="text-sm text-zinc-400 hover:text-zinc-200">
|
<h1 className="text-2xl font-semibold">{pkg.meta.title ?? pkg.meta.id}</h1>
|
||||||
← All packages
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-1 text-2xl font-semibold">{pkg.meta.title ?? pkg.meta.id}</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-400">
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
{pkg.meta.designer ? `${pkg.meta.designer} · ` : ''}
|
{pkg.meta.designer ? `${pkg.meta.designer} · ` : ''}
|
||||||
{pkg.meta.players ? `${pkg.meta.players} players · ` : ''}
|
{pkg.meta.players ? `${pkg.meta.players} players · ` : ''}
|
||||||
@@ -38,52 +49,19 @@ export default function BgmPackagePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="space-y-2">
|
<ul className="grid gap-2 sm:grid-cols-3">
|
||||||
<h2 className="text-lg font-semibold">Parts</h2>
|
{sections.map((section) => (
|
||||||
<ul className="grid gap-2 sm:grid-cols-2">
|
<li key={section.label}>
|
||||||
{parts.map((part) => (
|
<Link
|
||||||
<li key={`${part.type}#${part.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
to={section.to}
|
||||||
<div className="font-medium">
|
className="block rounded-lg border border-zinc-800 bg-zinc-900 p-3 hover:border-zinc-600"
|
||||||
{part.type}#{part.id}
|
>
|
||||||
</div>
|
<div className="font-medium">{section.label}</div>
|
||||||
<div className="mt-1 text-xs text-zinc-400">
|
<div className="mt-1 text-sm text-zinc-400">{section.count}</div>
|
||||||
{part.size ? `size ${part.size.join('×')}mm` : ''}
|
</Link>
|
||||||
{part.fillet ? ` · fillet ${part.fillet}mm` : ''}
|
</li>
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-2">
|
|
||||||
<h2 className="text-lg font-semibold">Surfaces</h2>
|
|
||||||
<ul className="grid gap-2 sm:grid-cols-2">
|
|
||||||
{surfaces.map((surface) => (
|
|
||||||
<li key={`${surface.type}#${surface.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
|
||||||
<div className="font-medium">
|
|
||||||
{surface.type}#{surface.id}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-zinc-400">
|
|
||||||
{surface.size ? `size ${surface.size.join('×')}mm` : ''} · {surface.layout.length} routes
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-2">
|
|
||||||
<h2 className="text-lg font-semibold">Setups</h2>
|
|
||||||
{setups.map((setup) => (
|
|
||||||
<div key={`${setup.type}#${setup.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
|
||||||
<div className="font-medium">
|
|
||||||
{setup.type}#{setup.id}
|
|
||||||
</div>
|
|
||||||
<pre className="mt-2 overflow-x-auto text-xs text-zinc-400">
|
|
||||||
{JSON.stringify(setup.setup, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</section>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import packages from 'virtual:bgm/packages';
|
import packages from 'virtual:bgm/packages';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists every discovered bgm package. The `virtual:bgm/packages` module's
|
* Lists every discovered bgm package. The `virtual:bgm/packages` module's
|
||||||
@@ -9,6 +10,7 @@ import packages from 'virtual:bgm/packages';
|
|||||||
export default function BgmPage() {
|
export default function BgmPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs crumbs={[{ label: 'Board games' }]} />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Board games</h1>
|
<h1 className="text-2xl font-semibold">Board games</h1>
|
||||||
<p className="mt-1 text-sm text-zinc-400">
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/** Detail view for a single part within a package. */
|
||||||
|
export default function PartPage() {
|
||||||
|
const { id, type, part } = useParams<{ id: string; type: string; part: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const found = pkg.parts[`${type}#${part}`];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Parts', to: `/bgm/${pkg.meta.id}/parts` },
|
||||||
|
{ label: `${type}#${part}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!found ? (
|
||||||
|
<p className="text-sm text-zinc-400">No part “{type}#{part}”.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{found.type}#{found.id}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{found.size ? `size ${found.size.join('×')}mm` : ''}
|
||||||
|
{found.fillet ? ` · fillet ${found.fillet}mm` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
||||||
|
{JSON.stringify(found, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All parts of a package, grouped by part type. Each part links to its
|
||||||
|
* `/bgm/:id/parts/:type/:part` detail page.
|
||||||
|
*/
|
||||||
|
export default function PartsPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const parts = Object.values(pkg.parts);
|
||||||
|
const byType = new Map<string, typeof parts>();
|
||||||
|
for (const part of parts) {
|
||||||
|
const list = byType.get(part.type) ?? [];
|
||||||
|
list.push(part);
|
||||||
|
byType.set(part.type, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Parts' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Parts</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">{parts.length} part{parts.length === 1 ? '' : 's'}.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[...byType.entries()].map(([type, list]) => (
|
||||||
|
<section key={type} className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold">{type}</h2>
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{list.map((part) => (
|
||||||
|
<li key={`${part.type}#${part.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<Link
|
||||||
|
to={`/bgm/${pkg.meta.id}/parts/${part.type}/${part.id}`}
|
||||||
|
className="font-medium hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{part.id}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{part.size ? `size ${part.size.join('×')}mm` : ''}
|
||||||
|
{part.fillet ? ` · fillet ${part.fillet}mm` : ''}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/** Detail view for a single setup within a package. */
|
||||||
|
export default function SetupPage() {
|
||||||
|
const { id, type, setup } = useParams<{ id: string; type: string; setup: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const found = pkg.setups[`${type}#${setup}`];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Setups', to: `/bgm/${pkg.meta.id}/setups` },
|
||||||
|
{ label: `${type}#${setup}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!found ? (
|
||||||
|
<p className="text-sm text-zinc-400">No setup “{type}#{setup}”.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{found.type}#{found.id}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
||||||
|
{JSON.stringify(found.setup, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All setups of a package. Each setup links to its
|
||||||
|
* `/bgm/:id/setups/:type/:setup` detail page.
|
||||||
|
*/
|
||||||
|
export default function SetupsPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const setups = Object.values(pkg.setups);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Setups' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Setups</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{setups.length} setup{setups.length === 1 ? '' : 's'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{setups.map((setup) => (
|
||||||
|
<li key={`${setup.type}#${setup.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<Link
|
||||||
|
to={`/bgm/${pkg.meta.id}/setups/${setup.type}/${setup.id}`}
|
||||||
|
className="font-medium hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{setup.id}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/** Detail view for a single surface within a package. */
|
||||||
|
export default function SurfacePage() {
|
||||||
|
const { id, type, surface } = useParams<{ id: string; type: string; surface: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const found = pkg.surfaces[`${type}#${surface}`];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Surfaces', to: `/bgm/${pkg.meta.id}/surfaces` },
|
||||||
|
{ label: `${type}#${surface}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!found ? (
|
||||||
|
<p className="text-sm text-zinc-400">No surface “{type}#{surface}”.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{found.type}#{found.id}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{found.size ? `size ${found.size.join('×')}mm` : ''} · {found.layout.length} routes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
||||||
|
{JSON.stringify(found, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All surfaces of a package. Each surface links to its
|
||||||
|
* `/bgm/:id/surfaces/:type/:surface` detail page.
|
||||||
|
*/
|
||||||
|
export default function SurfacesPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const surfaces = Object.values(pkg.surfaces);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Surfaces' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Surfaces</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{surfaces.length} surface{surfaces.length === 1 ? '' : 's'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{surfaces.map((surface) => (
|
||||||
|
<li key={`${surface.type}#${surface.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<Link
|
||||||
|
to={`/bgm/${pkg.meta.id}/surfaces/${surface.type}/${surface.id}`}
|
||||||
|
className="font-medium hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{surface.id}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{surface.size ? `size ${surface.size.join('×')}mm` : ''} · {surface.layout.length} routes
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import packages from 'virtual:bgm/packages';
|
||||||
|
import type { SerializedPackage } from '@tts/bgm';
|
||||||
|
|
||||||
|
/** Look up a bgm package by id from the `virtual:bgm/packages` module. */
|
||||||
|
export function findPackage(id: string | undefined): SerializedPackage | undefined {
|
||||||
|
return packages.find((p) => p.meta.id === id);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user