diff --git a/.gitignore b/.gitignore
index 93bdc13..ef5d71d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -81,6 +81,7 @@ out
# Nuxt.js build / generate output
.nuxt
dist
+next-env.d.ts
# Gatsby files
.cache/
diff --git a/app/globals.css b/app/globals.css
index 65ff396..794cfbc 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -74,16 +74,16 @@ body::after {
body::before {
background-image:
- radial-gradient(circle at 20% 20%, rgba(59, 130, 246, 0.08), transparent 35%),
- radial-gradient(circle at 80% 0%, rgba(168, 85, 247, 0.08), transparent 30%),
+ radial-gradient(circle at 20% 20%, hsl(var(--primary) / 0.08), transparent 35%),
+ radial-gradient(circle at 80% 0%, hsl(var(--accent) / 0.08), transparent 30%),
linear-gradient(180deg, #f8fbff 0%, #f2f5f9 40%, #f9fafb 100%);
opacity: 1;
}
body::after {
background-image:
- radial-gradient(circle at 20% 20%, rgba(59, 130, 246, 0.08), transparent 35%),
- radial-gradient(circle at 80% 0%, rgba(124, 58, 237, 0.12), transparent 30%),
+ radial-gradient(circle at 20% 20%, hsl(var(--primary) / 0.08), transparent 35%),
+ radial-gradient(circle at 80% 0%, hsl(var(--accent) / 0.12), transparent 30%),
linear-gradient(180deg, #0f172a 0%, #0b1221 40%, #0a0f1c 100%);
opacity: 0;
}
diff --git a/app/layout.tsx b/app/layout.tsx
index cf1565a..5865918 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -2,7 +2,6 @@ import "./globals.css";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
-import Script from "next/script";
import {
DEFAULT_LOCALE,
LOCALE_COOKIE,
@@ -112,10 +111,13 @@ export default async function RootLayout({ children }: { children: ReactNode })
return (
+
+
+
-
{children}
diff --git a/app/page.tsx b/app/page.tsx
index 7c58084..c3a57fe 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import { Suspense } from "react";
-import { DashboardSkeleton } from "@/components/skeletons";
+import { AppFooter } from "@/components/app-footer";
+import { AppHeader } from "@/components/app-header";
import { HomePageClient } from "@/components/home-page-client";
+import { Skeleton } from "@/components/ui/skeleton";
import { JsonLd } from "@/components/seo/json-ld";
import { toAbsoluteUrl } from "@/lib/seo";
@@ -63,11 +65,65 @@ const softwareSchema = {
},
};
+function HomePageFallback() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
export default function HomePage() {
return (
<>
- }>
+ }>
>
diff --git a/components/compare-form.tsx b/components/compare-form.tsx
index 60ee6f9..4d854e6 100644
--- a/components/compare-form.tsx
+++ b/components/compare-form.tsx
@@ -47,6 +47,7 @@ type CompareFormProps = {
swapUsers?: () => void;
username1Error?: string | null;
username2Error?: string | null;
+ disableDuplicateFetch?: boolean;
};
export function CompareForm({
@@ -63,6 +64,7 @@ export function CompareForm({
reset,
username1Error,
username2Error,
+ disableDuplicateFetch,
}: CompareFormProps) {
const { t } = useTranslation();
const firstInputRef = useRef(null);
@@ -71,7 +73,12 @@ export function CompareForm({
firstInputRef.current?.focus();
}, []);
- const canSubmit = Boolean(username1.trim() && username2.trim() && !loading);
+ const normalized1 = username1.trim().toLowerCase();
+ const normalized2 = username2.trim().toLowerCase();
+ const sameUsername = Boolean(normalized1 && normalized2 && normalized1 === normalized2);
+ const canSubmit = Boolean(
+ username1.trim() && username2.trim() && !loading && !sameUsername && !disableDuplicateFetch,
+ );
const isEmpty = !username1.trim() && !username2.trim() && !hasData;
const hasLanguageSelection = selectedLanguages.length > 0;
@@ -238,7 +245,13 @@ export function CompareForm({
-
+
+ {sameUsername ? (
+
{t("error.sameUser")}
+ ) : null}
+
+
+
+
diff --git a/components/home-page-client.tsx b/components/home-page-client.tsx
index 59c5917..c5edf71 100644
--- a/components/home-page-client.tsx
+++ b/components/home-page-client.tsx
@@ -96,6 +96,7 @@ export function HomePageClient() {
);
const [data, setData] = useState
(null);
const [displayData, setDisplayData] = useState(null);
+ const [disableDuplicateFetch, setDisableDuplicateFetch] = useState(false);
const lastFetchedKeyRef = useRef(null);
const inFlightFetchKeyRef = useRef(null);
const inFlightPromiseRef = useRef | null>(null);
@@ -228,8 +229,18 @@ export function HomePageClient() {
return inFlightPromiseRef.current;
}
+ // If we've already fetched this exact comparison and have the data, skip.
+ if (lastFetchedKeyRef.current === fetchKey && data) {
+ return Promise.resolve();
+ }
+
lastFetchedKeyRef.current = fetchKey;
+ // update duplicate fetch state for current form values
+ setDisableDuplicateFetch(
+ Boolean(lastFetchedKeyRef.current === createFetchKey(username1.trim(), username2.trim(), { selectedLanguages }) && (data || inFlightFetchKeyRef.current === fetchKey)),
+ );
+
const requestPromise = (async () => {
if (options.updateUrl !== false) {
const params = new URLSearchParams();
@@ -306,6 +317,11 @@ export function HomePageClient() {
inFlightFetchKeyRef.current = fetchKey;
inFlightPromiseRef.current = requestPromise;
+ // mark duplicate fetch disabled while request is in-flight
+ setDisableDuplicateFetch(
+ Boolean(lastFetchedKeyRef.current === createFetchKey(username1.trim(), username2.trim(), { selectedLanguages }) && (data || inFlightFetchKeyRef.current === fetchKey)),
+ );
+
return requestPromise;
};
@@ -319,6 +335,7 @@ export function HomePageClient() {
lastFetchedKeyRef.current = null;
setData(null);
resetErrors();
+ setDisableDuplicateFetch(false);
return;
}
@@ -326,10 +343,7 @@ export function HomePageClient() {
selectedLanguages: languages,
});
- if (
- lastFetchedKeyRef.current === nextKey &&
- (data || inFlightFetchKeyRef.current === nextKey)
- ) {
+ if (lastFetchedKeyRef.current === nextKey && data) {
return;
}
@@ -383,6 +397,19 @@ export function HomePageClient() {
const isRefreshing = loading && Boolean(displayData);
const isExiting = !loading && !data && Boolean(displayData);
+
+ useEffect(() => {
+ const currentFetchKey = createFetchKey(username1.trim(), username2.trim(), {
+ selectedLanguages,
+ });
+
+ const lastKey = lastFetchedKeyRef.current;
+ const inFlightKey = inFlightFetchKeyRef.current;
+
+ const disabled = Boolean(lastKey === currentFetchKey && (data || inFlightKey === currentFetchKey));
+ setDisableDuplicateFetch(disabled);
+ }, [username1, username2, selectedLanguages, data, loading]);
+
const handleUsername1Change = (value: string) => {
setUsername1(value);
if (usernameErrors.username1) {
@@ -403,6 +430,7 @@ export function HomePageClient() {
resetErrors();
inFlightFetchKeyRef.current = null;
inFlightPromiseRef.current = null;
+ setDisableDuplicateFetch(false);
setUsername1("");
setUsername2("");
setSelectedLanguages([]);
@@ -441,6 +469,7 @@ export function HomePageClient() {
reset={reset}
swapUsers={swapUsers}
hasData={Boolean(data)}
+ disableDuplicateFetch={disableDuplicateFetch}
username1Error={usernameErrors.username1}
username2Error={usernameErrors.username2}
/>
diff --git a/components/skeletons.tsx b/components/skeletons.tsx
index d6e0e7c..d33ad28 100644
--- a/components/skeletons.tsx
+++ b/components/skeletons.tsx
@@ -1,19 +1,80 @@
import { Skeleton } from "@/components/ui/skeleton";
+import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
export function DashboardSkeleton() {
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx
index 75a06f3..cadf070 100644
--- a/components/theme-provider.tsx
+++ b/components/theme-provider.tsx
@@ -9,6 +9,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
attribute="class"
defaultTheme="system"
enableSystem
+ enableColorScheme
storageKey="devimpact-theme"
>
{children}
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
index 0118624..8fc953e 100644
--- a/components/ui/skeleton.tsx
+++ b/components/ui/skeleton.tsx
@@ -4,7 +4,10 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
)
diff --git a/locales/ar.json b/locales/ar.json
index 9431ed5..c1182eb 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -47,6 +47,7 @@
"error.resourceLimit": "تم الوصول إلى حدود موارد GitHub API لهذا الطلب. حاول مرة أخرى بعد قليل.",
"error.missingUsername": "أدخل اسمين للمقارنة.",
"error.userNotFound": "لم يتم العثور على مستخدم GitHub",
+ "error.sameUser": "مطلوب اسمان مختلفان لمستخدمي GitHub.",
"explanations.contribution": "درجة المساهمات",
"explanations.line.contribution.1": "تعتمد درجة المساهمات على المشكلات والنقاشات الخارجية فقط.",
"explanations.line.contribution.2": "لا يتم احتساب الـ commits وطلبات السحب لتجنب العد المزدوج.",
diff --git a/locales/en.json b/locales/en.json
index 4a07471..af52421 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -47,6 +47,7 @@
"error.resourceLimit": "GitHub API resource limits were reached for this request. Please retry shortly.",
"error.missingUsername": "Provide two usernames to compare.",
"error.userNotFound": "GitHub user not found",
+ "error.sameUser": "Two different GitHub usernames are required.",
"explanations.contribution": "Contribution Score",
"explanations.line.contribution.1": "Contribution score is based on external issues and discussions only.",
"explanations.line.contribution.2": "Commits and pull requests are excluded to avoid double-counting.",
diff --git a/next-env.d.ts b/next-env.d.ts
deleted file mode 100644
index c4b7818..0000000
--- a/next-env.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-///
-///
-import "./.next/dev/types/routes.d.ts";
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.