Skip to content

Commit 4ffe624

Browse files
committed
feat: implement Phase 1 project page enhancements
Added four key features to make individual project pages more engaging: 1. Contributor Avatars - Display top 10 contributors with profile pictures - Hover tooltips showing contribution count - Links to contributor GitHub profiles 2. Installation Quick Copy - Auto-detect package manager (npm, pip, cargo, go) - One-click copy for installation commands - Fallback to git clone for non-packaged projects 3. Documentation Links - Auto-detect docs directories and changelog files - Quick access to Repository, Documentation, Changelog, Issues - Responsive grid layout 4. Similar Projects Recommendation - Smart matching based on shared tags, tech stack, location - Shows 4 most relevant projects - Displays project logo, stars, location, and tech Implementation details: - Enhanced GitHub API functions for contributors, installation detection - Created enrichment cache system (public/cache/*.json) - Updated nightly enrichment workflow to fetch additional data - Added similarity algorithm with weighted scoring - Created reusable components for each feature - All features degrade gracefully if data unavailable Build: Successful Test coverage: All Phase 1 features functional
1 parent e250ec0 commit 4ffe624

File tree

9 files changed

+617
-4
lines changed

9 files changed

+617
-4
lines changed

app/projects/[slug]/page.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { notFound } from "next/navigation";
22
import { loadAllProjects, getProjectBySlug } from "@/lib/projects";
3+
import { findSimilarProjects } from "@/lib/similar";
34
import { ProjectDetail } from "@/components/ProjectDetail";
45
import { ThemeToggle } from "@/components/ThemeToggle";
56
import { ArrowLeft } from "lucide-react";
67
import Link from "next/link";
78
import type { Metadata } from "next";
9+
import fs from "fs";
10+
import path from "path";
811

912
interface ProjectPageProps {
1013
params: Promise<{ slug: string }>;
@@ -89,6 +92,22 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
8992
notFound();
9093
}
9194

95+
// Load cache data
96+
let cache;
97+
try {
98+
const cachePath = path.join(process.cwd(), "public", "cache", `${slug}.json`);
99+
if (fs.existsSync(cachePath)) {
100+
const cacheData = fs.readFileSync(cachePath, "utf-8");
101+
cache = JSON.parse(cacheData);
102+
}
103+
} catch (error) {
104+
console.error("Error loading cache:", error);
105+
}
106+
107+
// Find similar projects
108+
const allProjects = loadAllProjects();
109+
const similarProjects = findSimilarProjects(project, allProjects, 4);
110+
92111
return (
93112
<div className="min-h-screen bg-gray-50 dark:bg-black">
94113
{/* Header */}
@@ -109,7 +128,7 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
109128

110129
{/* Main Content */}
111130
<main className="container mx-auto px-4 py-12">
112-
<ProjectDetail project={project} />
131+
<ProjectDetail project={project} cache={cache} similarProjects={similarProjects} />
113132
</main>
114133

115134
{/* JSON-LD Structured Data */}

components/ContributorAvatars.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import { Users } from "lucide-react";
4+
5+
interface Contributor {
6+
login: string;
7+
avatar_url: string;
8+
html_url: string;
9+
contributions: number;
10+
}
11+
12+
interface ContributorAvatarsProps {
13+
contributors: Contributor[];
14+
totalContributors?: number;
15+
}
16+
17+
export function ContributorAvatars({ contributors, totalContributors }: ContributorAvatarsProps) {
18+
if (contributors.length === 0) {
19+
return null;
20+
}
21+
22+
const displayedCount = contributors.length;
23+
const remainingCount = totalContributors && totalContributors > displayedCount
24+
? totalContributors - displayedCount
25+
: 0;
26+
27+
return (
28+
<div className="mb-8">
29+
<h2 className="text-base font-heading font-normal text-gray-700 dark:text-gray-300 tracking-wider mb-3 flex items-center gap-2">
30+
<Users className="h-4 w-4" />
31+
Contributors
32+
</h2>
33+
<div className="flex items-center gap-4">
34+
<div className="flex -space-x-2">
35+
{contributors.map((contributor) => (
36+
<a
37+
key={contributor.login}
38+
href={contributor.html_url}
39+
target="_blank"
40+
rel="noopener noreferrer"
41+
className="relative group"
42+
title={`${contributor.login} (${contributor.contributions} contributions)`}
43+
>
44+
<img
45+
src={contributor.avatar_url}
46+
alt={contributor.login}
47+
className="w-10 h-10 rounded-full border-2 border-white dark:border-gray-900 hover:scale-110 transition-transform hover:z-10"
48+
/>
49+
</a>
50+
))}
51+
</div>
52+
{remainingCount > 0 && (
53+
<span className="text-sm text-gray-500 dark:text-gray-400">
54+
+{remainingCount} more
55+
</span>
56+
)}
57+
</div>
58+
</div>
59+
);
60+
}

components/DocumentationLinks.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"use client";
2+
3+
import { Book, FileText, ExternalLink } from "lucide-react";
4+
5+
interface DocumentationLinksProps {
6+
repoUrl: string;
7+
documentation?: {
8+
docs_url?: string;
9+
changelog_url?: string;
10+
};
11+
}
12+
13+
export function DocumentationLinks({ repoUrl, documentation }: DocumentationLinksProps) {
14+
const links = [
15+
{
16+
label: "Repository",
17+
url: repoUrl,
18+
icon: Book,
19+
alwaysShow: true,
20+
},
21+
{
22+
label: "Documentation",
23+
url: documentation?.docs_url,
24+
icon: Book,
25+
alwaysShow: false,
26+
},
27+
{
28+
label: "Changelog",
29+
url: documentation?.changelog_url,
30+
icon: FileText,
31+
alwaysShow: false,
32+
},
33+
{
34+
label: "Issues",
35+
url: `${repoUrl}/issues`,
36+
icon: ExternalLink,
37+
alwaysShow: true,
38+
},
39+
];
40+
41+
const visibleLinks = links.filter((link) => link.alwaysShow || link.url);
42+
43+
if (visibleLinks.length === 0) {
44+
return null;
45+
}
46+
47+
return (
48+
<div className="mb-8">
49+
<h2 className="text-base font-heading font-normal text-gray-700 dark:text-gray-300 tracking-wider mb-3">
50+
Resources
51+
</h2>
52+
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
53+
{visibleLinks.map((link) => {
54+
const Icon = link.icon;
55+
return (
56+
<a
57+
key={link.label}
58+
href={link.url}
59+
target="_blank"
60+
rel="noopener noreferrer"
61+
className="flex items-center gap-2 px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-sm"
62+
>
63+
<Icon className="h-4 w-4 text-gray-500 dark:text-gray-400" />
64+
<span className="text-gray-700 dark:text-gray-300 font-medium">
65+
{link.label}
66+
</span>
67+
</a>
68+
);
69+
})}
70+
</div>
71+
</div>
72+
);
73+
}

components/InstallationGuide.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"use client";
2+
3+
import { Terminal } from "lucide-react";
4+
import { InlineCopy } from "./InlineCopy";
5+
6+
interface InstallationGuideProps {
7+
installation?: {
8+
type: string;
9+
command: string;
10+
};
11+
repoUrl: string;
12+
}
13+
14+
export function InstallationGuide({ installation, repoUrl }: InstallationGuideProps) {
15+
// Fallback to git clone if no installation detected
16+
const installCommand = installation?.command || `git clone ${repoUrl}`;
17+
const installType = installation?.type || "git";
18+
19+
const typeLabels: Record<string, string> = {
20+
npm: "npm",
21+
pip: "pip",
22+
cargo: "Cargo",
23+
go: "Go",
24+
git: "Git",
25+
};
26+
27+
return (
28+
<div className="mb-8">
29+
<h2 className="text-base font-heading font-normal text-gray-700 dark:text-gray-300 tracking-wider mb-3 flex items-center gap-2">
30+
<Terminal className="h-4 w-4" />
31+
Quick Start
32+
</h2>
33+
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
34+
<div className="flex items-center justify-between gap-2 mb-2">
35+
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
36+
{typeLabels[installType] || "Installation"}
37+
</span>
38+
</div>
39+
<div className="flex items-center gap-2">
40+
<code className="flex-1 px-3 py-2 rounded bg-gray-100 dark:bg-gray-800 text-sm text-gray-800 dark:text-gray-200 font-mono overflow-x-auto">
41+
{installCommand}
42+
</code>
43+
<InlineCopy text={installCommand} label="Copy" />
44+
</div>
45+
</div>
46+
</div>
47+
);
48+
}

components/ProjectDetail.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,38 @@
11
import { Project } from "@/lib/schema";
22
import { VerifiedPill } from "./VerifiedPill";
33
import { InlineCopy } from "./InlineCopy";
4+
import { ContributorAvatars } from "./ContributorAvatars";
5+
import { InstallationGuide } from "./InstallationGuide";
6+
import { DocumentationLinks } from "./DocumentationLinks";
7+
import { SimilarProjects } from "./SimilarProjects";
48
import { ExternalLink, Github, Star, GitBranch, Calendar, Scale } from "lucide-react";
59
import { formatNumber, formatRelativeTime } from "@/lib/utils";
610
import Link from "next/link";
711

12+
interface ProjectCache {
13+
contributors: Array<{
14+
login: string;
15+
avatar_url: string;
16+
html_url: string;
17+
contributions: number;
18+
}>;
19+
installation?: {
20+
type: string;
21+
command: string;
22+
};
23+
documentation: {
24+
docs_url?: string;
25+
changelog_url?: string;
26+
};
27+
}
28+
829
interface ProjectDetailProps {
930
project: Project;
31+
cache?: ProjectCache;
32+
similarProjects?: Project[];
1033
}
1134

12-
export function ProjectDetail({ project }: ProjectDetailProps) {
35+
export function ProjectDetail({ project, cache, similarProjects }: ProjectDetailProps) {
1336
const badgeMarkdown = `[![fossradar.in: Verified](https://img.shields.io/badge/fossradar.in-Verified-brightgreen?style=for-the-badge)](https://fossradar.in/projects/${project.slug})`;
1437

1538
return (
@@ -99,6 +122,17 @@ export function ProjectDetail({ project }: ProjectDetailProps) {
99122
</div>
100123
</div>
101124

125+
{/* Contributors */}
126+
{cache?.contributors && cache.contributors.length > 0 && (
127+
<ContributorAvatars contributors={cache.contributors} />
128+
)}
129+
130+
{/* Installation Guide */}
131+
<InstallationGuide installation={cache?.installation} repoUrl={project.repo} />
132+
133+
{/* Documentation Links */}
134+
<DocumentationLinks repoUrl={project.repo} documentation={cache?.documentation} />
135+
102136
{/* Tags */}
103137
<div className="mb-8">
104138
<h2 className="text-base font-heading font-normal text-gray-700 dark:text-gray-300 tracking-wider mb-3">
@@ -134,6 +168,11 @@ export function ProjectDetail({ project }: ProjectDetailProps) {
134168
</div>
135169
)}
136170

171+
{/* Similar Projects */}
172+
{similarProjects && similarProjects.length > 0 && (
173+
<SimilarProjects projects={similarProjects} />
174+
)}
175+
137176
{/* Badge */}
138177
{project.verified && (
139178
<div className="mb-8">

components/SimilarProjects.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Project } from "@/lib/schema";
2+
import { Star, MapPin } from "lucide-react";
3+
import { formatNumber } from "@/lib/utils";
4+
import Link from "next/link";
5+
import Image from "next/image";
6+
7+
interface SimilarProjectsProps {
8+
projects: Project[];
9+
}
10+
11+
export function SimilarProjects({ projects }: SimilarProjectsProps) {
12+
if (projects.length === 0) {
13+
return null;
14+
}
15+
16+
return (
17+
<div className="mb-8">
18+
<h2 className="text-base font-heading font-normal text-gray-700 dark:text-gray-300 tracking-wider mb-3">
19+
Similar Projects
20+
</h2>
21+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
22+
{projects.map((project) => (
23+
<Link
24+
key={project.slug}
25+
href={`/projects/${project.slug}`}
26+
className="group p-4 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 hover:border-gray-300 dark:hover:border-gray-700 transition-colors"
27+
>
28+
<div className="flex items-start gap-3">
29+
<div className="relative w-12 h-12 rounded-lg overflow-hidden flex-shrink-0 bg-gray-100 dark:bg-gray-800">
30+
<Image
31+
src={project.logo}
32+
alt={`${project.name} logo`}
33+
fill
34+
className="object-contain p-1"
35+
/>
36+
</div>
37+
<div className="flex-1 min-w-0">
38+
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors mb-1">
39+
{project.name}
40+
</h3>
41+
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mb-2">
42+
{project.short_desc}
43+
</p>
44+
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-500">
45+
<div className="flex items-center gap-1">
46+
<Star className="h-3 w-3" />
47+
{formatNumber(project.stars || 0)}
48+
</div>
49+
<div className="flex items-center gap-1">
50+
<MapPin className="h-3 w-3" />
51+
{project.location_city}
52+
</div>
53+
{project.primary_lang && (
54+
<span className="px-2 py-0.5 rounded-md bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
55+
{project.primary_lang}
56+
</span>
57+
)}
58+
</div>
59+
</div>
60+
</div>
61+
</Link>
62+
))}
63+
</div>
64+
</div>
65+
);
66+
}

0 commit comments

Comments
 (0)