-
Notifications
You must be signed in to change notification settings - Fork 0
feat(releases) phase C: frontend — Special Edition cards, list, detail #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| import { ImageResponse } from 'next/og'; | ||
| import { loadReleases, loadRelease } from '@/lib/releases-loader'; | ||
| import { releaseSlug } from '@/lib/urls'; | ||
|
|
||
| export const dynamic = 'force-static'; | ||
| export const size = { width: 1200, height: 630 }; | ||
| export const contentType = 'image/png'; | ||
| export const alt = 'gitpulse release edition'; | ||
|
|
||
| // Sentinel matching the page's stub when no releases exist yet. | ||
| const EMPTY_STUB_TAG = '__no_releases_yet__'; | ||
|
|
||
| // generateStaticParams returns raw segment values — Next.js encodes them | ||
| // when emitting the static filesystem path and decodes them back into the | ||
| // `params` object. Pre-encoding here would double-encode tags that contain | ||
| // special characters (e.g. 'release/v1.0.0'). | ||
| export function generateStaticParams() { | ||
| const releases = loadReleases(); | ||
| if (releases.length === 0) { | ||
| return [{ tag: EMPTY_STUB_TAG, slug: 'placeholder' }]; | ||
| } | ||
| return releases.map((r) => ({ | ||
| tag: r.tag, | ||
| slug: releaseSlug(r), | ||
| })); | ||
| } | ||
|
|
||
| export default async function OG({ | ||
| params, | ||
| }: { | ||
| params: Promise<{ tag: string; slug: string }>; | ||
| }) { | ||
| const { tag } = await params; | ||
| const release = tag === EMPTY_STUB_TAG ? null : loadRelease(tag); | ||
| if (!release) { | ||
| return new ImageResponse( | ||
| ( | ||
| <div | ||
| style={{ | ||
| width: '100%', | ||
| height: '100%', | ||
| display: 'flex', | ||
| backgroundColor: '#0d0d0c', | ||
| color: '#f0ede8', | ||
| fontFamily: 'serif', | ||
| alignItems: 'center', | ||
| justifyContent: 'center', | ||
| fontSize: 48, | ||
| }} | ||
| > | ||
| Not found | ||
| </div> | ||
| ), | ||
| size, | ||
| ); | ||
| } | ||
|
|
||
| const dateStr = new Date(release.publishedAt).toLocaleDateString('en-US', { | ||
| year: 'numeric', | ||
| month: 'long', | ||
| day: 'numeric', | ||
| }); | ||
|
|
||
| return new ImageResponse( | ||
| ( | ||
| <div | ||
| style={{ | ||
| width: '100%', | ||
| height: '100%', | ||
| display: 'flex', | ||
| flexDirection: 'column', | ||
| backgroundColor: '#0d0d0c', | ||
| color: '#f0ede8', | ||
| fontFamily: 'serif', | ||
| padding: '80px 100px', | ||
| justifyContent: 'space-between', | ||
| }} | ||
| > | ||
| <div | ||
| style={{ | ||
| display: 'flex', | ||
| justifyContent: 'space-between', | ||
| alignItems: 'center', | ||
| fontSize: 18, | ||
| letterSpacing: '0.2em', | ||
| color: '#8a8780', | ||
| textTransform: 'uppercase', | ||
| fontFamily: 'monospace', | ||
| }} | ||
| > | ||
| <div style={{ display: 'flex' }}>Gitpulse</div> | ||
| <div style={{ display: 'flex', gap: 24, color: '#b8860b' }}> | ||
| <span>{release.isPrerelease ? 'PRE-RELEASE' : 'RELEASE EDITION'}</span> | ||
| <span style={{ color: '#8a8780' }}>·</span> | ||
| <span style={{ color: '#8a8780' }}>{release.tag}</span> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}> | ||
| <div | ||
| style={{ | ||
| fontSize: 56, | ||
| lineHeight: 1.15, | ||
| fontStyle: 'italic', | ||
| letterSpacing: '-0.005em', | ||
| display: 'flex', | ||
| }} | ||
| > | ||
| “{clamp(release.quip || release.name || release.tag, 140)}” | ||
| </div> | ||
| <div | ||
| style={{ | ||
| width: 96, | ||
| height: 4, | ||
| backgroundColor: '#b8860b', | ||
| marginTop: 8, | ||
| }} | ||
| /> | ||
| </div> | ||
|
|
||
| <div | ||
| style={{ | ||
| fontSize: 18, | ||
| letterSpacing: '0.18em', | ||
| color: '#8a8780', | ||
| textTransform: 'uppercase', | ||
| fontFamily: 'monospace', | ||
| display: 'flex', | ||
| gap: 24, | ||
| }} | ||
| > | ||
| <span>{release.prCount} PRs</span> | ||
| <span>·</span> | ||
| <span>{release.contributorCount} contributors</span> | ||
| <span>·</span> | ||
| <span>{dateStr}</span> | ||
| </div> | ||
| </div> | ||
| ), | ||
| size, | ||
| ); | ||
| } | ||
|
|
||
| function clamp(s: string, n: number): string { | ||
| return s.length > n ? s.slice(0, n - 1) + '…' : s; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| // /releases/<tag>/<slug>/ — release detail. Lifted shape from gitsky's | ||
| // apps/web/app/[owner]/[repo]/releases/[tag]/page.tsx. Single-repo, | ||
| // no DB — reads from public/data/{releases,stories}. | ||
|
|
||
| import type { Metadata } from 'next'; | ||
| import { notFound } from 'next/navigation'; | ||
| import { loadReleases, loadRelease } from '@/lib/releases-loader'; | ||
| import { loadStories } from '@/lib/stories-loader'; | ||
| import type { Story } from '@/lib/stories'; | ||
| import type { Release } from '@/lib/releases'; | ||
| import { loadRepo } from '@/lib/repo'; | ||
| import { | ||
| buildReleaseMetadata, | ||
| canonicalUrl, | ||
| } from '@/lib/seo'; | ||
| import { JsonLd, buildReleaseJsonLd } from '@/lib/json-ld'; | ||
| import { releasePath, releaseSlug, releaseOgImagePath } from '@/lib/urls'; | ||
| import { ReleaseEditionHero } from '@/components/ReleaseEditionHero'; | ||
| import { ReleaseEditionStatBar } from '@/components/ReleaseEditionStatBar'; | ||
| import { ReleaseEditionTopStories } from '@/components/ReleaseEditionTopStories'; | ||
| import { ReleaseEditionChangelog } from '@/components/ReleaseEditionChangelog'; | ||
|
|
||
| interface RouteParams { | ||
| tag: string; | ||
| slug: string; | ||
| } | ||
|
|
||
| interface PageProps { | ||
| params: Promise<RouteParams>; | ||
| } | ||
|
|
||
| // Sentinel route emitted only when there are no releases yet. Next 16's | ||
| // `output: 'export'` errors out on an empty generateStaticParams[] — this | ||
| // stub generates one URL that 404s, keeping the route file valid. | ||
| const EMPTY_STUB_TAG = '__no_releases_yet__'; | ||
|
|
||
| // generateStaticParams returns raw segment values — Next.js encodes them | ||
| // when emitting the static filesystem path and decodes them back into the | ||
| // `params` object. Pre-encoding here would double-encode tags that contain | ||
| // special characters (e.g. 'release/v1.0.0'). | ||
| export function generateStaticParams(): RouteParams[] { | ||
| const releases = loadReleases(); | ||
| if (releases.length === 0) { | ||
| return [{ tag: EMPTY_STUB_TAG, slug: 'placeholder' }]; | ||
| } | ||
| return releases.map((r) => ({ | ||
| tag: r.tag, | ||
| slug: releaseSlug(r), | ||
| })); | ||
| } | ||
|
|
||
| export async function generateMetadata({ | ||
| params, | ||
| }: PageProps): Promise<Metadata> { | ||
| const { tag, slug } = await params; | ||
| if (tag === EMPTY_STUB_TAG) return {}; | ||
| const release = loadRelease(tag); | ||
| if (!release) return {}; | ||
| if (slug !== releaseSlug(release)) return {}; | ||
| return buildReleaseMetadata(release); | ||
| } | ||
|
|
||
| export default async function ReleaseDetailPage({ params }: PageProps) { | ||
| const { tag, slug } = await params; | ||
| if (tag === EMPTY_STUB_TAG) notFound(); | ||
| const release = loadRelease(tag); | ||
| if (!release) notFound(); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| // Reject alternate slugs that decode to the same release, so /releases/ | ||
| // <tag>/<wrong-slug>/ 404s instead of serving identical content under two | ||
| // canonical URLs. | ||
| if (slug !== releaseSlug(release)) notFound(); | ||
|
|
||
| const allStories = loadStories(); | ||
| const changelogStories = resolveChangelog(release, allStories); | ||
| const repo = loadRepo(); | ||
|
|
||
| const url = canonicalUrl(releasePath(release)); | ||
| const jsonLd = buildReleaseJsonLd({ | ||
| release, | ||
| canonicalUrl: url, | ||
| imageUrl: canonicalUrl(releaseOgImagePath(release)), | ||
| repo, | ||
| }); | ||
|
|
||
| return ( | ||
| <main className="min-h-screen bg-background"> | ||
| <JsonLd data={jsonLd} /> | ||
| <ReleaseEditionHero release={release} /> | ||
| {release.prCount > 0 && <ReleaseEditionStatBar release={release} />} | ||
| <div className="max-w-[1080px] mx-auto px-6 pb-20"> | ||
| <ReleaseEditionTopStories stories={release.topStories} /> | ||
| <ReleaseEditionChangelog stories={changelogStories} /> | ||
| </div> | ||
| </main> | ||
| ); | ||
| } | ||
|
|
||
| function resolveChangelog(release: Release, allStories: Story[]): Story[] { | ||
| const lookup = new Map(allStories.map((s) => [s.id, s])); | ||
| const out: Story[] = []; | ||
| for (const id of release.changelogStoryIds) { | ||
| const s = lookup.get(id); | ||
| if (s) out.push(s); | ||
| } | ||
| return out; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| // /releases/ — list page. Lifted from gitsky's | ||
| // apps/web/app/[owner]/[repo]/releases/page.tsx. Single-repo: no | ||
| // owner/repo params, no DB — reads from public/data/releases. | ||
|
|
||
| import type { Metadata } from 'next'; | ||
| import { loadReleases } from '@/lib/releases-loader'; | ||
| import { loadRepo, publicationName } from '@/lib/repo'; | ||
| import { | ||
| buildReleasesListMetadata, | ||
| canonicalUrl, | ||
| } from '@/lib/seo'; | ||
| import { JsonLd, buildReleasesListJsonLd } from '@/lib/json-ld'; | ||
| import { releasePath, releasesIndexPath } from '@/lib/urls'; | ||
| import { ReleasesListHero } from '@/components/ReleasesListHero'; | ||
| import { ReleasesListStandardCard } from '@/components/ReleasesListStandardCard'; | ||
| import { ReleasesListCompactRow } from '@/components/ReleasesListCompactRow'; | ||
|
|
||
| export function generateMetadata(): Metadata { | ||
| return buildReleasesListMetadata(); | ||
| } | ||
|
|
||
| export default function ReleasesIndexPage() { | ||
| const releases = loadReleases(); | ||
| const repo = loadRepo(); | ||
|
|
||
| if (releases.length === 0) { | ||
| return ( | ||
| <main className="min-h-screen bg-background"> | ||
| <div className="max-w-2xl mx-auto px-6 py-20 text-center"> | ||
| <div className="font-feed-mono text-[0.6875rem] uppercase tracking-[0.2em] text-feed-gold mb-4"> | ||
| Release Editions | ||
| </div> | ||
| <h1 className="font-feed-display text-3xl text-foreground mb-4"> | ||
| No editions yet | ||
| </h1> | ||
| <p className="font-feed-body text-foreground-secondary"> | ||
| Special editions appear here when you publish a release on GitHub. | ||
| </p> | ||
| </div> | ||
| </main> | ||
| ); | ||
| } | ||
|
|
||
| const [hero, ...rest] = releases; | ||
| const standard = rest.slice(0, 3); | ||
| const compact = rest.slice(3); | ||
|
|
||
| const listJsonLd = buildReleasesListJsonLd({ | ||
| releases, | ||
| canonicalUrl: canonicalUrl(releasesIndexPath()), | ||
| repo, | ||
| releaseUrl: (r) => canonicalUrl(releasePath(r)), | ||
| }); | ||
|
|
||
| return ( | ||
| <main className="min-h-screen bg-background"> | ||
| <JsonLd data={listJsonLd} /> | ||
| <div className="max-w-[1200px] mx-auto px-6 pt-10 pb-6 text-center"> | ||
| <p className="font-feed-mono text-[13px] tracking-wide text-muted mb-3"> | ||
| Release notes & changelog | ||
| </p> | ||
| <h1 className="font-feed-display text-4xl md:text-5xl text-foreground mb-2"> | ||
| {publicationName(repo)} Releases | ||
| </h1> | ||
| </div> | ||
| <div className="max-w-3xl mx-auto px-6"> | ||
| <EditionsDivider /> | ||
| {hero && <ReleasesListHero release={hero} />} | ||
| {standard.map((release) => ( | ||
| <ReleasesListStandardCard key={release.tag} release={release} /> | ||
| ))} | ||
| {compact.length > 0 && ( | ||
| <> | ||
| <EarlierDivider /> | ||
| <div className="grid grid-cols-1 md:grid-cols-2 gap-x-8"> | ||
| {compact.map((release) => ( | ||
| <ReleasesListCompactRow key={release.tag} release={release} /> | ||
| ))} | ||
| </div> | ||
| </> | ||
| )} | ||
| </div> | ||
| </main> | ||
| ); | ||
| } | ||
|
|
||
| function EditionsDivider() { | ||
| return ( | ||
| <div className="flex items-center gap-4 py-6"> | ||
| <div className="flex-1 h-px bg-gradient-to-r from-transparent via-feed-gold/40 to-transparent" /> | ||
| <span className="font-feed-mono text-[0.5625rem] font-semibold uppercase tracking-[0.25em] text-feed-gold flex items-center gap-2"> | ||
| <span className="w-1 h-1 bg-feed-gold rotate-45 opacity-50" /> | ||
| The Editions | ||
| <span className="w-1 h-1 bg-feed-gold rotate-45 opacity-50" /> | ||
| </span> | ||
| <div className="flex-1 h-px bg-gradient-to-r from-transparent via-feed-gold/40 to-transparent" /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function EarlierDivider() { | ||
| return ( | ||
| <div className="flex items-center gap-4 py-6"> | ||
| <div className="flex-1 h-px bg-gradient-to-r from-transparent via-feed-gold/40 to-transparent" /> | ||
| <span className="font-feed-display text-lg text-foreground whitespace-nowrap"> | ||
| Earlier Editions | ||
| </span> | ||
| <div className="flex-1 h-px bg-gradient-to-r from-transparent via-feed-gold/40 to-transparent" /> | ||
| </div> | ||
| ); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.