From 04522a256620901a939aa674177d284e9c7e50c9 Mon Sep 17 00:00:00 2001 From: Osama Mabkhot <99215291+O2sa@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:03:55 +0300 Subject: [PATCH] feat: Add i18n Support (EN/AR) --- app/page.tsx | 6 +- app/providers.tsx | 7 +- components/breakdown-bars.tsx | 105 ++++++++++++++++++------------ components/compare-form.tsx | 18 +++--- components/comparison-chart.tsx | 20 +++--- components/comparison-table.tsx | 11 ++-- components/insights-list.tsx | 5 +- components/language-provider.tsx | 26 ++++++++ components/language-switcher.tsx | 23 +++++++ components/result-dashboard.tsx | 56 ++++++++-------- components/top-list.tsx | 43 ++++++------- lib/i18n.ts | 106 +++++++++++++++++++++++++++++++ locales/ar.json | 55 ++++++++++++++++ locales/en.json | 56 ++++++++++++++++ 14 files changed, 419 insertions(+), 118 deletions(-) create mode 100644 components/language-provider.tsx create mode 100644 components/language-switcher.tsx create mode 100644 lib/i18n.ts create mode 100644 locales/ar.json create mode 100644 locales/en.json diff --git a/app/page.tsx b/app/page.tsx index ed3a79e..4adc5f4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,6 +5,7 @@ import { CompareForm } from "../components/compare-form"; import { ResultDashboard } from "../components/result-dashboard"; import { DashboardSkeleton } from "../components/skeletons"; import { UserResult } from "@/types/user-result"; +import { LanguageSwitcher } from "@/components/language-switcher"; type ApiResponse = { success: boolean; @@ -74,7 +75,10 @@ export default function HomePage() { DevImpact - + +
+ +
diff --git a/app/providers.tsx b/app/providers.tsx index 9b467c1..48e0a87 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -1,11 +1,12 @@ "use client"; +import { LanguageProvider } from "@/components/language-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; export default function Providers({ children }: { children: React.ReactNode }) { return ( - - {children} - + + {children}{" "} + ); } diff --git a/components/breakdown-bars.tsx b/components/breakdown-bars.tsx index db00b6b..5c1c8c0 100644 --- a/components/breakdown-bars.tsx +++ b/components/breakdown-bars.tsx @@ -1,7 +1,13 @@ import { UserResult } from "@/types/user-result"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "./ui/card"; import { Progress } from "./ui/progress"; - +import { useTranslation } from "./language-provider"; type Props = { user1: UserResult; @@ -15,45 +21,64 @@ const items = [ ]; export function BreakdownBars({ user1, user2 }: Props) { - const getMaxScore = (score1: number, score2: number) => Math.max(score1, score2, 1) - + const getMaxScore = (score1: number, score2: number) => + Math.max(score1, score2, 1); + const { t,dir } = useTranslation(); return ( - - - Detailed Breakdown - Progress bars showing relative performance - - - {["repoScore", "prScore", "contributionScore"].map((metric) => { - const user1Value = user1[metric as keyof UserResult] as number - const user2Value = user2[metric as keyof UserResult] as number - const maxVal = getMaxScore(user1Value, user2Value) - const metricLabel = metric === "repoScore" ? "Repository Score" : metric === "prScore" ? "Pull Request Score" : "Contribution Score" - return ( -
-
- {metricLabel} - - {user1.username}: {user1Value} | {user2.username}: {user2Value} - -
-
-
- {user1.username} - - {user1Value} -
-
- {user2.username} - - {user2Value} -
-
-
- ) - })} -
-
+ + + {t('breakdown.title')} + + {t('breakdown.description')} + + + + {["repoScore", "prScore", "contributionScore"].map((metric) => { + const user1Value = user1[metric as keyof UserResult] as number; + const user2Value = user2[metric as keyof UserResult] as number; + const maxVal = getMaxScore(user1Value, user2Value); + const metricLabel = + metric === "repoScore" + ? "breakdown.repo" + : metric === "prScore" + ? "breakdown.pr" + : "breakdown.contribution"; + return ( +
+
+ {t(metricLabel)} + + {user1.username}: {user1Value} | {user2.username}:{" "} + {user2Value} + +
+
+
+ + {user1.username} + + + {user1Value} +
+
+ + {user2.username} + + + {user2Value} +
+
+
+ ); + })} +
+
); } diff --git a/components/compare-form.tsx b/components/compare-form.tsx index a07f593..512eb45 100644 --- a/components/compare-form.tsx +++ b/components/compare-form.tsx @@ -9,6 +9,7 @@ import { CardTitle, } from "./ui/card"; import { Alert, AlertDescription } from "./ui/alert"; +import { useTranslation } from "./language-provider"; type CompareFormProps = { data?: any; @@ -27,6 +28,7 @@ export function CompareForm({ reset, error, }: CompareFormProps) { + const { t } = useTranslation(); const [username1, setUsername1] = useState("pbiggar"); const [username2, setUsername2] = useState("CoralineAda"); @@ -54,22 +56,20 @@ export function CompareForm({
- Compare GitHub Developers - - Enter two GitHub usernames to compare their developer metrics - + {t("app.title")} + {t("app.subtitle")}
setUsername1(e.target.value)} /> setUsername2(e.target.value)} /> @@ -81,7 +81,7 @@ export function CompareForm({ disabled={!canSubmit} className="min-w-[140px] shadow-sm transition-transform hover:-translate-y-0.5" > - {loading ? "Comparing..." : "Compare"} + {loading ? t("form.compare.ing") : t("form.compare")} {data && ( <> @@ -89,14 +89,14 @@ export function CompareForm({ onClick={handleSwap} disabled={loading} type="button" - title={"Swap users"} + title={t("form.swap")} >
); @@ -71,46 +66,46 @@ export function TopList({ userResults }: Props) { - Top Work • {user.username} + {t('topwork.title')} • {user.username} - Most impactful repositories and pull requests + {t('topwork.desc')} {/* Top Repos */}

- Top Repositories + {t('topwork.toprepos')}

{user.topRepos.slice(0, 3).map((repo, i) => cardDetails({ key: `repo-${i}`, - title: repo.name || "Unknown Repository", + title: repo.name || t('untitled'), score: repo.score, badges: [ { icon: , label: repo.stars, - tooltip: `${repo.stars} stars`, + tooltip: `${repo.stars} ${t('topwork.stars')}`, }, { icon: , label: repo.forks, - tooltip: `${repo.forks} forks`, + tooltip: `${repo.forks} ${t('topwork.forks')}`, }, { icon: , label: repo.watchers, - tooltip: `${repo.watchers} watchers`, + tooltip: `${repo.watchers} ${t('topwork.watchers')}`, }, ], }), )} {user.topRepos.length === 0 && (

- No repository data + {t('topwork.norepos')}

)}
@@ -118,38 +113,38 @@ export function TopList({ userResults }: Props) {

- Top Pull Requests + {t('topwork.topprs')}

{user.topPullRequests.slice(0, 3).map((pr, i) => cardDetails({ key: `pr-${i}`, - title: pr.title || "Untitled Pull Request", + title: pr.title || t('untitled'), subtitle: `in ${pr.repo}`, score: pr.score, badges: [ { icon: , label: pr.stars, - tooltip: `${pr.stars} stars on the PR's repository`, + tooltip: `${pr.stars} ${t('topwork.pr.repo.stars')}`, }, - + { icon: , label: pr.additions || "0", - tooltip: `+${pr.additions || 0} additions`, + tooltip: `+${pr.additions || 0} ${t('topwork.pr.additions')}`, }, { icon: , label: pr.deletions || "0", - tooltip: `-${pr.deletions || 0} deletions`, + tooltip: `-${pr.deletions || 0} ${t('topwork.pr.deletions')}`, }, ], }), )} {user.topPullRequests.length === 0 && (

- No pull request data + {t('topwork.noPRs')}

)}
diff --git a/lib/i18n.ts b/lib/i18n.ts new file mode 100644 index 0000000..f6de237 --- /dev/null +++ b/lib/i18n.ts @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +type Messages = Record; + +export const supportedLocales = ["en", "ar"] as const; +export type Locale = (typeof supportedLocales)[number]; +const storageKey = "app-locale"; + +let enMessagesCache: Messages | null = null; + +const localeMeta: Record = { + en: { dir: "ltr", label: "English" }, + ar: { dir: "rtl", label: "\u0627\u0644\u0639\u0631\u0628\u064a\u0629" }, +}; + +async function loadMessages(locale: Locale): Promise { + switch (locale) { + case "ar": + return (await import("../locales/ar.json")).default; + case "en": + default: + return (await import("../locales/en.json")).default; + } +} + +function detectLocale(): Locale { + if (typeof window === "undefined") return "en"; + const stored = window.localStorage.getItem(storageKey) as Locale | null; + if (stored && supportedLocales.includes(stored)) return stored; + const nav = navigator.language?.split("-")?.[0]?.toLowerCase(); + if (nav && supportedLocales.includes(nav as Locale)) return nav as Locale; + return "en"; +} + +export function useI18nProvider() { + const [locale, setLocaleState] = useState("en"); + const [messages, setMessages] = useState(() => { + if (enMessagesCache) return enMessagesCache; + // eslint-disable-next-line @typescript-eslint/no-var-requires + enMessagesCache = require("../locales/en.json"); + return enMessagesCache as Messages; + }); + const [ready, setReady] = useState(true); + + const changeLocale = useCallback((next: Locale) => { + setReady(false); + loadMessages(next) + .then((m) => { + setMessages(m); + setLocaleState(next); + if (typeof window !== "undefined") { + window.localStorage.setItem(storageKey, next); + } + setReady(true); + }) + .catch((err) => { + console.warn("[i18n] failed to load locale, falling back to en", err); + if (enMessagesCache) setMessages(enMessagesCache); + setLocaleState("en"); + setReady(true); + }); + }, []); + + useEffect(() => { + if (typeof window === "undefined") return; + const detected = detectLocale(); + changeLocale(detected); + }, [changeLocale]); + + useEffect(() => { + if (typeof document === "undefined") return; + const meta = localeMeta[locale]; + document.documentElement.lang = locale; + document.documentElement.dir = meta.dir; + }, [locale]); + + const setLocale = useCallback( + (next: Locale) => { + changeLocale(next); + }, + [changeLocale] + ); + + const t = useCallback( + (key: string, params?: Record) => { + const template = messages[key] ?? key; + if (ready && !messages[key]) { + console.warn(`[i18n] Missing translation key "${key}" for locale ${locale}`); + } + if (!params) return template; + return Object.keys(params).reduce( + (acc, k) => acc.replace(`{${k}}`, String(params[k])), + template + ); + }, + [messages, locale, ready] + ); + + const dir = useMemo(() => localeMeta[locale]?.dir ?? "ltr", [locale]); + const locales = useMemo( + () => supportedLocales.map((lc) => ({ value: lc, label: localeMeta[lc].label })), + [] + ); + + return { locale, setLocale, t, dir, locales, ready }; +} diff --git a/locales/ar.json b/locales/ar.json new file mode 100644 index 0000000..6c8f967 --- /dev/null +++ b/locales/ar.json @@ -0,0 +1,55 @@ +{ + "app.subtitle": "قارن بين مطورين", + "app.title": "قارن بين مطوري الجيتهب (GitHub)", + "banner.leadby": "متقدم بـ", + "banner.metric": "المقاييس", + "banner.tie": "تعادل — كلا المطورين متساويان في الأداء.", + "banner.winner": "الفائز", + "barchart.desc": "تحليل بصري للمقاييس الرئيسية", + "barchart.title": "مقارنة الأدء", + "breakdown.contribution": "نقاط المساهمات (Contributions)", + "breakdown.description": "أشرطة تقدم تظهر الأداء النسبي", + "breakdown.pr": "نقاط طلبات الدمج (PRs)", + "breakdown.repo": "نقاط المشاريع (Repos)", + "breakdown.title": "مقارنة تفصيلية", + "comparsion.activity.score": "نقاط النشاط (Activity)", + "comparsion.contribution.diff": "فرق المساهمات", + "comparsion.contribution.score": "نقاط المساهمات (Contributions)", + "comparsion.diff": "فارق التقييم", + "comparsion.final.score": "النتيجة النهائية", + "comparsion.pr.diff": "فرق طلبات الدمج", + "comparsion.pr.score": "نقاط طلبات الدمج (PRs)", + "comparsion.repo.diff": "فرق المشاريع", + "comparsion.repo.score": "نقاط المشاريع (Repos)", + "comparsion.score": "التقييم", + "error.generic": "فشل في جلب البيانات", + "form.compare": "قارن", + "form.compare.ing": "جارٍ المقارنة...", + "form.enterTwo": "أدخل اسم مستخدمين من GitHub لمقارنة مقاييسهم كمطورين", + "form.reset": "إعادة تعيين", + "form.swap": "تبديل", + "form.username1": "اسم المستخدم 1 (مثال: torvalds)", + "form.username2": "اسم المستخدم 2 (مثال: octocat)", + "insights.equal.contribution": "كلا المطورين لديهم مستويات مساهمة متشابهة", + "insights.equal.pr": "كلا المطورين لديهم تأثير طلبات الدمج متساوي", + "insights.equal.repo": "كلا المطورين لديهم قوة مشاريع متساوية", + "insights.higher.contribution": "يُظهر نشاطاً أكثر في المساهمات", + "insights.pull.leads": "يتفوق في تأثير طلبات الدمج بـ", + "insights.stronger.contribution": "لديه مساهمات عامة أقوى بـ", + "insights.stronger.pr": "لديه تأثير أقوى في طلبات الدمج بـ", + "insights.stronger.repo": "لديه مشاريع أقوى بـ", + "insights.title": "أهم الملاحظات", + "topwork.desc": "المشاريع والإسهامات الأكثر تأثيراً", + "topwork.forks": "استنساخات", + "topwork.noPRs": "لم يتم العثور على طلبات دمج", + "topwork.noRepos": "لم يتم العثور على مشاريع", + "topwork.pr.additions": "الإضافات", + "topwork.pr.deletions": "الحذف", + "topwork.pr.repo.stars": "النجوم في مستودع الطلب", + "topwork.stars": "إعجابات", + "topwork.title": "أفضل الأعمال", + "topwork.topprs": "طلبات الدمج", + "topwork.toprepos": "المشاريع", + "topwork.watchers": "متابعون", + "untitled": "بدون عنوان" +} \ No newline at end of file diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..b279b22 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,56 @@ +{ + "app.subtitle": "Compare two developers", + "app.title": "Compare GitHub Developers", + "banner.leadby": "Lead by", + "banner.metric": "Metric", + "banner.tie": "It's a tie — both developers are evenly matched.", + "banner.winner": "Winner", + "barchart.desc": "Visual breakdown of key metrics", + "barchart.title": "Score Comparison", + "breakdown.contribution": "Contribution Score", + "breakdown.description": "Progress bars showing relative performance", + "breakdown.pr": "Pull Request Score", + "breakdown.repo": "Repository Score", + "breakdown.title": "Detailed Breakdown", + "comparsion.activity.score": "Activity Score", + "comparsion.contribution.diff": "Contribution diff", + "comparsion.contribution.score": "Contribution Score", + "comparsion.diff": "Score Difference", + "comparsion.final.score": "Final Score", + "comparsion.pr.diff": "PR diff", + "comparsion.pr.score": "PR Score", + "comparsion.repo.diff": "Repo diff", + "comparsion.repo.score": "Repo Score", + "comparsion.score": "Score", + "error.generic": "Failed to fetch", + "form.compare": "Compare", + "form.compare.ing": "Comparing...", + "form.enterTwo": "Enter two GitHub usernames to compare their developer metrics", + "form.reset": "Reset", + "form.swap": "Swap users", + "form.username1": "Username 1 (e.g., torvalds)", + "form.username2": "Username 2 (e.g., octocat)", + "insights.equal.contribution": "Both developers have similar contribution levels", + "insights.equal.pr": "Both developers have equal pull request impact", + "insights.equal.repo": "Both developers have equal repository strength", + "insights.higher.contribution": "shows higher contribution activity", + "insights.pull.leads": "leads in pull request impact", + "insights.stronger.contribution": "has stronger overall contribution with", + "insights.stronger.pr": "has stronger pull request impact with", + "insights.stronger.repo": "has stronger repository portfolio with", + "insights.title": "Key Insights", + "toggle.direction": "Toggle direction ({dir})", + "topwork.desc": "Most impactful repositories and pull requests", + "topwork.forks": "forks", + "topwork.noPRs": "No pull requests found", + "topwork.noRepos": "No repositories found", + "topwork.pr.additions": "additions", + "topwork.pr.deletions": "deletions", + "topwork.pr.repo.stars": "stars on the PR's repository", + "topwork.stars": "stars", + "topwork.title": "Top Work", + "topwork.topprs": "Top Pull Requests", + "topwork.toprepos": "Top Repositories", + "topwork.watchers": "watchers", + "untitled": "Untitled" +} \ No newline at end of file