Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions docs/translations.md
Original file line number Diff line number Diff line change
@@ -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<keyof typeof en, string>`, 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 <span>{t("repo.lastSeen", { time: "10m ago" })}</span>;
```

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<keyof typeof en, string> = {
"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.
198 changes: 104 additions & 94 deletions src/App.tsx

Large diffs are not rendered by default.

44 changes: 20 additions & 24 deletions src/components/AuthGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<AuthStatus | null>(null);
const [flow, setFlow] = useState<DeviceFlowStart | null>(null);
const [phase, setPhase] = useState<Phase>("idle");
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -110,82 +112,76 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) {
<span className="auth-logo"><img src={appLogo} alt="" /></span>
<span>GitHub Dashboard</span>
</div>
<h1>Sign in with GitHub</h1>
<h1>{t("auth.signIn")}</h1>
<p className="auth-sub">
This dashboard reads your repositories and issues via the GitHub API. Authorize the app
to continue.
{t("auth.description")}
</p>

{externalMode ? (
<div className="auth-error">
<strong>
{mode === "gh-cli"
? "Authentication via gh CLI is not ready."
: "GITHUB_TOKEN is not available."}
? t("auth.ghCliNotReady")
: t("auth.tokenMissing")}
</strong>
<p>
{mode === "gh-cli" ? (
<>
The server is configured with <code>GH_AUTH_MODE=gh-cli</code>. Make sure the{" "}
{t("auth.ghCliHelp")}{" "}
<a href="https://cli.github.com/" target="_blank" rel="noreferrer">gh CLI</a>
{" "}is installed and you are signed in:
<br />
<code>gh auth login</code>
{", "}then reload this page.
{", "}{t("auth.ghCliReload")}
</>
) : (
<>
The server is configured with <code>GH_AUTH_MODE=token</code>. Export a personal
access token as <code>GITHUB_TOKEN</code> and restart the server.
{t("auth.tokenHelp")}
</>
)}
</p>
{status?.detail ? <p><small>{status.detail}</small></p> : null}
</div>
) : clientMissing ? (
<div className="auth-error">
<strong>GITHUB_CLIENT_ID is not set.</strong>
<strong>{t("auth.clientMissing")}</strong>
<p>
Register an OAuth App at{" "}
{t("auth.clientHelp").split("github.com/settings/developers")[0]}
<a href="https://github.com/settings/developers" target="_blank" rel="noreferrer">
github.com/settings/developers
</a>
, enable Device Flow, then export <code>GITHUB_CLIENT_ID</code> and restart the
server. Alternatively, set <code>GH_AUTH_MODE=gh-cli</code> to reuse your local
{" "}<code>gh</code> CLI session, or <code>GH_AUTH_MODE=token</code> with a
{" "}<code>GITHUB_TOKEN</code>.
{t("auth.clientHelp").split("github.com/settings/developers")[1]}
</p>
</div>
) : null}

{!externalMode && (phase === "idle" || phase === "error") ? (
<button className="auth-primary" onClick={() => void start()} disabled={clientMissing}>
Continue with GitHub
{t("auth.continue")}
</button>
) : null}

{phase === "starting" ? <p className="auth-status">Requesting device code…</p> : null}
{phase === "starting" ? <p className="auth-status">{t("auth.requestingCode")}</p> : null}

{phase === "awaiting" && flow ? (
<div className="auth-flow">
<p className="auth-status">
Open the GitHub verification page and enter the code below.
{t("auth.openVerification")}
</p>
<a className="auth-link" href={flow.verificationUri} target="_blank" rel="noreferrer">
{flow.verificationUri}
</a>
<div className="auth-code-row">
<code className="auth-code">{flow.userCode}</code>
<button className="auth-secondary" onClick={() => void copyCode()}>
{copied ? "Copied" : "Copy"}
{copied ? t("auth.copied") : t("auth.copy")}
</button>
</div>
<p className="auth-hint">Waiting for authorization…</p>
<p className="auth-hint">{t("auth.waiting")}</p>
</div>
) : null}

{phase === "success" ? (
<p className="auth-status">Authenticated. Loading dashboard…</p>
<p className="auth-status">{t("auth.success")}</p>
) : null}

{error ? <p className="auth-error-line">{error}</p> : null}
Expand Down
8 changes: 5 additions & 3 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { APP_VERSION } from "../version";
import { useI18n } from "../i18n/I18nProvider";

interface FooterProps {
onContributorsClick: () => void;
onChangelogClick: () => void;
}

export function Footer({ onContributorsClick, onChangelogClick }: FooterProps) {
const { t } = useI18n();
return (
<footer className="app-footer">
<div className="app-footer-inner">
<span className="app-footer-credit">
Crafted by{" "}
{t("footer.craftedBy")}{" "}
<a href="https://github.com/debba" target="_blank" rel="noreferrer">debba</a>
<span className="app-footer-sep">·</span>
<span className="app-footer-version">v{APP_VERSION}</span>
Expand All @@ -26,11 +28,11 @@ export function Footer({ onContributorsClick, onChangelogClick }: FooterProps) {
</a>
<button className="app-footer-link" type="button" onClick={onContributorsClick}>
<UsersIcon />
<span>Contributors</span>
<span>{t("footer.contributors")}</span>
</button>
<button className="app-footer-link" type="button" onClick={onChangelogClick}>
<ChangelogIcon />
<span>Changelog</span>
<span>{t("footer.changelog")}</span>
</button>
</div>
</div>
Expand Down
Loading