Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
76 changes: 76 additions & 0 deletions src/app/[...parts]/_page/catalog/CatalogPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { cacheLife } from "next/cache";
import { Suspense } from "react";
import Skeleton from "^/components/ui/Skeleton";
import getVersionData from "^/lib/api/npm/getVersionData";
import packument from "^/lib/api/npm/packument";
import { generateComparisons } from "^/lib/utils/generateComparisons";
import { getCatalogPackageName } from "^/lib/utils/isCatalogPage";
import ComparisonList from "./ComparisonList";
import PackageMeta from "./PackageMeta";

export interface CatalogPageProps {
specs: string[];
}

async function CatalogPageInner({ specs }: CatalogPageProps) {
"use cache";

cacheLife("hours");

const packageName = getCatalogPackageName(specs);

if (!packageName) {
throw new Error("Invalid catalog page specs");
}

// Fetch package data
const [pack, versionMap] = await Promise.all([
packument(packageName),
getVersionData(packageName),
]);

// Get all versions
const versions = Object.keys(versionMap);

// Generate comparisons
const comparisons = generateComparisons(versions);

return (
<div className="mx-auto w-full max-w-7xl py-8">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[2fr,3fr]">
<div className="flex flex-col">
<PackageMeta packument={pack} />
</div>
<div className="flex flex-col">
<ComparisonList
packageName={packageName}
comparisons={comparisons}
/>
</div>
</div>
</div>
);
}

function CatalogPageFallback() {
return (
<div className="mx-auto w-full max-w-7xl py-8">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[2fr,3fr]">
<div className="flex flex-col">
<Skeleton className="h-96 w-full rounded-md" />
</div>
<div className="flex flex-col">
<Skeleton className="h-96 w-full rounded-md" />
</div>
</div>
</div>
);
}

export default function CatalogPage(props: CatalogPageProps) {
return (
<Suspense fallback={<CatalogPageFallback />}>
<CatalogPageInner {...props} />
</Suspense>
);
}
104 changes: 104 additions & 0 deletions src/app/[...parts]/_page/catalog/ComparisonList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Link from "next/link";
import BorderBox from "^/components/ui/BorderBox";
import Heading from "^/components/ui/Heading";
import Stack from "^/components/ui/Stack";
import type { Comparison } from "^/lib/utils/generateComparisons";
import specsToDiff from "^/lib/utils/specsToDiff";
import PackageNamePrefix from "./PackageNamePrefix";
import VersionWithHighlight from "./VersionWithHighlight";

export interface ComparisonListProps {
packageName: string;
comparisons: Comparison[];
}

/**
* Returns which part of the semver changed based on comparison type
*/
function getHighlightIndex(type: "major" | "minor" | "patch"): number {
switch (type) {
case "major":
return 0;
case "minor":
return 1;
case "patch":
return 2;
}
}

/**
* Threshold for omitting the package name in the "to" spec
* When package name is 24+ characters, we show "pkg@1.0.0...2.0.0" instead of "pkg@1.0.0...pkg@2.0.0"
*/
const PACKAGE_NAME_LENGTH_THRESHOLD = 24;

/**
* Right column showing the list of comparisons
*/
export default function ComparisonList({
packageName,
comparisons,
}: ComparisonListProps) {
const showSecondPackageName =
packageName.length < PACKAGE_NAME_LENGTH_THRESHOLD;

if (comparisons.length === 0) {
return (
<BorderBox>
<p className="text-sm text-muted-foreground">
No comparisons available for this package.
</p>
</BorderBox>
);
}

return (
<BorderBox className="flex flex-col gap-4">
<Heading h={2} className="text-2xl">
Suggested Diffs
</Heading>

<Stack direction="v" gap={2}>
{comparisons.map((comparison) => {
const highlightIndex = getHighlightIndex(comparison.type);
const fromSpec = `${packageName}@${comparison.from}`;
const toSpec = `${packageName}@${comparison.to}`;
const diffPath = specsToDiff([fromSpec, toSpec]);

return (
<Link
key={`${comparison.from}-${comparison.to}`}
href={`/${diffPath}`}
className="flex items-center justify-between rounded-md border border-input p-3 transition-colors hover:border-blue-500/50 hover:bg-blue-500/5"
prefetch={false}
aria-label={`Compare ${fromSpec}...${toSpec} (${comparison.type} version change)`}
>
<span className="font-mono">
<PackageNamePrefix packageName={packageName} />
<VersionWithHighlight
version={comparison.from}
highlightIndex={highlightIndex}
/>
<span className="text-muted-foreground">
...
</span>
{showSecondPackageName ? (
<PackageNamePrefix
packageName={packageName}
/>
) : null}
<VersionWithHighlight
version={comparison.to}
highlightIndex={highlightIndex}
/>
</span>
<span className="text-xs capitalize text-muted-foreground">
{comparison.type}
</span>
</Link>
);
})}
</Stack>
</BorderBox>
);
}
142 changes: 142 additions & 0 deletions src/app/[...parts]/_page/catalog/PackageMeta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import ClientDate from "^/components/ClientDate";
import ExternalLink from "^/components/ExternalLink";
import BorderBox from "^/components/ui/BorderBox";
import Heading from "^/components/ui/Heading";
import Stack from "^/components/ui/Stack";
import type { Packument } from "^/lib/api/npm/packument";

export interface PackageMetaProps {
packument: Packument;
}

/**
* Left column showing package metadata
*/
export default function PackageMeta({ packument }: PackageMetaProps) {
const latestVersion = packument["dist-tags"].latest;
const latestManifest = packument.versions[latestVersion];

if (!latestManifest) {
return null;
}

const latestTime = packument.time[latestVersion];
const npmUrl = `https://www.npmjs.com/package/${packument.name}`;
const repositoryUrl =
typeof latestManifest.repository === "string"
? latestManifest.repository
: latestManifest.repository?.url
?.replace(/^git\+/, "")
.replace(/\.git$/, "");
const homepageUrl = latestManifest.homepage;

// Calculate total versions
const totalVersions = Object.keys(packument.versions).length;

// Get keywords
const keywords = latestManifest.keywords;

return (
<BorderBox className="flex h-fit flex-col gap-4">
<Stack direction="v" gap={2}>
<Heading h={2} className="text-2xl">
{packument.name}
</Heading>
<div className="text-sm text-muted-foreground">
<span className="font-mono">{latestVersion}</span>
{latestTime ? (
<>
<span className="mx-2">•</span>
<ClientDate time={latestTime} />
</>
) : null}
</div>
</Stack>

{latestManifest.description ? (
<p className="text-sm text-muted-foreground">
{latestManifest.description}
</p>
) : null}

<Stack direction="v" gap={2}>
{latestManifest.license ? (
<div className="text-sm">
<span className="text-muted-foreground">License: </span>
<span>{latestManifest.license}</span>
</div>
) : null}

{latestManifest.author ? (
<div className="text-sm">
<span className="text-muted-foreground">Author: </span>
<span>
{typeof latestManifest.author === "string"
? latestManifest.author
: latestManifest.author.name}
</span>
</div>
) : null}

{totalVersions > 0 && (
<div className="text-sm">
<span className="text-muted-foreground">
Versions:{" "}
</span>
<span>{totalVersions}</span>
</div>
)}

{latestManifest.maintainers &&
latestManifest.maintainers.length > 0 ? (
<div className="text-sm">
<span className="text-muted-foreground">
Maintainers:{" "}
</span>
<span>{latestManifest.maintainers.length}</span>
</div>
) : null}
</Stack>

{keywords && keywords.length > 0 ? (
<div className="flex flex-wrap gap-2">
{keywords.slice(0, 10).map((keyword) => (
<span
key={keyword}
className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground"
>
{keyword}
</span>
))}
</div>
) : null}

<Stack direction="v" gap={2}>
<ExternalLink
href={npmUrl}
className="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
View on npm →
</ExternalLink>

{repositoryUrl ? (
<ExternalLink
href={repositoryUrl}
className="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
Repository →
</ExternalLink>
) : null}

{homepageUrl && homepageUrl !== repositoryUrl ? (
<ExternalLink
href={homepageUrl}
className="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
Homepage →
</ExternalLink>
) : null}
</Stack>
</BorderBox>
);
}
12 changes: 12 additions & 0 deletions src/app/[...parts]/_page/catalog/PackageNamePrefix.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Displays a faded package name prefix (e.g., "react@")
* Used in comparison displays to de-emphasize the package name
* and focus attention on the version number
*/
export default function PackageNamePrefix({
packageName,
}: {
packageName: string;
}) {
return <span className="text-muted-foreground">{packageName}@</span>;
}
38 changes: 38 additions & 0 deletions src/app/[...parts]/_page/catalog/VersionWithHighlight.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import semver from "semver";

export interface VersionWithHighlightProps {
version: string;
highlightIndex: number;
}

/**
* Highlights the specific semver digit that changed
*/
export default function VersionWithHighlight({
version,
highlightIndex,
}: VersionWithHighlightProps) {
const parsed = semver.parse(version);
if (!parsed) {
return <span className="font-mono">{version}</span>;
}

const parts = [parsed.major, parsed.minor, parsed.patch];

return (
<span className="font-mono">
{parts.map((part, index) => (
<span key={index}>
{index === highlightIndex ? (
<span className="rounded bg-blue-500/20 px-1 font-bold">
{part}
</span>
) : (
<span>{part}</span>
)}
{index < 2 && "."}
</span>
))}
</span>
);
}
Loading