Skip to content

Commit ca70221

Browse files
authored
Merge branch 'O2sa:main' into docs/add-screenshots-readme
2 parents 9770862 + 0501c5e commit ca70221

8 files changed

Lines changed: 207 additions & 76 deletions

File tree

.github/workflows/append_note_in_issue.yml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@ jobs:
2121
2222
// The custom note you want to append
2323
const note = [
24-
'> [!NOTE]',
25-
'> 🚀 **CONTRIBUTIONS ARE WELCOME!**',
26-
'> ',
27-
'> If you want to get this issue assigned to you, just comment **\'assign me\'**.',
28-
'> You will be assigned to the issue instantly via our GitHub Actions bot. Happy coding! ✨'
29-
].join('\n');
24+
'',
25+
'> [!TIP]',
26+
'> 🚀 **Want to contribute?**',
27+
'> Comment `assign me` to be automatically assigned to this issue via our GitHub Actions bot. Happy coding! ✨'
28+
].join('\n');
3029
3130
// Get the current issue body
3231
const currentBody = context.payload.issue.body || '';

app/layout.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import "./globals.css";
22
import type { ReactNode } from "react";
3+
import { cookies, headers } from "next/headers";
34
import Script from "next/script";
5+
import {
6+
DEFAULT_LOCALE,
7+
LOCALE_COOKIE,
8+
getLocaleDir,
9+
isSupportedLocale,
10+
parseAcceptLanguage,
11+
supportedLocales,
12+
} from "@/lib/i18n-core";
413
import Providers from "./providers";
514

615
const themeInitScript = `
@@ -31,14 +40,26 @@ export const metadata = {
3140
},
3241
};
3342

34-
export default function RootLayout({ children }: { children: ReactNode }) {
43+
export default async function RootLayout({ children }: { children: ReactNode }) {
44+
const cookieStore = await cookies();
45+
const headerStore = await headers();
46+
const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value;
47+
const initialLocale = isSupportedLocale(cookieLocale)
48+
? cookieLocale
49+
: parseAcceptLanguage(
50+
headerStore.get("accept-language"),
51+
supportedLocales,
52+
DEFAULT_LOCALE
53+
);
54+
const dir = getLocaleDir(initialLocale);
55+
3556
return (
36-
<html lang="en" suppressHydrationWarning>
57+
<html lang={initialLocale} dir={dir} suppressHydrationWarning>
3758
<body>
3859
<Script id="theme-init" strategy="beforeInteractive">
3960
{themeInitScript}
4061
</Script>
41-
<Providers>{children}</Providers>
62+
<Providers initialLocale={initialLocale}>{children}</Providers>
4263
</body>
4364
</html>
4465
);

app/providers.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
"use client";
22

33
import { LanguageProvider } from "@/components/language-provider";
4+
import type { Locale } from "@/lib/i18n-core";
45
import { TooltipProvider } from "@/components/ui/tooltip";
56
import { ThemeProvider } from "@/components/theme-provider";
67

7-
export default function Providers({ children }: { children: React.ReactNode }) {
8+
export default function Providers({
9+
children,
10+
initialLocale,
11+
}: {
12+
children: React.ReactNode;
13+
initialLocale: Locale;
14+
}) {
815
return (
916
<ThemeProvider>
10-
<LanguageProvider>
17+
<LanguageProvider initialLocale={initialLocale}>
1118
<TooltipProvider>{children}</TooltipProvider>
1219
</LanguageProvider>
1320
</ThemeProvider>

components/language-provider.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,14 @@ type I18nContextValue = {
1414

1515
const I18nContext = createContext<I18nContextValue | null>(null);
1616

17-
export function LanguageProvider({ children }: { children: React.ReactNode }) {
18-
const value = useI18nProvider();
17+
export function LanguageProvider({
18+
children,
19+
initialLocale,
20+
}: {
21+
children: React.ReactNode;
22+
initialLocale?: Locale;
23+
}) {
24+
const value = useI18nProvider(initialLocale);
1925
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
2026
}
2127

components/result-dashboard.tsx

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ export function ResultDashboard({ user1, user2 }: Props) {
3030
const loser = winner === user1 ? user2 : user1;
3131
const diffPct = winner
3232
? Math.round(
33-
((winner.finalScore - loser.finalScore) / loser.finalScore) * 100,
34-
)
33+
((winner.finalScore - loser.finalScore) / loser.finalScore) * 100,
34+
)
3535
: 0;
3636
const repoDiff =
3737
Math.max(user1.repoScore, user2.repoScore) -
@@ -99,28 +99,28 @@ export function ResultDashboard({ user1, user2 }: Props) {
9999
};
100100

101101
const handleCopy = async () => {
102-
const summary = {
103-
comparison: {
104-
[user1.username]: {
105-
finalScore: user1.finalScore,
106-
repoScore: user1.repoScore,
107-
prScore: user1.prScore,
108-
contributionScore: user1.contributionScore,
102+
try {
103+
const summary = {
104+
comparison: {
105+
user1,
106+
user2,
107+
winner: winner
108+
? {
109+
username: winner.username,
110+
name: winner.name || winner.username,
111+
finalScore: winner.finalScore,
112+
}
113+
: "tie",
114+
leadBy: winner ? `${diffPct}%` : "0%",
115+
insights: getInsights(),
109116
},
110-
[user2.username]: {
111-
finalScore: user2.finalScore,
112-
repoScore: user2.repoScore,
113-
prScore: user2.prScore,
114-
contributionScore: user2.contributionScore,
115-
},
116-
winner: winner?.username ?? "tie",
117-
leadBy: winner ? `${diffPct}%` : "0%",
118-
insights: getInsights(),
119-
},
120-
};
121-
await navigator.clipboard.writeText(JSON.stringify(summary, null, 2));
122-
setCopied(true);
123-
setTimeout(() => setCopied(false), 2000);
117+
};
118+
await navigator.clipboard.writeText(JSON.stringify(summary, null, 2));
119+
setCopied(true);
120+
setTimeout(() => setCopied(false), 2000);
121+
} catch (error) {
122+
console.error("Copy failed:", error);
123+
}
124124
};
125125

126126
return (

lib/i18n-core.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
export const supportedLocales = ["en", "ar"] as const;
2+
export type Locale = (typeof supportedLocales)[number];
3+
export const DEFAULT_LOCALE: Locale = "en";
4+
export const LOCALE_COOKIE = "app-locale";
5+
6+
export const localeMeta: Record<Locale, { dir: "ltr" | "rtl"; label: string }> = {
7+
en: { dir: "ltr", label: "English" },
8+
ar: { dir: "rtl", label: "\u0627\u0644\u0639\u0631\u0628\u064a\u0629" },
9+
};
10+
11+
export function isSupportedLocale(value: string | null | undefined): value is Locale {
12+
return supportedLocales.includes(value as Locale);
13+
}
14+
15+
export function parseAcceptLanguage<T extends string>(
16+
header: string | null | undefined,
17+
supported: readonly T[],
18+
fallback: T
19+
): T {
20+
if (!header) return fallback;
21+
22+
// Parse "lang;q=0.5" entries, drop q=0 (explicit rejection), and pick
23+
// the highest-q supported language. RFC 9110 §12.5.4: missing q
24+
// defaults to 1.0. A header like "en;q=0.1, ar;q=1" must select ar.
25+
const parsed: { tag: string; primary: string; q: number }[] = [];
26+
for (const part of header.split(",")) {
27+
const segments = part.trim().split(";");
28+
const tag = segments[0]?.toLowerCase().trim();
29+
if (!tag) continue;
30+
31+
let q = 1;
32+
for (const param of segments.slice(1)) {
33+
const [key, value] = param.split("=").map((s) => s.trim().toLowerCase());
34+
if (key === "q") {
35+
const num = Number(value);
36+
if (!Number.isNaN(num)) q = num;
37+
}
38+
}
39+
40+
if (q <= 0) continue;
41+
parsed.push({ tag, primary: tag.split("-")[0], q });
42+
}
43+
44+
parsed.sort((a, b) => b.q - a.q);
45+
46+
for (const entry of parsed) {
47+
const match = supported.find((locale) => {
48+
const normalized = locale.toLowerCase();
49+
return normalized === entry.tag || normalized === entry.primary;
50+
});
51+
if (match) return match;
52+
}
53+
54+
return fallback;
55+
}
56+
57+
export function getLocaleDir(locale: Locale) {
58+
return localeMeta[locale].dir;
59+
}

lib/i18n.ts

Lines changed: 43 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,47 @@
1-
import { useCallback, useEffect, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
2+
import arMessages from "../locales/ar.json";
3+
import enMessages from "../locales/en.json";
4+
import {
5+
DEFAULT_LOCALE,
6+
LOCALE_COOKIE,
7+
isSupportedLocale,
8+
localeMeta,
9+
supportedLocales,
10+
type Locale,
11+
} from "./i18n-core";
212

3-
type Messages = Record<string, string>;
4-
5-
export const supportedLocales = ["en", "ar"] as const;
6-
export type Locale = (typeof supportedLocales)[number];
7-
const storageKey = "app-locale";
13+
export {
14+
DEFAULT_LOCALE,
15+
LOCALE_COOKIE,
16+
getLocaleDir,
17+
isSupportedLocale,
18+
parseAcceptLanguage,
19+
supportedLocales,
20+
type Locale,
21+
} from "./i18n-core";
822

9-
let enMessagesCache: Messages | null = null;
23+
type Messages = Record<string, string>;
1024

11-
const localeMeta: Record<Locale, { dir: "ltr" | "rtl"; label: string }> = {
12-
en: { dir: "ltr", label: "English" },
13-
ar: { dir: "rtl", label: "\u0627\u0644\u0639\u0631\u0628\u064a\u0629" },
25+
const cookieMaxAge = 60 * 60 * 24 * 365;
26+
const messagesByLocale: Record<Locale, Messages> = {
27+
en: enMessages,
28+
ar: arMessages,
1429
};
1530

1631
async function loadMessages(locale: Locale): Promise<Messages> {
17-
switch (locale) {
18-
case "ar":
19-
return (await import("../locales/ar.json")).default;
20-
case "en":
21-
default:
22-
return (await import("../locales/en.json")).default;
23-
}
32+
return messagesByLocale[locale];
2433
}
2534

26-
function detectLocale(): Locale {
27-
if (typeof window === "undefined") return "en";
28-
const stored = window.localStorage.getItem(storageKey) as Locale | null;
29-
if (stored && supportedLocales.includes(stored)) return stored;
30-
const nav = navigator.language?.split("-")?.[0]?.toLowerCase();
31-
if (nav && supportedLocales.includes(nav as Locale)) return nav as Locale;
32-
return "en";
35+
function persistLocale(locale: Locale) {
36+
if (typeof window === "undefined") return;
37+
38+
window.localStorage.setItem(LOCALE_COOKIE, locale);
39+
document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=${cookieMaxAge}; samesite=lax`;
3340
}
3441

35-
export function useI18nProvider() {
36-
const [locale, setLocaleState] = useState<Locale>("en");
37-
const [messages, setMessages] = useState<Messages>(() => {
38-
if (enMessagesCache) return enMessagesCache;
39-
// eslint-disable-next-line @typescript-eslint/no-var-requires
40-
enMessagesCache = require("../locales/en.json");
41-
return enMessagesCache as Messages;
42-
});
42+
export function useI18nProvider(initialLocale: Locale = DEFAULT_LOCALE) {
43+
const [locale, setLocaleState] = useState<Locale>(initialLocale);
44+
const [messages, setMessages] = useState<Messages>(() => messagesByLocale[initialLocale]);
4345
const [ready, setReady] = useState<boolean>(true);
4446

4547
const changeLocale = useCallback((next: Locale) => {
@@ -48,24 +50,25 @@ export function useI18nProvider() {
4850
.then((m) => {
4951
setMessages(m);
5052
setLocaleState(next);
51-
if (typeof window !== "undefined") {
52-
window.localStorage.setItem(storageKey, next);
53-
}
53+
persistLocale(next);
5454
setReady(true);
5555
})
5656
.catch((err) => {
5757
console.warn("[i18n] failed to load locale, falling back to en", err);
58-
if (enMessagesCache) setMessages(enMessagesCache);
59-
setLocaleState("en");
58+
setMessages(messagesByLocale[DEFAULT_LOCALE]);
59+
setLocaleState(DEFAULT_LOCALE);
6060
setReady(true);
6161
});
6262
}, []);
6363

6464
useEffect(() => {
6565
if (typeof window === "undefined") return;
66-
const detected = detectLocale();
67-
changeLocale(detected);
68-
}, [changeLocale]);
66+
67+
const stored = window.localStorage.getItem(LOCALE_COOKIE);
68+
if (isSupportedLocale(stored) && stored !== locale) {
69+
changeLocale(stored);
70+
}
71+
}, [changeLocale, locale]);
6972

7073
useEffect(() => {
7174
if (typeof document === "undefined") return;

middleware.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { NextRequest } from "next/server";
2+
import { NextResponse } from "next/server";
3+
import {
4+
DEFAULT_LOCALE,
5+
LOCALE_COOKIE,
6+
isSupportedLocale,
7+
parseAcceptLanguage,
8+
supportedLocales,
9+
} from "./lib/i18n-core";
10+
11+
export function middleware(request: NextRequest) {
12+
const response = NextResponse.next();
13+
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
14+
15+
if (!isSupportedLocale(cookieLocale)) {
16+
const locale = parseAcceptLanguage(
17+
request.headers.get("accept-language"),
18+
supportedLocales,
19+
DEFAULT_LOCALE
20+
);
21+
22+
response.cookies.set(LOCALE_COOKIE, locale, { path: "/" });
23+
}
24+
25+
return response;
26+
}
27+
28+
// Run only on routes that produce HTML or read the cookie. Skip Next.js
29+
// internals, the optimized image endpoint, public static assets, and API
30+
// routes — they don't need a locale cookie and we want their responses
31+
// to stay cacheable.
32+
export const config = {
33+
matcher: [
34+
"/((?!_next/static|_next/image|api|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|woff|woff2|ttf|otf)$).*)",
35+
],
36+
};

0 commit comments

Comments
 (0)