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
27 changes: 27 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,43 @@
import "./globals.css";
import type { ReactNode } from "react";
import Script from "next/script";
import Providers from "./providers";

const themeInitScript = `
try {
const storageKey = "devimpact-theme";
const storedTheme = window.localStorage.getItem(storageKey);
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme =
storedTheme === "light" || storedTheme === "dark"
? storedTheme
: prefersDark
? "dark"
: "light";

const root = document.documentElement;
root.classList.toggle("dark", theme === "dark");
root.style.colorScheme = theme;
} catch {}
`;

export const metadata = {
title: "DevImpact",
description: "GitHub user scoring",
icons: {
icon: "/logo.svg",
shortcut: "/logo.svg",
apple: "/logo.svg",
},
};

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<Script id="theme-init" strategy="beforeInteractive">
{themeInitScript}
</Script>
<Providers>{children}</Providers>
</body>
</html>
Expand Down
88 changes: 41 additions & 47 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ 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";
import { ThemeToggle } from "@/components/theme-toggle";
import { BrandLogo } from "@/components/brand-logo";
import { AppHeader } from "@/components/app-header";
import { AppFooter } from "@/components/app-footer";
import { useTranslation } from "@/components/language-provider";

type ApiResponse = {
success: boolean;
Expand All @@ -15,26 +17,48 @@ type ApiResponse = {
};

export default function HomePage() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<{
user1: UserResult;
user2: UserResult;
} | null>(null);

const localizeErrorMessage = (message?: string) => {
switch (message) {
case "provide at least one username param":
return t("error.missingUsername");
case "GitHub user not found":
return t("error.userNotFound");
case "Failed to calculate score":
return t("error.calculateFailed");
case "Comparison failed":
return t("error.comparisonFailed");
case "Failed to fetch":
return t("error.fetchFailed");
default:
return message || t("error.generic");
}
};

const handleCompare = async (u1: string, u2: string) => {
setLoading(true);
setError(null);
setData(null);

try {
const params = new URLSearchParams();
params.append("username", u1);
params.append("username", u2);

const res = await fetch(`/api/compare?${params.toString()}`);
const body: ApiResponse = await res.json();

if (!body.success || !body.users || body.users.length < 2) {
throw new Error(body.error || "Comparison failed");
throw new Error(localizeErrorMessage(body.error || "Comparison failed"));
}

if (body.users[0].finalScore > body.users[1].finalScore) {
setData({
user1: { ...body.users[0], isWinner: true },
Expand All @@ -49,7 +73,7 @@ export default function HomePage() {
setData({ user1: body.users[0], user2: body.users[1] });
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch");
setError(localizeErrorMessage(err instanceof Error ? err.message : undefined));
} finally {
setLoading(false);
}
Expand All @@ -61,29 +85,17 @@ export default function HomePage() {
setData(null);
setError(null);
};

const swapUsers = () => {
if (!data) return;
setData((d) => ({ user1: d!.user2, user2: d!.user1 }));
console.log("Swapped users", data);
};

return (
<main className="min-h-screen flex flex-col">
{" "}
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 max-w-7xl items-center justify-between m-auto px-4">
<div className="flex items-center gap-2 font-bold text-xl">
<span className="bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
DevImpact
</span>
</div>
<AppHeader />

<div className="flex gap-4">
<LanguageSwitcher />
<ThemeToggle />
</div>
</div>
</header>
<div className="flex-1 max-w-6xl mx-auto px-4 py-10 space-y-6 w-full">
<div className="w-full flex-1 max-w-6xl mx-auto px-4 py-10 space-y-6">
<CompareForm
onSubmit={handleCompare}
loading={loading}
Expand All @@ -94,41 +106,23 @@ export default function HomePage() {

{loading && skeleton}
{error && (
<div className="card p-4 text-sm text-red-600 bg-red-50 border border-red-100">
<div className="card border border-red-100 bg-red-50 p-4 text-sm text-red-600">
{error}
</div>
)}

{data && <ResultDashboard user1={data.user1} user2={data.user2} />}

{!loading && !error && !data && (
<div className="flex flex-col items-center justify-center py-20 text-center text-muted-foreground gap-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-30"
>
<circle cx="9" cy="7" r="4" />
<circle cx="15" cy="7" r="4" />
<path d="M3 21v-2a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4v2" />
</svg>
<p className="text-lg font-medium">Enter two usernames to compare</p>
<p className="text-sm opacity-70">
Compare GitHub developer metrics side by side
</p>
<div className="flex flex-col items-center justify-center gap-4 py-20 text-center text-muted-foreground">
<BrandLogo size="xl" />
<p className="text-lg font-medium">{t("page.empty.title")}</p>
<p className="text-sm opacity-70">{t("page.empty.description")}</p>
</div>
)}
</div>
<footer className="border-t border-border py-6 text-center text-sm text-muted-foreground">
<div className="container max-w-7xl mx-auto px-4">
<span className="font-medium">DevImpact</span> — Compare GitHub developer metrics
</div>
</footer>

<AppFooter />
</main>
);
}
73 changes: 73 additions & 0 deletions components/app-footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"use client";

import { useTranslation } from "@/components/language-provider";
import { cn } from "@/lib/utils";

export function AppFooter() {
const { t, dir } = useTranslation();

return (
<footer className="relative mt-10 border-t border-border/80 py-10 text-sm text-muted-foreground">
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-cyan-400/60 to-transparent" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-[radial-gradient(circle_at_top,rgba(30,185,229,0.12),transparent_70%)] dark:bg-[radial-gradient(circle_at_top,rgba(30,185,229,0.18),transparent_72%)]" />

<div className="container relative mx-auto max-w-7xl px-4">
<div
className={cn(
"flex flex-col gap-8 md:items-end md:justify-between md:flex-row",
)}
>
<div
className={cn(
"space-y-3 text-center md:text-start",
)}
>
<span className="inline-block text-[11px] font-semibold uppercase tracking-[0.34em] text-muted-foreground/75">
{t("footer.eyebrow")}
</span>
<div className="space-y-3">
<span className="block text-3xl font-semibold tracking-tight sm:text-4xl">
<span className="bg-gradient-to-r from-[#113764] via-[#1C4F94] to-[#414AED] bg-clip-text text-transparent dark:from-[#A7CEFF] dark:via-[#6FB2FF] dark:to-[#7C85FF]">
Dev
</span>
<span className="bg-gradient-to-r from-[#1EB5EC] via-[#1EB9E5] to-[#01FFF9] bg-clip-text text-transparent dark:from-[#76E4FF] dark:via-[#55D7FF] dark:to-[#9AFFF7]">
Impact
</span>
</span>
<p className="max-w-xl text-sm leading-6 text-muted-foreground ">
{t("footer.description")}
</p>
</div>
</div>

<div
className={cn(
"flex flex-col items-center gap-3 md:items-end",
)}
>
<span className="rounded-full border border-border/70 bg-background/75 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.28em] text-muted-foreground shadow-sm backdrop-blur">
{t("footer.tag")}
</span>
<p
className={cn(
"max-w-sm text-center text-sm leading-6 text-muted-foreground md:text-end",
)}
>
{t("footer.note")}
</p>
</div>
</div>

<div
className={cn(
"mt-8 flex flex-col gap-3 border-t border-border/70 pt-4 text-[11px] font-medium uppercase tracking-[0.24em] text-muted-foreground/75 md:items-center md:justify-between",
dir === "rtl" ? "md:flex-row-reverse" : "md:flex-row"
)}
>
<span>{t("footer.summary")}</span>
<span>{t("footer.tagline")}</span>
</div>
</div>
</footer>
);
}
18 changes: 18 additions & 0 deletions components/app-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { BrandLogo } from "@/components/brand-logo";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeToggle } from "@/components/theme-toggle";

export function AppHeader() {
return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container m-auto flex h-24 max-w-7xl items-center justify-between px-4 sm:h-28">
<BrandLogo priority size="lg" className="shrink-0" />

<div className="flex gap-4">
<LanguageSwitcher />
<ThemeToggle />
</div>
</div>
</header>
);
}
40 changes: 40 additions & 0 deletions components/brand-logo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Image from "next/image";
import { cn } from "@/lib/utils";

type BrandLogoProps = {
size?: "sm" | "md" | "lg" | "xl";
className?: string;
priority?: boolean;
};

const sizeClasses = {
sm: "h-12 w-12 sm:h-16 sm:w-16",
md: "h-14 w-14 sm:h-20 sm:w-20",
lg: "h-16 w-16 sm:h-24 sm:w-24",
xl: "h-24 w-24 sm:h-32 sm:w-32",
} as const;

export function BrandLogo({
size = "md",
className,
priority = false,
}: BrandLogoProps) {
return (
<span
className={cn(
"inline-flex items-center justify-center",
"drop-shadow-[0_12px_24px_rgba(15,23,42,0.12)] dark:drop-shadow-[0_16px_26px_rgba(2,6,23,0.5)]",
className
)}
>
<Image
src="/logo.svg"
alt="DevImpact"
width={1024}
height={1024}
priority={priority}
className={cn("object-contain", sizeClasses[size])}
/>
</span>
);
}
Loading
Loading