Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/app/cards/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { cards, getCard, rarityLabel } from '@/data/cards';
import { OG_CONTENT_TYPE, OG_SIZE, renderCardOg } from '@/lib/og';

export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export const alt = 'Open Source Legends trading card';

export function generateStaticParams() {
return cards.map((c) => ({ slug: c.slug }));
}

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const card = getCard(slug);
if (!card) throw new Error(`No card for slug ${slug}`);

return renderCardOg({
number: card.number,
name: card.name,
title: card.title,
rarity: card.rarity,
rarityLabel: rarityLabel[card.rarity],
impact: card.impact,
front: card.front,
setLabel: 'Series One',
});
}
86 changes: 86 additions & 0 deletions src/app/cards/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import CardDetail from '@/components/CardDetail';
import { cards, getCard, rarityLabel } from '@/data/cards';
import { site } from '@/data/site';

type Params = { params: Promise<{ slug: string }> };

export function generateStaticParams() {
return cards.map((c) => ({ slug: c.slug }));
}

export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { slug } = await params;
const card = getCard(slug);
if (!card) return { title: 'Card not found' };

const title = `${card.name} — ${card.title}`;
const description = `${card.knownFor}. Card #${String(card.number).padStart(3, '0')} in ${site.name} Series One, ${rarityLabel[card.rarity]} rarity, impact ${card.impact}.`;
const url = `${site.url}/cards/${card.slug}`;

return {
title,
description,
alternates: { canonical: url },
openGraph: { title, description, url, type: 'profile' },
twitter: { card: 'summary_large_image', title, description },
};
}

export default async function CardPage({ params }: Params) {
const { slug } = await params;
const card = getCard(slug);
if (!card) notFound();

const i = cards.findIndex((c) => c.slug === card.slug);
const prev = i > 0 ? cards[i - 1] : undefined;
const next = i < cards.length - 1 ? cards[i + 1] : undefined;

const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Person',
name: card.name,
jobTitle: card.title,
description: card.scouting,
nationality: card.nationality,
url: `${site.url}/cards/${card.slug}`,
image: `${site.url}${card.front}`,
subjectOf: {
'@type': 'CreativeWork',
name: `${card.name} — ${site.name} card #${String(card.number).padStart(3, '0')}`,
license: 'https://creativecommons.org/licenses/by-sa/4.0/',
},
};

return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<CardDetail
setLabel="Series One"
setHref="/cards"
number={card.number}
slug={card.slug}
name={card.name}
title={card.title}
knownFor={card.knownFor}
rarity={card.rarity}
rarityLabel={rarityLabel[card.rarity]}
impact={card.impact}
nationality={card.nationality}
era={card.era}
chips={card.projects}
chipsLabel="Signature projects"
scouting={card.scouting}
quote={card.quote}
front={card.front}
back={card.back}
prev={prev && { slug: prev.slug, name: prev.name, front: prev.front }}
next={next && { slug: next.slug, name: next.name, front: next.front }}
/>
</>
);
}
3 changes: 2 additions & 1 deletion src/app/cards/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import styles from './page.module.css';

export const metadata: Metadata = {
title: 'The Set',
description: 'A preview of Open Source Legends Series One — 50 collectible cards.',
description: `Every card in Open Source Legends Series One — ${cards.length} collectible cards, each with its own page.`,
alternates: { canonical: '/cards' },
};

export default function CardsPage() {
Expand Down
9 changes: 8 additions & 1 deletion src/app/collect/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { site } from '@/data/site';
import { cards } from '@/data/cards';
import WaitlistForm from '@/components/WaitlistForm';
import styles from './page.module.css';

export const metadata: Metadata = {
title: 'Collect',
description: 'Order physical foil packs, join the NFT mint list, or print your own Open Source Legends cards.',
alternates: { canonical: '/collect' },
};

const tiers = [
Expand All @@ -24,7 +26,12 @@ const tiers = [
price: '$79',
unit: 'full set',
blurb: 'The complete Series One in a numbered display box. Every legend, no duplicates.',
features: ['All 16 cards', 'Numbered limited box', 'Premium foil set', 'Certificate of authenticity'],
features: [
`All ${cards.length} cards`,
'Numbered limited box',
'Premium foil set',
'Certificate of authenticity',
],
cta: 'Reserve a box',
featured: true,
},
Expand Down
34 changes: 34 additions & 0 deletions src/app/contribute/page.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,44 @@
color: #cdd3e0;
}

.specNotes {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 2.4rem;
margin-top: 3.2rem;
}

.specNotes article {
padding: 2.2rem;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.02);
}

.specNotes h3 {
font-size: 1.7rem;
font-weight: 700;
margin-bottom: 1rem;
}

.specNotes p {
font-size: 1.45rem;
color: var(--text-muted);
line-height: 1.7;
}

.specNotes .mono {
color: var(--crest);
font-size: 1.3rem;
}

@media (max-width: 900px) {
.steps {
grid-template-columns: repeat(2, 1fr);
}
.specNotes {
grid-template-columns: minmax(0, 1fr);
}
}

@media (max-width: 540px) {
Expand Down
87 changes: 66 additions & 21 deletions src/app/contribute/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import styles from './page.module.css';
export const metadata: Metadata = {
title: 'Contribute',
description: 'Nominate a legend, design a card, or improve the stats. Open Source Legends is built in the open.',
alternates: { canonical: '/contribute' },
};

const steps = [
Expand All @@ -15,18 +16,18 @@ const steps = [
},
{
n: '02',
title: 'Draft the card',
body: 'Add a record to legends.ts — title, signature projects, skill stack, impact score, and a great quote. Keep it accurate and sourced.',
title: 'Draft the card data',
body: 'Add a record to the roster for the series you are contributing to — see the spec below. Every factual claim needs a public source we can check.',
},
{
n: '03',
title: 'Open a pull request',
body: 'Submit art (or use the monogram fallback) and your card data. Maintainers review for accuracy, licensing, and balance.',
title: 'Render the faces',
body: 'Run the series pipeline to produce the front and back PNGs from your record. Card art is generated from the data, so nobody hand-edits a card image.',
},
{
n: '04',
title: 'Ship it',
body: 'Merged cards join the printable set and the next physical series. Contributors are credited on the card and in the repo.',
title: 'Open a pull request',
body: 'Ship the roster record plus the rendered faces. Maintainers review for accuracy, sourcing, licensing, and balance.',
},
];

Expand Down Expand Up @@ -68,32 +69,76 @@ export default function ContributePage() {
<div className="container">
<div className={styles.sectionHead}>
<span className="kicker">The card spec</span>
<h2 className={styles.h2}>One legend, one object</h2>
<h2 className={styles.h2}>One legend, one record</h2>
<p className={styles.specSub}>
Every card is a typed record. Add yours to <span className="mono">src/data/legends.ts</span>.
Series Two is the set currently taking nominations. Its roster is
hand-curated in <span className="mono">src/data/hacking.ts</span>, and that
file is the source of truth — the pipeline reads it directly, so there is no
second copy to keep in sync.
</p>
</div>
<pre className={styles.code}>
<code>{`{
number: 17,
<code>{`// src/data/hacking.ts
{
number: 31,
slug: "ada-lovelace",
name: "Ada Lovelace",
handle: "", // optional scene handle
title: "The First Programmer",
rarity: "iconic", // iconic | legendary | epic | rare
impact: 96, // headline score, 0–99
era: "1815 – 1852",
knownFor: "The first published algorithm",
rarity: "iconic", // iconic | legendary | epic | rare
impact: 96, // headline score, 0-100
technical: 97, // the four stat bars on the card back
social: 70,
notoriety: 60,
influence: 95,
nationality: "England",
signatureProjects: ["Analytical Engine", "Note G"],
skills: [
{ label: "Algorithmic Thinking", value: 99 },
{ label: "Vision", value: 99 },
{ label: "Mathematics", value: 97 },
{ label: "Open Source Impact", value: 90 },
],
era: "1815-1852",
domains: ["Computing theory", "Mathematics"],
scouting: "Wrote the first published algorithm...",
quote: "That brain of mine is something more than merely mortal.",
note: "Curator's voice. Never a fabricated quote.",
status: "draft", // locked | draft | candidate
sources: [ // required — at least one public reference
{ label: "Ada Lovelace", url: "https://en.wikipedia.org/wiki/Ada_Lovelace" },
],
}`}</code>
</pre>

<div className={styles.specNotes}>
<article>
<h3>Then render the faces</h3>
<p>
<span className="mono">pnpm hacking validate</span> checks the roster and
reports what art is missing.{' '}
<span className="mono">pnpm hacking all 31</span> generates the portrait,
renders the front and back, and writes them to{' '}
<span className="mono">public/cards/hacking/</span>. The{' '}
<span className="mono">front</span> and <span className="mono">back</span>{' '}
fields are written by that step — never by hand.
</p>
</article>
<article>
<h3>Series One works differently</h3>
<p>
Series One is complete and locked. Its fact-checked roster lives in{' '}
<span className="mono">data/roster.locked.json</span>, and{' '}
<span className="mono">src/data/cards.ts</span> is generated from it — the
header of that file says so. Edit the JSON and re-run{' '}
<span className="mono">node scripts/open-source-legends.mjs all</span>;
a pull request that edits <span className="mono">cards.ts</span> directly
will be overwritten by the next render.
</p>
</article>
<article>
<h3>Sourcing is not optional</h3>
<p>
Every historical claim on a card has to be checkable. The{' '}
<span className="mono">sources</span> array is rendered on the card page
and in the roster&apos;s scouting report, so a reader can go straight to
the reporting, court record, talk or primary writing behind it.
</p>
</article>
</div>
</div>
</section>
</>
Expand Down
28 changes: 28 additions & 0 deletions src/app/hacking-legends/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { hackers, getHacker, rarityLabel } from '@/data/hacking';
import { OG_CONTENT_TYPE, OG_SIZE, renderCardOg } from '@/lib/og';

export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export const alt = 'Hacking Legends trading card';

export function generateStaticParams() {
return hackers.filter((h) => h.front).map((h) => ({ slug: h.slug }));
}

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const h = getHacker(slug);
if (!h?.front) throw new Error(`No illustrated card for slug ${slug}`);

return renderCardOg({
number: h.number,
name: h.name,
handle: h.handle,
title: h.title,
rarity: h.rarity,
rarityLabel: rarityLabel[h.rarity],
impact: h.impact,
front: h.front,
setLabel: 'Hacking Legends',
});
}
Loading
Loading