From 3c1e22790ed554801fee276266c3d2c3d2169776 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Thu, 14 May 2026 09:54:25 +0200 Subject: [PATCH] Add dashboard translations and preferences menu --- README.md | 5 + docs/translations.md | 119 +++++++++++ src/App.tsx | 198 ++++++++--------- src/components/AuthGate.tsx | 44 ++-- src/components/Footer.tsx | 8 +- src/components/SidebarControls.tsx | 45 ++-- src/components/TopBar.tsx | 114 ++++++++-- src/components/common/Pagination.tsx | 6 +- src/components/modals/WelcomeModal.tsx | 22 +- src/components/views/CIHealthView.tsx | 50 ++--- src/components/views/DailyDigestView.tsx | 53 ++--- src/components/views/InsightsView.tsx | 22 +- src/components/views/IssueList.tsx | 12 +- src/components/views/PullRequestList.tsx | 14 +- src/components/views/RepoGrid.tsx | 20 +- src/i18n/I18nProvider.tsx | 56 +++++ src/i18n/de.ts | 257 +++++++++++++++++++++++ src/i18n/en.ts | 255 ++++++++++++++++++++++ src/i18n/es.ts | 257 +++++++++++++++++++++++ src/i18n/fr.ts | 257 +++++++++++++++++++++++ src/i18n/it.ts | 257 +++++++++++++++++++++++ src/i18n/translations.ts | 9 + src/i18n/zh.ts | 257 +++++++++++++++++++++++ src/main.tsx | 9 +- src/styles.css | 1 + src/styles/navigation.css | 143 ++++++++++++- src/styles/preferences.css | 27 +++ src/utils/format.ts | 76 ++++++- src/utils/i18n.ts | 50 +++++ tests/utils/format.test.ts | 18 ++ tests/utils/i18n.test.ts | 50 +++++ 31 files changed, 2437 insertions(+), 274 deletions(-) create mode 100644 docs/translations.md create mode 100644 src/i18n/I18nProvider.tsx create mode 100644 src/i18n/de.ts create mode 100644 src/i18n/en.ts create mode 100644 src/i18n/es.ts create mode 100644 src/i18n/fr.ts create mode 100644 src/i18n/it.ts create mode 100644 src/i18n/translations.ts create mode 100644 src/i18n/zh.ts create mode 100644 src/styles/preferences.css create mode 100644 src/utils/i18n.ts create mode 100644 tests/utils/i18n.test.ts diff --git a/README.md b/README.md index 7215056..b600928 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,10 @@ In production both are served by the Node process: Vite builds the SPA into `dis | Tests | Vitest + jsdom | | Tooling | `concurrently`, `tsc` | +### Translations + +UI translations live in `src/i18n/`, with one dictionary file per language. See [docs/translations.md](docs/translations.md) for the workflow to edit text or add another language. + ## Prerequisites - **Node.js 20+** (anything that supports native `fetch` and ESM is fine). @@ -248,6 +252,7 @@ Tests live under `tests/` and mirror the structure of `src/` (see [AGENTS.md](AG │ ├── types/github.ts # Shared TypeScript types │ └── utils/ # Pure logic (covered by unit tests) ├── tests/ # Vitest suites mirroring src/ +├── docs/ # Contributor documentation ├── public/ # Static assets (demo media) ├── vite.config.ts ├── tsconfig.json # Frontend TS config diff --git a/docs/translations.md b/docs/translations.md new file mode 100644 index 0000000..d9a3a9e --- /dev/null +++ b/docs/translations.md @@ -0,0 +1,119 @@ +# Translations + +The dashboard uses small TypeScript dictionaries, one file per language. + +``` +src/i18n/ +├── en.ts # source keys and English text +├── it.ts # Italian +├── fr.ts # French +├── es.ts # Spanish +├── de.ts # German +├── zh.ts # Chinese +└── translations.ts # language registry +``` + +`en.ts` is the source of truth for translation keys. Other languages are typed as `Record`, so TypeScript fails if a language is missing a key. + +## Edit Existing Text + +1. Find the key in `src/i18n/en.ts`. +2. Update the same key in each language file that needs a wording change. +3. Keep placeholders unchanged. For example, if English contains `{count}` or `{time}`, every translation for that key must keep the same placeholder name. +4. Run: + +```bash +npm run typecheck +npm test +``` + +Example: + +```ts +"common.refresh": "Refresh", +``` + +can become: + +```ts +"common.refresh": "Reload", +``` + +Do not rename the key unless you also update every `t("...")` call that uses it. + +## Add a New Key + +1. Add the key to `src/i18n/en.ts`. +2. Add the same key to every other language file. +3. Use the key from React with `t("your.key")`. + +Example: + +```ts +// src/i18n/en.ts +"repo.lastSeen": "Last seen {time}", +``` + +```tsx +const { t } = useI18n(); + +return {t("repo.lastSeen", { time: "10m ago" })}; +``` + +Interpolation is intentionally simple: values are replaced by matching `{name}` tokens. + +## Add a New Language + +Use a lowercase language code as the file name. For example, Portuguese would use `pt.ts`. + +1. Copy `src/i18n/en.ts` to `src/i18n/pt.ts`. +2. Rename the export and add the type constraint: + +```ts +import type { en } from "./en"; + +export const pt: Record = { + "app.title": "GitHub Dashboard", + // ... +}; +``` + +3. Translate the values. Keep all keys and placeholders unchanged. +4. Register the language in `src/i18n/translations.ts`: + +```ts +import { pt } from "./pt"; + +export const translations = { en, it, fr, es, de, zh, pt } as const; +``` + +5. Add the language name to every dictionary: + +```ts +"language.pt": "Português", +``` + +6. If the language needs custom relative-time text, update `RELATIVE_TIME_LABELS` in `src/utils/format.ts`. +7. Add or update tests in: + +``` +tests/utils/i18n.test.ts +tests/utils/format.test.ts +``` + +8. Run: + +```bash +npm run typecheck +npm test +npm run build +``` + +## Review Checklist + +- The new file is listed in `src/i18n/translations.ts`. +- `npm run typecheck` passes. +- All placeholders match the English source. +- The language appears in the top-bar language switcher. +- Browser language detection works for the language code. +- Relative time is readable in list rows and repository cards. diff --git a/src/App.tsx b/src/App.tsx index 0619787..98920d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -70,9 +70,11 @@ import { clampPage } from "./utils/pagination"; import { formatNumber } from "./utils/format"; import { clearStatsCache, readStatsCache, writeStatsCache } from "./utils/statsCache"; import { clearFiltersCache, hydrateFilters, readFiltersCache, writeFiltersCache } from "./utils/filtersCache"; +import { useI18n } from "./i18n/I18nProvider"; type Tab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "ci" | "digests"; type Theme = "dark" | "light" | "auto"; +type TextSize = "small" | "normal" | "large"; const TAB_ROUTES: Record = { inbox: "/inbox", @@ -143,12 +145,6 @@ const defaultRepoFilters = (): RepoFilters => ({ includeArchived: false, }); -function themeIcon(theme: Theme) { - if (theme === "light") return ; - if (theme === "auto") return ; - return ; -} - function downloadJson(filename: string, rows: unknown[]) { const blob = new Blob([JSON.stringify(rows, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); @@ -162,6 +158,7 @@ function downloadJson(filename: string, rows: unknown[]) { type AuthState = "checking" | "anonymous" | "authenticated"; export function App() { + const { t } = useI18n(); const location = useLocation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -203,6 +200,7 @@ export function App() { const [inboxPageSize, setInboxPageSize] = useState(Number(localStorage.getItem("gh-dash.inboxPageSize")) || 20); const [inboxSearch, setInboxSearch] = useState(""); const [theme, setTheme] = useState(() => (localStorage.getItem("gh-dash.theme") as Theme) || "dark"); + const [textSize, setTextSize] = useState(() => (localStorage.getItem("gh-dash.textSize") as TextSize) || "normal"); const [issueFilters, setIssueFilters] = useState(() => cachedFiltersOnMount?.hydrated.issueFilters ?? defaultIssueFilters()); const [prFilters, setPrFilters] = useState(() => cachedFiltersOnMount?.hydrated.prFilters ?? defaultPrFilters()); const [repoFilters, setRepoFilters] = useState(() => cachedFiltersOnMount?.hydrated.repoFilters ?? defaultRepoFilters()); @@ -416,6 +414,11 @@ export function App() { localStorage.setItem("gh-dash.theme", theme); }, [theme]); + useEffect(() => { + document.documentElement.dataset.textSize = textSize; + localStorage.setItem("gh-dash.textSize", textSize); + }, [textSize]); + useEffect(() => { document.body.classList.toggle("tab-inbox", tab === "inbox"); document.body.classList.toggle("tab-issues", tab === "issues"); @@ -503,7 +506,7 @@ export function App() { const handleMarkAllRead = useCallback(async () => { if (!inboxUnreadCount) return; - if (!window.confirm(`Mark ${inboxUnreadCount} notification${inboxUnreadCount === 1 ? "" : "s"} as read on GitHub?`)) return; + if (!window.confirm(t("confirm.markAllRead", { count: inboxUnreadCount, plural: inboxUnreadCount === 1 ? "" : "s" }))) return; const previous = notifications; setNotifications((prev) => prev.map((entry) => ({ ...entry, unread: false }))); try { @@ -563,7 +566,7 @@ export function App() { const metricTotalCount = routeMetricKind === "stars" ? metricRepo?.stargazerCount : routeMetricKind === "forks" ? metricRepo?.forkCount : undefined; if (authState === "checking") { - return

Loading…

; + return

{t("common.loadingEllipsis")}

; } if (authState === "anonymous") { @@ -585,8 +588,14 @@ export function App() { : tab === "prs" ? prFilters.search : issueFilters.search; - const subtitle = `${issues.length} issues · ${pullRequests.length} PRs · ${repos.length} repos · ${owners.length} orgs${loading ? " · loading…" : ""}`; - const lastUpdated = fetchedAt ? `updated ${new Date(fetchedAt).toLocaleTimeString()}` : ""; + const subtitle = [ + t("summary.issues", { count: issues.length }), + t("summary.prs", { count: pullRequests.length }), + t("summary.repos", { count: repos.length }), + t("summary.orgs", { count: owners.length }), + ...(loading ? [t("summary.loading")] : []), + ].join(" · "); + const lastUpdated = fetchedAt ? t("common.updatedAt", { time: new Date(fetchedAt).toLocaleTimeString() }) : ""; function setSearch(value: string) { if (tab === "inbox") { @@ -641,14 +650,14 @@ export function App() { } const tabs = [ - { key: "inbox" as const, label: "Inbox", count: issues.length + pullRequests.length, icon: }, - { key: "repos" as const, label: "Repositories", count: repos.length, icon: }, - { key: "issues" as const, label: "Issues", count: issues.length, icon: }, - { key: "prs" as const, label: "Pull Requests", count: pullRequests.length, icon: }, - { key: "insights" as const, label: "Insights", count: filteredInsights.length, icon: }, - { key: "ci" as const, label: "CI", count: ciHealth.length, icon: }, - { key: "digests" as const, label: "Digest", count: dailyDigests.length, icon: }, - { key: "kanban" as const, label: "Board", count: "—", icon: }, + { key: "inbox" as const, label: t("tabs.inbox"), count: issues.length + pullRequests.length, icon: }, + { key: "repos" as const, label: t("tabs.repositories"), count: repos.length, icon: }, + { key: "issues" as const, label: t("tabs.issues"), count: issues.length, icon: }, + { key: "prs" as const, label: t("tabs.pullRequests"), count: pullRequests.length, icon: }, + { key: "insights" as const, label: t("tabs.insights"), count: filteredInsights.length, icon: }, + { key: "ci" as const, label: t("tabs.ci"), count: ciHealth.length, icon: }, + { key: "digests" as const, label: t("tabs.digest"), count: dailyDigests.length, icon: }, + { key: "kanban" as const, label: t("tabs.board"), count: "—", icon: }, ]; return ( @@ -657,11 +666,12 @@ export function App() { subtitle={subtitle} lastUpdated={lastUpdated} loading={loading} - themeLabel={theme[0].toUpperCase() + theme.slice(1)} - themeIcon={themeIcon(theme)} + theme={theme} + textSize={textSize} + onThemeChange={setTheme} + onTextSizeChange={setTextSize} authLogin={authLogin} owners={owners} - onThemeToggle={cycleTheme} onRefresh={() => loadData(true)} onOpenFilters={() => setFiltersOpen(true)} onOpenPalette={() => setPaletteOpen(true)} @@ -704,7 +714,7 @@ export function App() { {tab === "inbox" ? ( entry.key === mailbox)?.label || "Inbox"} + mailboxLabel={t(`mailbox.${mailbox}`)} search={inboxSearch} page={inboxPage} pageSize={inboxPageSize} @@ -720,25 +730,25 @@ export function App() { {tab === "issues" ? (
-
Open issues
{formatNumber(filteredIssues.length)}
matching filters
-
Repositories
{new Set(filteredIssues.map((issue) => issue.repository.nameWithOwner)).size}
with open issues
-
Organizations
{new Set(filteredIssues.map((issue) => issue.repository.nameWithOwner.split("/")[0])).size}
including personal
-
Stale ≥ 30d
{filteredIssues.filter((issue) => Date.now() - new Date(issue.updatedAt).getTime() > 30 * 86_400_000).length}
no recent activity
+
{t("stats.openIssues")}
{formatNumber(filteredIssues.length)}
{t("stats.matchingFilters")}
+
{t("stats.repositories")}
{new Set(filteredIssues.map((issue) => issue.repository.nameWithOwner)).size}
{t("stats.withOpenIssues")}
+
{t("stats.organizations")}
{new Set(filteredIssues.map((issue) => issue.repository.nameWithOwner.split("/")[0])).size}
{t("stats.includingPersonal")}
+
{t("stats.stale30")}
{filteredIssues.filter((issue) => Date.now() - new Date(issue.updatedAt).getTime() > 30 * 86_400_000).length}
{t("stats.noRecentActivity")}
- {visibleIssues.length} of {filteredIssues.length} shown + {visibleIssues.length} {t("common.of")} {filteredIssues.length} {t("common.shown")}
- + - +
{ setIssuePageSize(size); setIssuePage(1); }} /> @@ -748,41 +758,41 @@ export function App() { {tab === "prs" ? (
-
Open PRs
{formatNumber(filteredPullRequests.length)}
matching filters
-
Drafts
{formatNumber(draftCount)}
across all PRs
-
Awaiting review
{formatNumber(awaitingReviewCount)}
no review yet
-
Approved
{formatNumber(approvedCount)}
ready to merge
-
Stale ≥ 14d
{formatNumber(stalePrCount)}
no recent activity
+
{t("stats.openPrs")}
{formatNumber(filteredPullRequests.length)}
{t("stats.matchingFilters")}
+
{t("stats.drafts")}
{formatNumber(draftCount)}
{t("stats.acrossAllPrs")}
+
{t("stats.awaitingReview")}
{formatNumber(awaitingReviewCount)}
{t("stats.noReviewYet")}
+
{t("stats.approved")}
{formatNumber(approvedCount)}
{t("stats.readyToMerge")}
+
{t("stats.stale14")}
{formatNumber(stalePrCount)}
{t("stats.noRecentActivity")}
- {visiblePullRequests.length} of {filteredPullRequests.length} shown + {visiblePullRequests.length} {t("common.of")} {filteredPullRequests.length} {t("common.shown")}
- + - + - +
{ setPrPageSize(size); setPrPage(1); }} /> @@ -792,29 +802,29 @@ export function App() { {tab === "repos" ? (
-
Repositories
{formatNumber(filteredRepos.length)}
matching filters
-
Total stars
{formatNumber(filteredRepos.reduce((sum, repo) => sum + repo.stargazerCount, 0))}
across shown
-
Total forks
{formatNumber(filteredRepos.reduce((sum, repo) => sum + repo.forkCount, 0))}
across shown
-
Average health
{formatNumber(averageHealth)}
from repo signals
+
{t("stats.repositories")}
{formatNumber(filteredRepos.length)}
{t("stats.matchingFilters")}
+
{t("stats.totalStars")}
{formatNumber(filteredRepos.reduce((sum, repo) => sum + repo.stargazerCount, 0))}
{t("stats.acrossShown")}
+
{t("stats.totalForks")}
{formatNumber(filteredRepos.reduce((sum, repo) => sum + repo.forkCount, 0))}
{t("stats.acrossShown")}
+
{t("stats.averageHealth")}
{formatNumber(averageHealth)}
{t("stats.fromRepoSignals")}
- {visibleRepos.length} of {filteredRepos.length} shown + {visibleRepos.length} {t("common.of")} {filteredRepos.length} {t("common.shown")}
- + - +
-
Average health
{formatNumber(averageHealth)}
across tracked repos
-
Alert count
{formatNumber(totalAlerts)}
active risks detected
-
Repos with insights
{formatNumber(filteredInsights.length)}
alerts, opportunities or correlations
-
At-risk repos
{formatNumber(repoInsights.filter((insight) => insight.healthLabel === "risky").length)}
health score under 55
+
{t("stats.averageHealth")}
{formatNumber(averageHealth)}
{t("stats.acrossTrackedRepos")}
+
{t("stats.alertCount")}
{formatNumber(totalAlerts)}
{t("stats.activeRisksDetected")}
+
{t("stats.reposWithInsights")}
{formatNumber(filteredInsights.length)}
{t("stats.alertsOpportunitiesCorrelations")}
+
{t("stats.atRiskRepos")}
{formatNumber(repoInsights.filter((insight) => insight.healthLabel === "risky").length)}
{t("stats.healthScoreUnder55")}
@@ -852,10 +862,10 @@ export function App() { return (
-
Repos with CI
{formatNumber(ciHealth.length)}
recent workflow runs
-
Total runs
{formatNumber(totalRuns)}
last {ciHealth[0]?.totalRuns ?? 30} per repo
-
Avg success
{avgSuccessPct}%
across decided runs
-
Failing repos
{formatNumber(failingRepos)}
{formatNumber(totalFailures)} failures total
+
{t("stats.reposWithCi")}
{formatNumber(ciHealth.length)}
{t("stats.recentWorkflowRuns")}
+
{t("stats.totalRuns")}
{formatNumber(totalRuns)}
{t("stats.lastRunsPerRepo", { count: ciHealth[0]?.totalRuns ?? 30 })}
+
{t("stats.avgSuccess")}
{avgSuccessPct}%
{t("stats.acrossDecidedRuns")}
+
{t("stats.failingRepos")}
{formatNumber(failingRepos)}
{t("stats.failuresTotal", { count: formatNumber(totalFailures) })}
@@ -866,10 +876,10 @@ export function App() { {tab === "digests" ? (
-
{digestPeriod === "day" ? "Digest days" : digestPeriod === "week" ? "Digest weeks" : "Digest months"}
{formatNumber(dailyDigests.length)}
{digestPeriod === "day" ? "days with saved snapshots" : "periods aggregated"}
-
Latest issue delta
{dailyDigests[0] ? `${dailyDigests[0].issueDelta >= 0 ? "+" : ""}${formatNumber(dailyDigests[0].issueDelta)}` : "0"}
vs previous {digestPeriod === "day" ? "day" : digestPeriod}
-
Latest stars delta
{dailyDigests[0] ? `${dailyDigests[0].starsDelta >= 0 ? "+" : ""}${formatNumber(dailyDigests[0].starsDelta)}` : "0"}
vs previous {digestPeriod === "day" ? "day" : digestPeriod}
-
Latest stale delta
{dailyDigests[0] ? `${dailyDigests[0].staleIssueDelta >= 0 ? "+" : ""}${formatNumber(dailyDigests[0].staleIssueDelta)}` : "0"}
vs previous {digestPeriod === "day" ? "day" : digestPeriod}
+
{digestPeriod === "day" ? t("stats.digestDays") : digestPeriod === "week" ? t("stats.digestWeeks") : t("stats.digestMonths")}
{formatNumber(dailyDigests.length)}
{digestPeriod === "day" ? t("stats.daysWithSavedSnapshots") : t("stats.periodsAggregated")}
+
{t("stats.latestIssueDelta")}
{dailyDigests[0] ? `${dailyDigests[0].issueDelta >= 0 ? "+" : ""}${formatNumber(dailyDigests[0].issueDelta)}` : "0"}
{t("stats.vsPrevious", { period: digestPeriod === "day" ? t("period.day") : t(`period.${digestPeriod}`) })}
+
{t("stats.latestStarsDelta")}
{dailyDigests[0] ? `${dailyDigests[0].starsDelta >= 0 ? "+" : ""}${formatNumber(dailyDigests[0].starsDelta)}` : "0"}
{t("stats.vsPrevious", { period: digestPeriod === "day" ? t("period.day") : t(`period.${digestPeriod}`) })}
+
{t("stats.latestStaleDelta")}
{dailyDigests[0] ? `${dailyDigests[0].staleIssueDelta >= 0 ? "+" : ""}${formatNumber(dailyDigests[0].staleIssueDelta)}` : "0"}
{t("stats.vsPrevious", { period: digestPeriod === "day" ? t("period.day") : t(`period.${digestPeriod}`) })}
diff --git a/src/components/AuthGate.tsx b/src/components/AuthGate.tsx index 38094ba..220ca12 100644 --- a/src/components/AuthGate.tsx +++ b/src/components/AuthGate.tsx @@ -7,6 +7,7 @@ import { type DeviceFlowStart, } from "../api/github"; import appLogo from "../assets/app-logo-mark.svg"; +import { useI18n } from "../i18n/I18nProvider"; interface AuthGateProps { onAuthenticated: (login: string) => void; @@ -15,6 +16,7 @@ interface AuthGateProps { type Phase = "idle" | "starting" | "awaiting" | "verifying" | "success" | "error"; export function AuthGate({ onAuthenticated }: AuthGateProps) { + const { t } = useI18n(); const [status, setStatus] = useState(null); const [flow, setFlow] = useState(null); const [phase, setPhase] = useState("idle"); @@ -52,13 +54,13 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) { if (result.status === "expired") { stopPolling(); setPhase("error"); - setError("Device code expired. Please start again."); + setError(t("auth.expired")); return; } if (result.status === "denied") { stopPolling(); setPhase("error"); - setError("Access was denied."); + setError(t("auth.denied")); return; } if (result.status === "error") { @@ -110,33 +112,30 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) { GitHub Dashboard
-

Sign in with GitHub

+

{t("auth.signIn")}

- This dashboard reads your repositories and issues via the GitHub API. Authorize the app - to continue. + {t("auth.description")}

{externalMode ? (
{mode === "gh-cli" - ? "Authentication via gh CLI is not ready." - : "GITHUB_TOKEN is not available."} + ? t("auth.ghCliNotReady") + : t("auth.tokenMissing")}

{mode === "gh-cli" ? ( <> - The server is configured with GH_AUTH_MODE=gh-cli. Make sure the{" "} + {t("auth.ghCliHelp")}{" "} gh CLI - {" "}is installed and you are signed in:
gh auth login - {", "}then reload this page. + {", "}{t("auth.ghCliReload")} ) : ( <> - The server is configured with GH_AUTH_MODE=token. Export a personal - access token as GITHUB_TOKEN and restart the server. + {t("auth.tokenHelp")} )}

@@ -144,32 +143,29 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) {
) : clientMissing ? (
- GITHUB_CLIENT_ID is not set. + {t("auth.clientMissing")}

- Register an OAuth App at{" "} + {t("auth.clientHelp").split("github.com/settings/developers")[0]} github.com/settings/developers - , enable Device Flow, then export GITHUB_CLIENT_ID and restart the - server. Alternatively, set GH_AUTH_MODE=gh-cli to reuse your local - {" "}gh CLI session, or GH_AUTH_MODE=token with a - {" "}GITHUB_TOKEN. + {t("auth.clientHelp").split("github.com/settings/developers")[1]}

) : null} {!externalMode && (phase === "idle" || phase === "error") ? ( ) : null} - {phase === "starting" ?

Requesting device code…

: null} + {phase === "starting" ?

{t("auth.requestingCode")}

: null} {phase === "awaiting" && flow ? ( ) : null} {phase === "success" ? ( -

Authenticated. Loading dashboard…

+

{t("auth.success")}

) : null} {error ?

{error}

: null} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 59a43cc..5734cfe 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,4 +1,5 @@ import { APP_VERSION } from "../version"; +import { useI18n } from "../i18n/I18nProvider"; interface FooterProps { onContributorsClick: () => void; @@ -6,11 +7,12 @@ interface FooterProps { } export function Footer({ onContributorsClick, onChangelogClick }: FooterProps) { + const { t } = useI18n(); return (
diff --git a/src/components/SidebarControls.tsx b/src/components/SidebarControls.tsx index 6a438b4..13f530f 100644 --- a/src/components/SidebarControls.tsx +++ b/src/components/SidebarControls.tsx @@ -4,6 +4,7 @@ import { getLanguageColor } from "../utils/colors"; import { INBOX_MAILBOXES, type InboxMailbox } from "../utils/inbox"; import { formatNumber } from "../utils/format"; import { ChevronIcon, CloseIcon, SearchIcon } from "./common/Icons"; +import { useI18n } from "../i18n/I18nProvider"; type Tab = "inbox" | "issues" | "repos" | "kanban" | "insights" | "ci" | "digests" | "prs"; @@ -74,6 +75,7 @@ function CheckList({ showGhAvatar?: boolean; userLogin?: string; }) { + const { t } = useI18n(); // Original sort (checked first, then by count, then alphabetical) with one addition: // when userLogin is set, the user's personal account always sorts last (orgs first). const sorted = [...entries].sort((a, b) => { @@ -83,7 +85,7 @@ function CheckList({ } return Number(selected.has(b[0])) - Number(selected.has(a[0])) || countOf(b[1]) - countOf(a[1]) || a[0].localeCompare(b[0]); }); - if (!sorted.length) return
No matches
; + if (!sorted.length) return
{t("common.noMatches")}
; return (
@@ -134,6 +136,7 @@ export function SidebarControls({ authLogin, inbox, }: SidebarControlsProps) { + const { t } = useI18n(); const inboxMode = tab === "inbox"; const prMode = tab === "prs"; const issueMode = tab === "issues" || tab === "kanban"; @@ -151,14 +154,14 @@ export function SidebarControls({ return ( @@ -190,19 +193,19 @@ export function SidebarControls({ return (