diff --git a/scripts/calculate-next-country.ts b/scripts/calculate-next-country.ts index fd59e72..d3ca2eb 100644 --- a/scripts/calculate-next-country.ts +++ b/scripts/calculate-next-country.ts @@ -20,7 +20,7 @@ import "dotenv/config"; import { getDatabaseStore } from "@/lib/db"; -import { calculateLeaderboard } from "@/features/leaderboard"; +import { calculateLeaderboard } from "@/features/leaderboard/services"; import { logger } from "@/lib/logger"; let activeCountrySlug: string | null = null; diff --git a/src/app/api/compare/route.ts b/src/app/api/compare/route.ts index 3a08b75..b1c11cd 100644 --- a/src/app/api/compare/route.ts +++ b/src/app/api/compare/route.ts @@ -6,7 +6,7 @@ import { createComparisonInsights, parseSelectedLanguagesFromSearchParams, resolveLocale, -} from "@/features/comparison"; +} from "@/features/comparison/services"; import { toSafeApiError } from "@/lib/github"; import type { ClientSafeError, SafeApiError } from "@/types/api"; diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 122200c..4da370f 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getLeaderboardResult } from "@/features/leaderboard"; +import { getLeaderboardResult } from "@/features/leaderboard/services"; export const runtime = "nodejs"; diff --git a/src/app/api/user/[username]/route.ts b/src/app/api/user/[username]/route.ts index ea74df7..888dc6d 100644 --- a/src/app/api/user/[username]/route.ts +++ b/src/app/api/user/[username]/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getUserProfile, UserFetchError } from "@/features/developer"; +import { getUserProfile, UserFetchError } from "@/features/developer/services"; import { normalizeSelectedLanguages } from "@/features/scoring"; import { toSafeApiError } from "@/lib/github"; import type { SafeApiError } from "@/types/api"; diff --git a/src/app/leaderboard/[country]/page.tsx b/src/app/leaderboard/[country]/page.tsx index ef5561b..c6a191b 100644 --- a/src/app/leaderboard/[country]/page.tsx +++ b/src/app/leaderboard/[country]/page.tsx @@ -1,7 +1,8 @@ import type { Metadata } from "next"; import countriesData from "@/data/countries.json"; import { JsonLd } from "@/components/seo/json-ld"; -import { getLeaderboardResult, CountryLeaderboardClient } from "@/features/leaderboard"; +import { CountryLeaderboardClient } from "@/features/leaderboard"; +import { getLeaderboardResult } from "@/features/leaderboard/services"; import { toAbsoluteUrl } from "@/lib/seo"; type CountryInfo = { diff --git a/src/app/user/[username]/page.tsx b/src/app/user/[username]/page.tsx index 0ec9e99..b78f5b0 100644 --- a/src/app/user/[username]/page.tsx +++ b/src/app/user/[username]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { JsonLd } from "@/components/seo/json-ld"; -import { UserProfileClient, UserNotFoundCard, getUserProfile } from "@/features/developer"; +import { UserProfileClient, UserNotFoundCard } from "@/features/developer"; +import { getUserProfile } from "@/features/developer/services"; import { AppHeader } from "@/components/layout/app-header"; import { AppFooter } from "@/components/layout/app-footer"; import { toAbsoluteUrl } from "@/lib/seo"; diff --git a/src/components/cards/community-card-item.tsx b/src/components/cards/community-card-item.tsx new file mode 100644 index 0000000..fca8939 --- /dev/null +++ b/src/components/cards/community-card-item.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { MessageSquare, Star } from "lucide-react"; +import { useTranslation } from "@/components/providers/language-provider"; +import type { UserResult } from "@/features/developer/types"; +import { StatChip } from "./work-card-helpers"; + +export type CommunityCardItemProps = { + item: NonNullable[number]; + rankIndex?: number; + showRank?: boolean; + className?: string; +}; + +export function CommunityCardItem({ + item, + rankIndex, + showRank = true, + className = "", +}: CommunityCardItemProps) { + const { t } = useTranslation(); + const itemTitle = item.title || t("untitled"); + + return ( +
+
+
+
+ {showRank && typeof rankIndex === "number" ? ( + + #{rankIndex + 1} + + ) : null} + + + {item.type === "issue" ? t("community.issue") : t("community.discussion")} + +
+ + {item.url ? ( + + {itemTitle} + + ) : ( +

{itemTitle}

+ )} + +

+ {item.repo || t("unknown.repo")} +

+ +
+ } + label={t("topwork.stars")} + value={item.stars ?? 0} + /> + } + label={t("community.comments")} + value={item.comments ?? 0} + /> +
+
+ +
+

{item.score ?? 0}

+

+ {t("comparsion.score")} +

+
+
+
+ ); +} diff --git a/src/components/cards/index.ts b/src/components/cards/index.ts new file mode 100644 index 0000000..cc520f6 --- /dev/null +++ b/src/components/cards/index.ts @@ -0,0 +1,5 @@ +export * from "./work-card-helpers"; +export * from "./repo-card-item"; +export * from "./pr-card-item"; +export * from "./community-card-item"; +export * from "./score-card"; diff --git a/src/components/cards/pr-card-item.tsx b/src/components/cards/pr-card-item.tsx new file mode 100644 index 0000000..909a6cb --- /dev/null +++ b/src/components/cards/pr-card-item.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { ArrowDown, ArrowUp, Star } from "lucide-react"; +import { useTranslation } from "@/components/providers/language-provider"; +import type { UserResult } from "@/features/developer/types"; +import { + formatLanguageMatch, + LanguageBreakdown, + SelectedLanguageRow, + StatChip, +} from "./work-card-helpers"; + +export type PullRequestCardItemProps = { + pr: UserResult["topPullRequests"][number]; + rankIndex?: number; + selectedLanguages?: string[]; + showRank?: boolean; + className?: string; +}; + +export function PullRequestCardItem({ + pr, + rankIndex, + selectedLanguages, + showRank = true, + className = "", +}: PullRequestCardItemProps) { + const { t } = useTranslation(); + const prTitle = pr.title || t("untitled"); + const targetRepo = pr.repo || t("unknown.repo"); + + return ( +
+
+
+
+ {showRank && typeof rankIndex === "number" ? ( + + #{rankIndex + 1} + + ) : null} + + {pr.url ? ( + + {prTitle} + + ) : ( +

{prTitle}

+ )} +
+ +

+ {t("topwork.inRepo", { repo: targetRepo })} +

+ +
+ } + label={t("topwork.pr.repo.stars")} + value={pr.stars ?? 0} + /> +
+ + +{pr.additions ?? 0} + + / + + -{pr.deletions ?? 0} + +
+
+ + + + {selectedLanguages && selectedLanguages.length > 0 ? ( + + ) : null} + + {typeof pr.languageMatch === "number" ? ( +

+ {t("language.match")}: {formatLanguageMatch(pr.languageMatch)} +

+ ) : null} +
+ +
+

{pr.score ?? 0}

+

+ {t("comparsion.score")} +

+
+
+
+ ); +} diff --git a/src/components/cards/repo-card-item.tsx b/src/components/cards/repo-card-item.tsx new file mode 100644 index 0000000..04f65e6 --- /dev/null +++ b/src/components/cards/repo-card-item.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { Eye, GitFork, Star } from "lucide-react"; +import { useTranslation } from "@/components/providers/language-provider"; +import type { UserResult } from "@/features/developer/types"; +import { + formatLanguageMatch, + LanguageBreakdown, + SelectedLanguageRow, + StatChip, +} from "./work-card-helpers"; + +export type RepoCardItemProps = { + repo: UserResult["topRepos"][number]; + rankIndex?: number; + selectedLanguages?: string[]; + showRank?: boolean; + className?: string; +}; + +export function RepoCardItem({ + repo, + rankIndex, + selectedLanguages, + showRank = true, + className = "", +}: RepoCardItemProps) { + const { t } = useTranslation(); + const repoName = repo.name || t("untitled"); + + return ( +
+
+
+
+ {showRank && typeof rankIndex === "number" ? ( + + #{rankIndex + 1} + + ) : null} + + {repo.url ? ( + + {repoName} + + ) : ( +

{repoName}

+ )} +
+ +
+ } + label={t("topwork.stars")} + value={repo.stars ?? 0} + /> + } + label={t("topwork.forks")} + value={repo.forks ?? 0} + /> + } + label={t("topwork.watchers")} + value={repo.watchers ?? 0} + /> +
+ + + + {selectedLanguages && selectedLanguages.length > 0 ? ( + + ) : null} + + {typeof repo.languageMatch === "number" ? ( +

+ {t("language.match")}: {formatLanguageMatch(repo.languageMatch)} +

+ ) : null} +
+ +
+

{repo.score ?? 0}

+

+ {t("comparsion.score")} +

+
+
+
+ ); +} diff --git a/src/features/developer/components/score-card.tsx b/src/components/cards/score-card.tsx similarity index 97% rename from src/features/developer/components/score-card.tsx rename to src/components/cards/score-card.tsx index be972f7..2bf8927 100644 --- a/src/features/developer/components/score-card.tsx +++ b/src/components/cards/score-card.tsx @@ -1,8 +1,10 @@ +"use client"; + import { cn } from "@/utils/cn"; import { useTranslation } from "@/components/providers/language-provider"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -type ScoreCardProps = { +export type ScoreCardProps = { title: string; rawValue: number; normalizedValue?: number; diff --git a/src/components/cards/work-card-helpers.tsx b/src/components/cards/work-card-helpers.tsx new file mode 100644 index 0000000..e4edd8b --- /dev/null +++ b/src/components/cards/work-card-helpers.tsx @@ -0,0 +1,173 @@ +"use client"; + +import type { ReactNode } from "react"; +import type { UserResult } from "@/features/developer/types"; + +export type LanguageEntry = { + name: string; + percentage: number; +}; + +export type LanguageMeta = { + languageMatch?: number; + topLanguages?: LanguageEntry[]; +}; + +export function formatLanguageMatch(value?: number): string { + if (value === undefined) { + return "N/A"; + } + return `${Math.round(value * 100)}%`; +} + +import { getLanguageColor, normalizeLanguageName } from "@/lib/languages"; + +export { getLanguageColor, normalizeLanguageName }; + +export function StatChip({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value: number | string; +}) { + return ( +
+ {icon} + {label} + {value} +
+ ); +} + +export function LanguageBreakdown({ topLanguages }: { topLanguages?: LanguageEntry[] }) { + if (!topLanguages || topLanguages.length === 0) { + return null; + } + + const normalized = topLanguages.slice(0, 5).filter((language) => language.percentage > 0); + + if (normalized.length === 0) { + return null; + } + + return ( +
+
+ {normalized.map((language) => { + const color = getLanguageColor(language.name); + return ( +
+ ); + })} +
+
+ {normalized.map((language) => { + const color = getLanguageColor(language.name); + return ( + + + {language.name} + {language.percentage}% + + ); + })} +
+
+ ); +} + +export function SelectedLanguageRow({ + topLanguages, + selectedLanguages, + label, +}: { + topLanguages?: LanguageEntry[]; + selectedLanguages?: string[]; + label: string; +}) { + if ( + !topLanguages || + topLanguages.length === 0 || + !selectedLanguages || + selectedLanguages.length === 0 + ) { + return null; + } + + const languageMap = new Map(); + for (const language of topLanguages) { + languageMap.set(normalizeLanguageName(language.name), language.percentage); + } + + return ( +
+ {label}: + {selectedLanguages.map((language) => { + const percentage = languageMap.get(normalizeLanguageName(language)) ?? 0; + const color = getLanguageColor(language); + return ( + + + + {language} {percentage}% + + + ); + })} +
+ ); +} + +export function findRepoLanguageMeta( + user: UserResult, + repo: UserResult["topRepos"][number], +): LanguageMeta { + const languageRepos = user.languageScores?.topRepos ?? []; + const byUrl = repo.url + ? languageRepos.find((item) => item.url && item.url === repo.url) + : undefined; + const byName = languageRepos.find((item) => item.name === repo.name); + const match = byUrl ?? byName; + + return { + languageMatch: match?.languageMatch ?? repo.languageMatch, + topLanguages: match?.topLanguages ?? repo.topLanguages, + }; +} + +export function findPrLanguageMeta( + user: UserResult, + pr: UserResult["topPullRequests"][number], +): LanguageMeta { + const languagePrs = user.languageScores?.topPullRequests ?? []; + const byUrl = pr.url ? languagePrs.find((item) => item.url && item.url === pr.url) : undefined; + const byTitleAndRepo = languagePrs.find( + (item) => item.title === pr.title && item.repo === pr.repo, + ); + const match = byUrl ?? byTitleAndRepo; + + return { + languageMatch: match?.languageMatch ?? pr.languageMatch, + topLanguages: match?.topLanguages ?? pr.topLanguages, + }; +} diff --git a/src/components/index.ts b/src/components/index.ts index 6a5e8b8..92f76df 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,3 +2,4 @@ export * from "./ui"; export * from "./layout"; export * from "./providers"; export * from "./seo"; +export * from "./cards"; diff --git a/src/features/comparison/components/result-dashboard.tsx b/src/features/comparison/components/result-dashboard.tsx index f905281..4d8e05d 100644 --- a/src/features/comparison/components/result-dashboard.tsx +++ b/src/features/comparison/components/result-dashboard.tsx @@ -9,7 +9,7 @@ import { Avatar } from "@/components/layout/avatar"; import { ComparisonChart } from "./comparison-chart"; import { TopList } from "./top-list"; import { InsightsList } from "./insights-list"; -import { ScoreCard } from "@/features/developer/components/score-card"; +import { ScoreCard } from "@/components/cards"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { UserResult } from "@/features/developer"; diff --git a/src/features/comparison/components/top-list.tsx b/src/features/comparison/components/top-list.tsx index 7449049..56b8a19 100644 --- a/src/features/comparison/components/top-list.tsx +++ b/src/features/comparison/components/top-list.tsx @@ -1,20 +1,17 @@ "use client"; -import type { ReactNode } from "react"; import Link from "next/link"; import type { Route } from "next"; -import { - ArrowDown, - ArrowUp, - ExternalLink, - Eye, - GitFork, - GitPullRequest, - MessageSquare, - Star, -} from "lucide-react"; +import { ExternalLink, GitPullRequest, MessageSquare, Star } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import type { UserResult } from "@/features/developer"; +import { + RepoCardItem, + PullRequestCardItem, + CommunityCardItem, + findRepoLanguageMeta, + findPrLanguageMeta, +} from "@/components/cards"; import { useTranslation } from "@/components/providers/language-provider"; type Props = { @@ -22,166 +19,6 @@ type Props = { selectedLanguages?: string[]; }; -type LanguageEntry = { - name: string; - percentage: number; -}; - -type LanguageMeta = { - languageMatch?: number; - topLanguages?: LanguageEntry[]; -}; - -function formatLanguageMatch(value?: number): string { - if (value === undefined) { - return "N/A"; - } - return `${Math.round(value * 100)}%`; -} - -function normalizeLanguageName(value: string): string { - return value.trim().toLowerCase(); -} - -function getLanguageColor(name: string): string { - const normalized = normalizeLanguageName(name); - if (normalized === "typescript") return "bg-sky-500"; - if (normalized === "javascript") return "bg-amber-400"; - if (normalized === "python") return "bg-blue-500"; - if (normalized === "go") return "bg-cyan-500"; - if (normalized === "rust") return "bg-orange-500"; - if (normalized === "java") return "bg-red-500"; - if (normalized === "c#") return "bg-violet-500"; - if (normalized === "php") return "bg-indigo-500"; - if (normalized === "ruby") return "bg-rose-500"; - if (normalized === "swift") return "bg-orange-400"; - if (normalized === "kotlin") return "bg-fuchsia-500"; - if (normalized === "c++") return "bg-blue-700"; - return "bg-slate-500"; -} - -function StatChip({ icon, label, value }: { icon: ReactNode; label: string; value: number }) { - return ( -
- {icon} - {label} - {value} -
- ); -} - -function LanguageBreakdown({ topLanguages }: { topLanguages?: LanguageEntry[] }) { - if (!topLanguages || topLanguages.length === 0) { - return null; - } - - const normalized = topLanguages.slice(0, 5).filter((language) => language.percentage > 0); - - if (normalized.length === 0) { - return null; - } - - return ( -
-
- {normalized.map((language) => ( -
- ))} -
-
- {normalized.map((language) => ( - - - - {language.name} {language.percentage}% - - - ))} -
-
- ); -} - -function SelectedLanguageRow({ - topLanguages, - selectedLanguages, - label, -}: { - topLanguages?: LanguageEntry[]; - selectedLanguages: string[]; - label: string; -}) { - if (!topLanguages || topLanguages.length === 0 || selectedLanguages.length === 0) { - return null; - } - - const languageMap = new Map(); - for (const language of topLanguages) { - languageMap.set(normalizeLanguageName(language.name), language.percentage); - } - - return ( -
- {label}: - {selectedLanguages.map((language) => { - const percentage = languageMap.get(normalizeLanguageName(language)) ?? 0; - return ( - - {language} {percentage}% - - ); - })} -
- ); -} - -function findRepoLanguageMeta( - user: UserResult, - repo: UserResult["topRepos"][number], -): LanguageMeta { - const languageRepos = user.languageScores?.topRepos ?? []; - const byUrl = repo.url - ? languageRepos.find((item) => item.url && item.url === repo.url) - : undefined; - const byName = languageRepos.find((item) => item.name === repo.name); - const match = byUrl ?? byName; - - return { - languageMatch: match?.languageMatch ?? repo.languageMatch, - topLanguages: match?.topLanguages ?? repo.topLanguages, - }; -} - -function findPrLanguageMeta( - user: UserResult, - pr: UserResult["topPullRequests"][number], -): LanguageMeta { - const languagePrs = user.languageScores?.topPullRequests ?? []; - const byUrl = pr.url ? languagePrs.find((item) => item.url && item.url === pr.url) : undefined; - const byTitleAndRepo = languagePrs.find( - (item) => item.title === pr.title && item.repo === pr.repo, - ); - const match = byUrl ?? byTitleAndRepo; - - return { - languageMatch: match?.languageMatch ?? pr.languageMatch, - topLanguages: match?.topLanguages ?? pr.topLanguages, - }; -} - export function TopList({ userResults, selectedLanguages = [] }: Props) { const { t } = useTranslation(); @@ -222,70 +59,15 @@ export function TopList({ userResults, selectedLanguages = [] }: Props) { ) : ( user.topRepos.slice(0, 3).map((repo, index) => { const languageMeta = findRepoLanguageMeta(user, repo); + const enrichedRepo = { ...repo, ...languageMeta }; return ( -
-
-
- {repo.url ? ( - - {repo.name || t("untitled")} - - ) : ( -

{repo.name || t("untitled")}

- )} - -
- } - label={t("topwork.stars")} - value={repo.stars ?? 0} - /> - } - label={t("topwork.forks")} - value={repo.forks ?? 0} - /> - } - label={t("topwork.watchers")} - value={repo.watchers ?? 0} - /> -
- - - - - {typeof languageMeta.languageMatch === "number" ? ( -

- {t("language.match")}:{" "} - {formatLanguageMatch(languageMeta.languageMatch)} -

- ) : null} -
- -
-

{repo.score ?? 0}

-

- {t("comparsion.score")} -

-
-
-
+ repo={enrichedRepo} + rankIndex={index} + selectedLanguages={selectedLanguages} + showRank={true} + /> ); }) )} @@ -302,74 +84,15 @@ export function TopList({ userResults, selectedLanguages = [] }: Props) { ) : ( user.topPullRequests.slice(0, 3).map((pr, index) => { const languageMeta = findPrLanguageMeta(user, pr); + const enrichedPr = { ...pr, ...languageMeta }; return ( -
-
-
- {pr.url ? ( - - {pr.title || t("untitled")} - - ) : ( -

{pr.title || t("untitled")}

- )} - -

- {pr.repo || t("unknown.repo")} -

- -
- } - label={t("topwork.stars")} - value={pr.stars ?? 0} - /> - } - label={t("topwork.pr.additions")} - value={pr.additions ?? 0} - /> - } - label={t("topwork.pr.deletions")} - value={pr.deletions ?? 0} - /> -
- - - - - {typeof languageMeta.languageMatch === "number" ? ( -

- {t("language.match")}:{" "} - {formatLanguageMatch(languageMeta.languageMatch)} -

- ) : null} -
- -
-

{pr.score ?? 0}

-

- {t("comparsion.score")} -

-
-
-
+ pr={enrichedPr} + rankIndex={index} + selectedLanguages={selectedLanguages} + showRank={true} + /> ); }) )} @@ -383,60 +106,12 @@ export function TopList({ userResults, selectedLanguages = [] }: Props) { {user.topCommunityContributions && user.topCommunityContributions.length > 0 ? (
{user.topCommunityContributions.slice(0, 3).map((item, index) => ( -
-
-
- - {item.type === "issue" - ? t("community.issue") - : t("community.discussion")} - - - {item.url ? ( - - {item.title} - - ) : ( -

{item.title}

- )} - -

- {item.repo} -

- -
- } - label={t("topwork.stars")} - value={item.stars} - /> - } - label={t("community.comments")} - value={item.comments} - /> -
-
- -
-

{item.score}

-

- {t("comparsion.score")} -

-
-
-
+ item={item} + rankIndex={index} + showRank={true} + /> ))}
) : ( diff --git a/src/features/comparison/index.ts b/src/features/comparison/index.ts index 1271af8..c50b2a0 100644 --- a/src/features/comparison/index.ts +++ b/src/features/comparison/index.ts @@ -1,3 +1,3 @@ export * from "./types"; -export * from "./services"; export * from "./components"; +export * from "./services/compare-request"; diff --git a/src/features/developer/components/index.ts b/src/features/developer/components/index.ts index 4f2baea..c1b4030 100644 --- a/src/features/developer/components/index.ts +++ b/src/features/developer/components/index.ts @@ -1,4 +1,3 @@ export * from "./user-profile-client"; export * from "./user-profile-skeleton"; export * from "./user-not-found"; -export * from "./score-card"; diff --git a/src/features/developer/components/user-profile-client.tsx b/src/features/developer/components/user-profile-client.tsx index 7c9646a..f6eded4 100644 --- a/src/features/developer/components/user-profile-client.tsx +++ b/src/features/developer/components/user-profile-client.tsx @@ -9,7 +9,6 @@ import { Check, Copy, ExternalLink, - GitFork, GitPullRequest, MapPin, MessageSquare, @@ -19,10 +18,15 @@ import { Trophy, } from "lucide-react"; import { Avatar } from "@/components/layout/avatar"; -import { ScoreCard } from "./score-card"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; +import { + ScoreCard, + RepoCardItem, + PullRequestCardItem, + CommunityCardItem, +} from "@/components/cards"; import { useTranslation } from "@/components/providers/language-provider"; import { getCountryCode, detectCountry } from "@/lib/geo"; import countriesData from "@/data/countries.json"; @@ -41,69 +45,6 @@ type Props = { countryParam?: string | null; }; -type LanguageEntry = { - name: string; - percentage: number; -}; - -function getLanguageColor(name: string): string { - const normalized = name.trim().toLowerCase(); - if (normalized === "typescript") return "bg-sky-500"; - if (normalized === "javascript") return "bg-amber-400"; - if (normalized === "python") return "bg-blue-500"; - if (normalized === "go") return "bg-cyan-500"; - if (normalized === "rust") return "bg-orange-500"; - if (normalized === "java") return "bg-red-500"; - if (normalized === "c#") return "bg-violet-500"; - if (normalized === "php") return "bg-indigo-500"; - if (normalized === "ruby") return "bg-rose-500"; - if (normalized === "swift") return "bg-orange-400"; - if (normalized === "kotlin") return "bg-fuchsia-500"; - if (normalized === "c++") return "bg-blue-700"; - return "bg-slate-500"; -} - -function StatChip({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) { - return ( -
- {icon} - {label} - {value} -
- ); -} - -function LanguageBreakdown({ topLanguages }: { topLanguages?: LanguageEntry[] }) { - if (!topLanguages || topLanguages.length === 0) return null; - - const normalized = topLanguages.slice(0, 4).filter((lang) => lang.percentage > 0); - - if (normalized.length === 0) return null; - - return ( -
-
- {normalized.map((lang, idx) => ( -
- ))} -
-
- {normalized.map((lang, idx) => ( - - - {lang.name} {Math.round(lang.percentage * 100)}% - - ))} -
-
- ); -} - export function UserProfileClient({ user, location, countryParam }: Props) { const { t } = useTranslation(); const searchParams = useSearchParams(); @@ -451,7 +392,7 @@ export function UserProfileClient({ user, location, countryParam }: Props) {
-
+
{/* Top Repositories */} @@ -465,57 +406,16 @@ export function UserProfileClient({ user, location, countryParam }: Props) { {user.topRepos.length === 0 ? (

{t("empty.repos")}

) : ( - user.topRepos.slice(0, 3).map((repo, idx) => ( -
-
-
-
- - #{idx + 1} - - {repo.url ? ( - - {repo.name || t("untitled")} - - ) : ( -

{repo.name || t("untitled")}

- )} -
- -
- } - label={t("topwork.stars")} - value={repo.stars ?? 0} - /> - } - label={t("topwork.forks")} - value={repo.forks ?? 0} - /> -
- - -
- -
-

{repo.score ?? 0}

-

{t("comparsion.score")}

-
-
-
- )) + user.topRepos + .slice(0, 3) + .map((repo, idx) => ( + + )) )}
@@ -533,73 +433,22 @@ export function UserProfileClient({ user, location, countryParam }: Props) { {user.topPullRequests.length === 0 ? (

{t("empty.pullRequests")}

) : ( - user.topPullRequests.slice(0, 3).map((pr, idx) => ( -
-
-
-
- - #{idx + 1} - - {pr.url ? ( - - {pr.title || t("untitled")} - - ) : ( -

{pr.title || t("untitled")}

- )} -
- -

- {t("topwork.inRepo", { - repo: pr.repo || t("unknown.repo"), - })} -

- -
- } - label={t("topwork.pr.repo.stars")} - value={pr.stars ?? 0} - /> -
- - +{pr.additions ?? 0} - - / - - -{pr.deletions ?? 0} - -
-
- - -
- -
-

{pr.score ?? 0}

-

{t("comparsion.score")}

-
-
-
- )) + user.topPullRequests + .slice(0, 3) + .map((pr, idx) => ( + + )) )} {/* Top Community Contributions */} - + @@ -609,65 +458,16 @@ export function UserProfileClient({ user, location, countryParam }: Props) { {user.topCommunityContributions && user.topCommunityContributions.length > 0 ? ( - user.topCommunityContributions.slice(0, 3).map((item, idx) => ( -
-
-
-
- - #{idx + 1} - - - {item.type === "issue" - ? t("community.issue") - : t("community.discussion")} - -
- - {item.url ? ( - - {item.title} - - ) : ( -

{item.title}

- )} - -

- {item.repo} -

- -
- } - label={t("topwork.stars")} - value={item.stars} - /> - } - label={t("community.comments")} - value={item.comments} - /> -
-
- -
-

{item.score}

-

{t("comparsion.score")}

-
-
-
- )) + user.topCommunityContributions + .slice(0, 3) + .map((item, idx) => ( + + )) ) : (

{t("empty.community")}

)} diff --git a/src/features/developer/components/user-profile-skeleton.tsx b/src/features/developer/components/user-profile-skeleton.tsx index 1eec927..fe00123 100644 --- a/src/features/developer/components/user-profile-skeleton.tsx +++ b/src/features/developer/components/user-profile-skeleton.tsx @@ -47,7 +47,7 @@ export function UserProfileSkeleton() {
{/* Top Work Cards Skeleton */} -
+
@@ -74,7 +74,7 @@ export function UserProfileSkeleton() { - + diff --git a/src/features/developer/index.ts b/src/features/developer/index.ts index 1271af8..c22c164 100644 --- a/src/features/developer/index.ts +++ b/src/features/developer/index.ts @@ -1,3 +1,2 @@ export * from "./types"; -export * from "./services"; export * from "./components"; diff --git a/src/features/developer/tests/user.route.test.ts b/src/features/developer/tests/user.route.test.ts index 4e53253..b6c718d 100644 --- a/src/features/developer/tests/user.route.test.ts +++ b/src/features/developer/tests/user.route.test.ts @@ -27,7 +27,7 @@ vi.mock("@/lib/db", () => ({ })); import { GET } from "@/app/api/user/[username]/route"; -import { getUserProfile } from "@/features/developer"; +import { getUserProfile } from "@/features/developer/services"; function makeUser(login: string, name: string) { return { diff --git a/src/features/leaderboard/index.ts b/src/features/leaderboard/index.ts index 1271af8..c22c164 100644 --- a/src/features/leaderboard/index.ts +++ b/src/features/leaderboard/index.ts @@ -1,3 +1,2 @@ export * from "./types"; -export * from "./services"; export * from "./components"; diff --git a/src/lib/languages/github-colors.ts b/src/lib/languages/github-colors.ts new file mode 100644 index 0000000..0ccea00 --- /dev/null +++ b/src/lib/languages/github-colors.ts @@ -0,0 +1,226 @@ +/** + * GitHub Linguist canonical language colors mapping. + * Sourced from github-linguist/linguist/lib/linguist/languages.yml + */ +export const GITHUB_LANGUAGE_COLORS: Record = { + // Common Web & Application Languages + typescript: "#3178c6", + javascript: "#f1e05a", + python: "#3572A5", + java: "#b07219", + c: "#555555", + "c++": "#f34b7d", + "c#": "#178600", + go: "#00ADD8", + rust: "#dea584", + php: "#4F5D95", + ruby: "#701516", + swift: "#F05138", + kotlin: "#A97BFF", + dart: "#00B4AB", + html: "#e34c26", + css: "#563d7c", + scss: "#c6538c", + sass: "#a53b70", + less: "#1d365d", + vue: "#41b883", + svelte: "#ff3e00", + astro: "#ff5a03", + + // Shell & Scripting + shell: "#89e051", + bash: "#89e051", + zsh: "#89e051", + fish: "#4aae47", + powershell: "#012456", + batchfile: "#C1F12E", + batch: "#C1F12E", + awk: "#c30e9b", + sed: "#64b970", + lua: "#000080", + r: "#198CE7", + julia: "#a270ba", + perl: "#0298c3", + raku: "#0000fb", + applescript: "#101F1F", + autohotkey: "#6594b9", + autoit: "#1C3552", + + // JVM Languages + scala: "#c22d40", + groovy: "#4298b8", + clojure: "#db5855", + xtend: "#24255d", + + // Systems, Low-Level & Native + zig: "#ec915c", + nim: "#ffc200", + d: "#ba595e", + v: "#4f87c4", + crystal: "#000100", + carbon: "#222222", + assembly: "#6E4C13", + webassembly: "#04133b", + "objective-c": "#438eff", + "objective-c++": "#6866fb", + fortran: "#4d41b1", + pascal: "#E3F171", + ada: "#02f88c", + pony: "#4a8b7c", + + // Functional Languages + elixir: "#6e4a7e", + erlang: "#B83998", + haskell: "#5e5086", + ocaml: "#ef7a08", + "f#": "#b845fc", + elm: "#60B5CC", + purescript: "#1D222D", + rescript: "#ed5051", + reason: "#ff5847", + "common lisp": "#3fb68b", + "emacs lisp": "#c065db", + scheme: "#1e4aec", + racket: "#3c5caa", + coq: "#d0b68c", + agda: "#315665", + idris: "#b30000", + + // Modern & Emerging + gleam: "#ffaff3", + mojo: "#ff4b00", + cairo: "#ff4a2b", + move: "#4a90e2", + solidity: "#AA6746", + vyper: "#2980b9", + clarity: "#5546ff", + ballerina: "#ff5000", + vala: "#a56de2", + wren: "#383838", + ring: "#2D54CB", + red: "#f50000", + + // Data, Query, Database + sql: "#e38c00", + plpgsql: "#336790", + plsql: "#dad8d8", + tsql: "#e38c00", + graphql: "#e10098", + prisma: "#0c344b", + "protocol buffer": "#e75429", + protobuf: "#e75429", + matlab: "#e16737", + stan: "#b2011d", + + // DevOps, Infrastructure & Config + dockerfile: "#384d54", + makefile: "#427819", + cmake: "#DA3434", + meson: "#007800", + bazel: "#003990", + nix: "#7e7eff", + hcl: "#844fba", + terraform: "#844fba", + starlark: "#76d275", + jsonnet: "#0064b5", + yaml: "#cb171e", + json: "#292929", + toml: "#9c4221", + xml: "#0060ac", + ini: "#d1dbe0", + + // Template & UI + blade: "#f7523f", + jinja: "#b41717", + liquid: "#67b8de", + mustache: "#724b3b", + handlebars: "#f7931e", + ejs: "#a91e50", + pug: "#a86454", + haml: "#ece2a9", + + // Game Dev & Graphics + gdscript: "#355570", + hlsl: "#aace60", + glsl: "#5686a5", + wgsl: "#1a1a1a", + shaderlab: "#222c37", + + // Hardware & Embedded + verilog: "#b2b7f8", + systemverilog: "#DAE1C2", + vhdl: "#49f6eb", + opencl: "#ed2e2d", + + // Document & Text + markdown: "#083fa1", + tex: "#3D6117", + latex: "#3D6117", + typst: "#239dad", + "vim script": "#199f4b", + vim: "#199f4b", + "visual basic .net": "#945db7", + "visual basic": "#945db7", + vb: "#945db7", + "vb.net": "#945db7", + coffeescript: "#244776", + apex: "#1797c0", + qml: "#44a51c", + haxe: "#df7900", + hack: "#878787", + actionscript: "#882B0F", + coldfusion: "#ed2f00", + smalltalk: "#596706", +}; + +/** + * Normalizes language names for case-insensitive and symbol-friendly lookup. + */ +export function normalizeLanguageName(name: string): string { + return name.trim().toLowerCase(); +} + +/** + * Generates a consistent, deterministic HSL color for any language + * not explicitly included in the Linguist dictionary. + */ +export function hashStringToColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = Math.abs(hash) % 360; + return `hsl(${hue}, 65%, 48%)`; +} + +/** + * Returns the canonical GitHub color for a programming language, + * or a deterministic fallback color if unknown. + */ +export function getLanguageColor(name: string): string { + if (!name || !name.trim()) { + return "#8b949e"; + } + + const normalized = normalizeLanguageName(name); + + // Exact match + if (GITHUB_LANGUAGE_COLORS[normalized]) { + return GITHUB_LANGUAGE_COLORS[normalized]; + } + + // Common alias normalization + if (normalized === "js") return GITHUB_LANGUAGE_COLORS.javascript; + if (normalized === "ts") return GITHUB_LANGUAGE_COLORS.typescript; + if (normalized === "py") return GITHUB_LANGUAGE_COLORS.python; + if (normalized === "golang") return GITHUB_LANGUAGE_COLORS.go; + if (normalized === "rs") return GITHUB_LANGUAGE_COLORS.rust; + if (normalized === "rb") return GITHUB_LANGUAGE_COLORS.ruby; + if (normalized === "sh") return GITHUB_LANGUAGE_COLORS.shell; + if (normalized === "csharp" || normalized === "cs") return GITHUB_LANGUAGE_COLORS["c#"]; + if (normalized === "cpp" || normalized === "cplusplus") return GITHUB_LANGUAGE_COLORS["c++"]; + if (normalized === "fsharp" || normalized === "fs") return GITHUB_LANGUAGE_COLORS["f#"]; + + // Fallback to deterministic vibrant color + return hashStringToColor(normalized); +} diff --git a/src/lib/languages/index.ts b/src/lib/languages/index.ts new file mode 100644 index 0000000..d3c0294 --- /dev/null +++ b/src/lib/languages/index.ts @@ -0,0 +1 @@ +export * from "./github-colors"; diff --git a/src/lib/languages/tests/language-colors.test.ts b/src/lib/languages/tests/language-colors.test.ts new file mode 100644 index 0000000..1f5a84b --- /dev/null +++ b/src/lib/languages/tests/language-colors.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { getLanguageColor, GITHUB_LANGUAGE_COLORS } from "../github-colors"; + +describe("GitHub Language Colors", () => { + it("returns canonical GitHub colors for popular languages", () => { + expect(getLanguageColor("TypeScript")).toBe("#3178c6"); + expect(getLanguageColor("JavaScript")).toBe("#f1e05a"); + expect(getLanguageColor("Python")).toBe("#3572A5"); + expect(getLanguageColor("Go")).toBe("#00ADD8"); + expect(getLanguageColor("Rust")).toBe("#dea584"); + expect(getLanguageColor("C++")).toBe("#f34b7d"); + expect(getLanguageColor("C#")).toBe("#178600"); + expect(getLanguageColor("HTML")).toBe("#e34c26"); + expect(getLanguageColor("CSS")).toBe("#563d7c"); + expect(getLanguageColor("Vue")).toBe("#41b883"); + expect(getLanguageColor("Svelte")).toBe("#ff3e00"); + expect(getLanguageColor("Kotlin")).toBe("#A97BFF"); + expect(getLanguageColor("Swift")).toBe("#F05138"); + expect(getLanguageColor("Ruby")).toBe("#701516"); + expect(getLanguageColor("PHP")).toBe("#4F5D95"); + }); + + it("handles aliases and casing gracefully", () => { + expect(getLanguageColor("ts")).toBe(GITHUB_LANGUAGE_COLORS.typescript); + expect(getLanguageColor("js")).toBe(GITHUB_LANGUAGE_COLORS.javascript); + expect(getLanguageColor("py")).toBe(GITHUB_LANGUAGE_COLORS.python); + expect(getLanguageColor("golang")).toBe(GITHUB_LANGUAGE_COLORS.go); + expect(getLanguageColor("rs")).toBe(GITHUB_LANGUAGE_COLORS.rust); + expect(getLanguageColor("cpp")).toBe(GITHUB_LANGUAGE_COLORS["c++"]); + expect(getLanguageColor("csharp")).toBe(GITHUB_LANGUAGE_COLORS["c#"]); + expect(getLanguageColor(" TYPESCRIPT ")).toBe("#3178c6"); + }); + + it("produces deterministic fallback color for unknown languages", () => { + const unknownColor1 = getLanguageColor("CustomObscureLang"); + const unknownColor2 = getLanguageColor("customobscurelang"); + expect(unknownColor1).toMatch(/^hsl\(\d+,\s*65%,\s*48%\)$/); + expect(unknownColor1).toBe(unknownColor2); + }); + + it("handles empty or blank language strings", () => { + expect(getLanguageColor("")).toBe("#8b949e"); + expect(getLanguageColor(" ")).toBe("#8b949e"); + }); +});