Skip to content

Commit c7cff5b

Browse files
committed
refactor(ui): unify work cards, add github language colors, and optimize responsive layout
- Move and unify shared work cards (RepoCardItem, PullRequestCardItem, CommunityCardItem, ScoreCard) under global src/components/cards/ - Fix client/server barrel boundary separation in feature index exports to avoid leaking server-only dependencies (pg, redis, dns) into client bundles - Restructure Profile Top Work section into a responsive 1-column (mobile) and max 2-column (desktop) grid with balanced community card spanning - Enable text wrapping with break-words leading-snug for repository and PR titles to prevent awkward truncation - Add GitHub Linguist language color palette supporting 150+ programming languages with alias normalization and deterministic HSL fallback - Update LanguageBreakdown and SelectedLanguageRow components to render official GitHub color bars and indicators - Add comprehensive unit test suite for language colors (15 suites / 117 tests passing)
1 parent 59f069a commit c7cff5b

25 files changed

Lines changed: 827 additions & 605 deletions

File tree

‎scripts/calculate-next-country.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import "dotenv/config";
2121

2222
import { getDatabaseStore } from "@/lib/db";
23-
import { calculateLeaderboard } from "@/features/leaderboard";
23+
import { calculateLeaderboard } from "@/features/leaderboard/services";
2424
import { logger } from "@/lib/logger";
2525

2626
let activeCountrySlug: string | null = null;

‎src/app/api/compare/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
createComparisonInsights,
77
parseSelectedLanguagesFromSearchParams,
88
resolveLocale,
9-
} from "@/features/comparison";
9+
} from "@/features/comparison/services";
1010
import { toSafeApiError } from "@/lib/github";
1111
import type { ClientSafeError, SafeApiError } from "@/types/api";
1212

‎src/app/api/leaderboard/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextResponse } from "next/server";
2-
import { getLeaderboardResult } from "@/features/leaderboard";
2+
import { getLeaderboardResult } from "@/features/leaderboard/services";
33

44
export const runtime = "nodejs";
55

‎src/app/api/user/[username]/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextResponse } from "next/server";
2-
import { getUserProfile, UserFetchError } from "@/features/developer";
2+
import { getUserProfile, UserFetchError } from "@/features/developer/services";
33
import { normalizeSelectedLanguages } from "@/features/scoring";
44
import { toSafeApiError } from "@/lib/github";
55
import type { SafeApiError } from "@/types/api";

‎src/app/leaderboard/[country]/page.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Metadata } from "next";
22
import countriesData from "@/data/countries.json";
33
import { JsonLd } from "@/components/seo/json-ld";
4-
import { getLeaderboardResult, CountryLeaderboardClient } from "@/features/leaderboard";
4+
import { CountryLeaderboardClient } from "@/features/leaderboard";
5+
import { getLeaderboardResult } from "@/features/leaderboard/services";
56
import { toAbsoluteUrl } from "@/lib/seo";
67

78
type CountryInfo = {

‎src/app/user/[username]/page.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from "next";
22
import { JsonLd } from "@/components/seo/json-ld";
3-
import { UserProfileClient, UserNotFoundCard, getUserProfile } from "@/features/developer";
3+
import { UserProfileClient, UserNotFoundCard } from "@/features/developer";
4+
import { getUserProfile } from "@/features/developer/services";
45
import { AppHeader } from "@/components/layout/app-header";
56
import { AppFooter } from "@/components/layout/app-footer";
67
import { toAbsoluteUrl } from "@/lib/seo";
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"use client";
2+
3+
import { MessageSquare, Star } from "lucide-react";
4+
import { useTranslation } from "@/components/providers/language-provider";
5+
import type { UserResult } from "@/features/developer/types";
6+
import { StatChip } from "./work-card-helpers";
7+
8+
export type CommunityCardItemProps = {
9+
item: NonNullable<UserResult["topCommunityContributions"]>[number];
10+
rankIndex?: number;
11+
showRank?: boolean;
12+
className?: string;
13+
};
14+
15+
export function CommunityCardItem({
16+
item,
17+
rankIndex,
18+
showRank = true,
19+
className = "",
20+
}: CommunityCardItemProps) {
21+
const { t } = useTranslation();
22+
const itemTitle = item.title || t("untitled");
23+
24+
return (
25+
<article
26+
className={`rounded-xl border border-border/80 bg-gradient-to-br from-card via-card to-muted/25 p-4 transition-all duration-200 hover:border-primary/40 hover:shadow-sm ${className}`}
27+
>
28+
<div className="flex items-start justify-between gap-3 sm:gap-4">
29+
<div className="min-w-0 flex-1">
30+
<div className="flex items-center gap-2">
31+
{showRank && typeof rankIndex === "number" ? (
32+
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-bold text-muted-foreground">
33+
#{rankIndex + 1}
34+
</span>
35+
) : null}
36+
37+
<span className="inline-flex rounded-full bg-secondary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-secondary-foreground sm:text-[11px]">
38+
{item.type === "issue" ? t("community.issue") : t("community.discussion")}
39+
</span>
40+
</div>
41+
42+
{item.url ? (
43+
<a
44+
href={item.url}
45+
target="_blank"
46+
rel="noopener noreferrer"
47+
className="mt-1.5 block break-words font-semibold leading-snug text-primary hover:underline"
48+
aria-label={t("a11y.openCommunityContribution", {
49+
title: itemTitle,
50+
})}
51+
>
52+
{itemTitle}
53+
</a>
54+
) : (
55+
<p className="mt-1.5 break-words font-semibold leading-snug">{itemTitle}</p>
56+
)}
57+
58+
<p className="mt-1 break-words text-xs font-medium text-muted-foreground">
59+
{item.repo || t("unknown.repo")}
60+
</p>
61+
62+
<div className="mt-2.5 flex flex-wrap gap-1.5">
63+
<StatChip
64+
icon={<Star className="h-3 w-3" />}
65+
label={t("topwork.stars")}
66+
value={item.stars ?? 0}
67+
/>
68+
<StatChip
69+
icon={<MessageSquare className="h-3 w-3" />}
70+
label={t("community.comments")}
71+
value={item.comments ?? 0}
72+
/>
73+
</div>
74+
</div>
75+
76+
<div className="shrink-0 rounded-lg border border-primary/25 bg-primary/5 px-2.5 py-1.5 text-right sm:px-3 sm:py-2">
77+
<p className="text-lg font-bold text-primary sm:text-xl">{item.score ?? 0}</p>
78+
<p className="text-[10px] text-muted-foreground sm:text-[11px]">
79+
{t("comparsion.score")}
80+
</p>
81+
</div>
82+
</div>
83+
</article>
84+
);
85+
}

‎src/components/cards/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from "./work-card-helpers";
2+
export * from "./repo-card-item";
3+
export * from "./pr-card-item";
4+
export * from "./community-card-item";
5+
export * from "./score-card";
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"use client";
2+
3+
import { ArrowDown, ArrowUp, Star } from "lucide-react";
4+
import { useTranslation } from "@/components/providers/language-provider";
5+
import type { UserResult } from "@/features/developer/types";
6+
import {
7+
formatLanguageMatch,
8+
LanguageBreakdown,
9+
SelectedLanguageRow,
10+
StatChip,
11+
} from "./work-card-helpers";
12+
13+
export type PullRequestCardItemProps = {
14+
pr: UserResult["topPullRequests"][number];
15+
rankIndex?: number;
16+
selectedLanguages?: string[];
17+
showRank?: boolean;
18+
className?: string;
19+
};
20+
21+
export function PullRequestCardItem({
22+
pr,
23+
rankIndex,
24+
selectedLanguages,
25+
showRank = true,
26+
className = "",
27+
}: PullRequestCardItemProps) {
28+
const { t } = useTranslation();
29+
const prTitle = pr.title || t("untitled");
30+
const targetRepo = pr.repo || t("unknown.repo");
31+
32+
return (
33+
<article
34+
className={`rounded-xl border border-border/80 bg-gradient-to-br from-card via-card to-muted/25 p-4 transition-all duration-200 hover:border-primary/40 hover:shadow-sm ${className}`}
35+
>
36+
<div className="flex items-start justify-between gap-3 sm:gap-4">
37+
<div className="min-w-0 flex-1">
38+
<div className="flex items-start gap-2">
39+
{showRank && typeof rankIndex === "number" ? (
40+
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-bold text-muted-foreground">
41+
#{rankIndex + 1}
42+
</span>
43+
) : null}
44+
45+
{pr.url ? (
46+
<a
47+
href={pr.url}
48+
target="_blank"
49+
rel="noopener noreferrer"
50+
className="break-words font-semibold leading-snug text-primary hover:underline"
51+
aria-label={t("a11y.openPullRequest", { title: prTitle })}
52+
>
53+
{prTitle}
54+
</a>
55+
) : (
56+
<p className="break-words font-semibold leading-snug">{prTitle}</p>
57+
)}
58+
</div>
59+
60+
<p className="mt-1 break-words text-xs font-medium text-muted-foreground">
61+
{t("topwork.inRepo", { repo: targetRepo })}
62+
</p>
63+
64+
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
65+
<StatChip
66+
icon={<Star className="h-3 w-3" />}
67+
label={t("topwork.pr.repo.stars")}
68+
value={pr.stars ?? 0}
69+
/>
70+
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/80 bg-background/80 px-2.5 py-1 text-xs">
71+
<span className="inline-flex items-center gap-0.5 font-semibold text-emerald-600 dark:text-emerald-400">
72+
<ArrowUp className="h-3 w-3" />+{pr.additions ?? 0}
73+
</span>
74+
<span className="text-muted-foreground">/</span>
75+
<span className="inline-flex items-center gap-0.5 font-semibold text-rose-600 dark:text-rose-400">
76+
<ArrowDown className="h-3 w-3" />-{pr.deletions ?? 0}
77+
</span>
78+
</div>
79+
</div>
80+
81+
<LanguageBreakdown topLanguages={pr.topLanguages} />
82+
83+
{selectedLanguages && selectedLanguages.length > 0 ? (
84+
<SelectedLanguageRow
85+
topLanguages={pr.topLanguages}
86+
selectedLanguages={selectedLanguages}
87+
label={t("topwork.selectedLang")}
88+
/>
89+
) : null}
90+
91+
{typeof pr.languageMatch === "number" ? (
92+
<p className="mt-2 text-xs text-muted-foreground">
93+
{t("language.match")}: {formatLanguageMatch(pr.languageMatch)}
94+
</p>
95+
) : null}
96+
</div>
97+
98+
<div className="shrink-0 rounded-lg border border-primary/25 bg-primary/5 px-2.5 py-1.5 text-right sm:px-3 sm:py-2">
99+
<p className="text-lg font-bold text-primary sm:text-xl">{pr.score ?? 0}</p>
100+
<p className="text-[10px] text-muted-foreground sm:text-[11px]">
101+
{t("comparsion.score")}
102+
</p>
103+
</div>
104+
</div>
105+
</article>
106+
);
107+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"use client";
2+
3+
import { Eye, GitFork, Star } from "lucide-react";
4+
import { useTranslation } from "@/components/providers/language-provider";
5+
import type { UserResult } from "@/features/developer/types";
6+
import {
7+
formatLanguageMatch,
8+
LanguageBreakdown,
9+
SelectedLanguageRow,
10+
StatChip,
11+
} from "./work-card-helpers";
12+
13+
export type RepoCardItemProps = {
14+
repo: UserResult["topRepos"][number];
15+
rankIndex?: number;
16+
selectedLanguages?: string[];
17+
showRank?: boolean;
18+
className?: string;
19+
};
20+
21+
export function RepoCardItem({
22+
repo,
23+
rankIndex,
24+
selectedLanguages,
25+
showRank = true,
26+
className = "",
27+
}: RepoCardItemProps) {
28+
const { t } = useTranslation();
29+
const repoName = repo.name || t("untitled");
30+
31+
return (
32+
<article
33+
className={`rounded-xl border border-border/80 bg-gradient-to-br from-card via-card to-muted/25 p-4 transition-all duration-200 hover:border-primary/40 hover:shadow-sm ${className}`}
34+
>
35+
<div className="flex items-start justify-between gap-3 sm:gap-4">
36+
<div className="min-w-0 flex-1">
37+
<div className="flex items-start gap-2">
38+
{showRank && typeof rankIndex === "number" ? (
39+
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-bold text-muted-foreground">
40+
#{rankIndex + 1}
41+
</span>
42+
) : null}
43+
44+
{repo.url ? (
45+
<a
46+
href={repo.url}
47+
target="_blank"
48+
rel="noopener noreferrer"
49+
className="break-words font-semibold leading-snug text-primary hover:underline"
50+
aria-label={t("a11y.openRepo", { name: repoName })}
51+
>
52+
{repoName}
53+
</a>
54+
) : (
55+
<p className="break-words font-semibold leading-snug">{repoName}</p>
56+
)}
57+
</div>
58+
59+
<div className="mt-2.5 flex flex-wrap gap-1.5">
60+
<StatChip
61+
icon={<Star className="h-3 w-3" />}
62+
label={t("topwork.stars")}
63+
value={repo.stars ?? 0}
64+
/>
65+
<StatChip
66+
icon={<GitFork className="h-3 w-3" />}
67+
label={t("topwork.forks")}
68+
value={repo.forks ?? 0}
69+
/>
70+
<StatChip
71+
icon={<Eye className="h-3 w-3" />}
72+
label={t("topwork.watchers")}
73+
value={repo.watchers ?? 0}
74+
/>
75+
</div>
76+
77+
<LanguageBreakdown topLanguages={repo.topLanguages} />
78+
79+
{selectedLanguages && selectedLanguages.length > 0 ? (
80+
<SelectedLanguageRow
81+
topLanguages={repo.topLanguages}
82+
selectedLanguages={selectedLanguages}
83+
label={t("topwork.selectedLang")}
84+
/>
85+
) : null}
86+
87+
{typeof repo.languageMatch === "number" ? (
88+
<p className="mt-2 text-xs text-muted-foreground">
89+
{t("language.match")}: {formatLanguageMatch(repo.languageMatch)}
90+
</p>
91+
) : null}
92+
</div>
93+
94+
<div className="shrink-0 rounded-lg border border-primary/25 bg-primary/5 px-2.5 py-1.5 text-right sm:px-3 sm:py-2">
95+
<p className="text-lg font-bold text-primary sm:text-xl">{repo.score ?? 0}</p>
96+
<p className="text-[10px] text-muted-foreground sm:text-[11px]">
97+
{t("comparsion.score")}
98+
</p>
99+
</div>
100+
</div>
101+
</article>
102+
);
103+
}

0 commit comments

Comments
 (0)