Skip to content

Commit aa20076

Browse files
authored
Merge pull request #20 from O2sa/feat/app-ui
feat: implement the UI(compare form + result dashboard)
2 parents b6c5bdf + f14f113 commit aa20076

13 files changed

Lines changed: 628 additions & 1 deletion

app/globals.css

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,25 @@ body {
2020
min-height: 100vh;
2121
}
2222

23+
.dark body {
24+
@apply bg-slate-950 text-slate-100;
25+
background-image:
26+
radial-gradient(circle at 20% 20%, rgba(59, 130, 246, 0.08), transparent 35%),
27+
radial-gradient(circle at 80% 0%, rgba(124, 58, 237, 0.12), transparent 30%),
28+
linear-gradient(180deg, #0f172a 0%, #0b1221 40%, #0a0f1c 100%);
29+
}
30+
31+
.card {
32+
@apply bg-white/90 shadow-card rounded-2xl border border-slate-100 backdrop-blur;
33+
transition: transform 180ms ease, box-shadow 180ms ease;
34+
}
35+
36+
.card:hover {
37+
transform: translateY(-2px);
38+
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.12);
39+
}
40+
41+
.dark .card {
42+
@apply bg-slate-900/80 border-slate-800;
43+
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
44+
}

app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
1010
return (
1111
<html>
1212
<body>
13-
DevImpact
13+
{children}
1414
</body>
1515
</html>
1616
);

app/page.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"use client";
2+
3+
import { useMemo, useState } from "react";
4+
import { CompareForm } from "../components/compare-form";
5+
import { ResultDashboard } from "../components/result-dashboard";
6+
import { DashboardSkeleton } from "../components/skeletons";
7+
8+
9+
type UserResult = {
10+
username: string;
11+
repoScore: number;
12+
prScore: number;
13+
contributionScore: number;
14+
finalScore: number;
15+
topRepos: { name?: string; stars?: number; score?: number }[];
16+
topPullRequests: { repo?: string; stars?: number; score?: number }[];
17+
insights?: string[];
18+
};
19+
20+
type ApiResponse = {
21+
success: boolean;
22+
users?: UserResult[];
23+
error?: string;
24+
};
25+
26+
export default function HomePage() {
27+
const [loading, setLoading] = useState(false);
28+
const [error, setError] = useState<string | null>(null);
29+
const [data, setData] = useState<{ user1: UserResult; user2: UserResult } | null>(
30+
null
31+
);
32+
33+
const handleCompare = async (u1: string, u2: string) => {
34+
setLoading(true);
35+
setError(null);
36+
setData(null);
37+
try {
38+
const params = new URLSearchParams();
39+
params.append("username", u1);
40+
params.append("username", u2);
41+
const res = await fetch(`/api/compare?${params.toString()}`);
42+
const body: ApiResponse = await res.json();
43+
if (!body.success || !body.users || body.users.length < 2) {
44+
throw new Error(body.error || "Comparison failed");
45+
}
46+
setData({ user1: body.users[0], user2: body.users[1] });
47+
} catch (err: any) {
48+
setError(err.message || "Failed to fetch");
49+
} finally {
50+
setLoading(false);
51+
}
52+
};
53+
54+
const skeleton = useMemo(() => <DashboardSkeleton />, []);
55+
56+
return (
57+
<main className="min-h-screen">
58+
<div className="max-w-6xl mx-auto px-4 py-10 space-y-6" >
59+
<div className="flex items-center justify-between gap-4">
60+
<div className="flex items-center gap-3">
61+
<div>
62+
<p className="text-sm text-slate-500">GitHub Developer Compare</p>
63+
<h1 className="text-3xl font-semibold text-slate-900">
64+
Compare two developers
65+
</h1>
66+
</div>
67+
</div>
68+
69+
</div>
70+
71+
72+
<CompareForm
73+
onSubmit={handleCompare}
74+
loading={loading}
75+
/>
76+
77+
{loading && skeleton}
78+
{error && (
79+
<div className="card p-4 text-sm text-red-600 bg-red-50 border border-red-100">
80+
{error}
81+
</div>
82+
)}
83+
{data && <ResultDashboard user1={data.user1} user2={data.user2} />}
84+
</div>
85+
</main>
86+
);
87+
}

components/breakdown-bars.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
type Props = {
2+
user: any;
3+
};
4+
5+
const items = [
6+
{ key: "repoScore", label: "Repos", color: "bg-blue-500" },
7+
{ key: "prScore", label: "Pull Requests", color: "bg-purple-500" },
8+
{ key: "contributionScore", label: "Activity", color: "bg-emerald-500" },
9+
];
10+
11+
export function BreakdownBars({ user }: Props) {
12+
const max = Math.max(
13+
user.repoScore ?? 0,
14+
user.prScore ?? 0,
15+
user.contributionScore ?? 0,
16+
1
17+
);
18+
19+
return (
20+
<div className="card p-4">
21+
<h3 className="text-sm font-semibold text-slate-800 mb-3">
22+
Breakdown — {user.username}
23+
</h3>
24+
<div className="flex flex-col gap-3">
25+
{items.map((item) => {
26+
const val = user[item.key] ?? 0;
27+
const pct = Math.min(100, Math.round((val / max) * 100));
28+
return (
29+
<div key={item.key} className="space-y-1">
30+
<div className="flex justify-between text-xs text-slate-600">
31+
<span>{item.label}</span>
32+
<span>{val.toFixed(2)}</span>
33+
</div>
34+
<div className="h-2 rounded-full bg-slate-100">
35+
<div
36+
className={`h-full rounded-full ${item.color}`}
37+
style={{ width: `${pct}%` }}
38+
/>
39+
</div>
40+
</div>
41+
);
42+
})}
43+
</div>
44+
</div>
45+
);
46+
}

components/compare-form.tsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { useState } from "react";
2+
import { Button } from "./ui/button";
3+
import { cn } from "../lib/utils";
4+
5+
type CompareFormProps = {
6+
onSubmit: (u1: string, u2: string) => void;
7+
loading?: boolean;
8+
};
9+
10+
export function CompareForm({ onSubmit, loading }: CompareFormProps) {
11+
const [username1, setUsername1] = useState("");
12+
const [username2, setUsername2] = useState("");
13+
14+
const canSubmit = Boolean(username1.trim() && username2.trim() && !loading);
15+
16+
const handleSwap = () => {
17+
setUsername1(username2);
18+
setUsername2(username1);
19+
};
20+
21+
const handleReset = () => {
22+
setUsername1("");
23+
setUsername2("");
24+
};
25+
26+
const submit = (e: React.FormEvent) => {
27+
e.preventDefault();
28+
if (!canSubmit) return;
29+
onSubmit(username1.trim(), username2.trim());
30+
};
31+
32+
return (
33+
<form
34+
onSubmit={submit}
35+
className={cn(
36+
"card p-6 flex flex-col gap-4 animate-fadeIn bg-gradient-to-br from-white/95 via-white/85 to-slate-50",
37+
)}
38+
>
39+
<div className="flex items-center justify-between gap-3">
40+
<div>
41+
<p className="text-xs uppercase text-slate-500">GitHub Developer Compare</p>
42+
<h2 className="text-xl font-semibold text-slate-900">
43+
Enter two usernames
44+
</h2>
45+
</div>
46+
<div className="flex gap-2">
47+
<Button
48+
type="button"
49+
variant="secondary"
50+
size="sm"
51+
onClick={handleSwap}
52+
>
53+
Swap
54+
</Button>
55+
<Button
56+
type="button"
57+
variant="ghost"
58+
size="sm"
59+
onClick={handleReset}
60+
>
61+
Reset
62+
</Button>
63+
</div>
64+
</div>
65+
66+
<div className="grid gap-3 md:grid-cols-2">
67+
<input
68+
dir="ltr"
69+
className="h-11 rounded-lg border border-slate-200 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/60 focus:border-transparent bg-white"
70+
placeholder={"Username 1"}
71+
value={username1}
72+
onChange={(e) => setUsername1(e.target.value)}
73+
/>
74+
<input
75+
dir="ltr"
76+
className="h-11 rounded-lg border border-slate-200 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/60 focus:border-transparent bg-white"
77+
placeholder={"Username 2"}
78+
value={username2}
79+
onChange={(e) => setUsername2(e.target.value)}
80+
/>
81+
</div>
82+
83+
<div className="flex gap-3 justify-end">
84+
<Button
85+
type="submit"
86+
disabled={!canSubmit}
87+
className="min-w-[140px] shadow-sm transition-transform hover:-translate-y-0.5"
88+
>
89+
{loading ? "Comparing..." : "Compare"}
90+
</Button>
91+
</div>
92+
</form>
93+
);
94+
}

components/comparison-chart.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
Bar,
3+
BarChart,
4+
CartesianGrid,
5+
ResponsiveContainer,
6+
Tooltip,
7+
XAxis,
8+
YAxis,
9+
} from "recharts";
10+
11+
type Props = {
12+
user1: any;
13+
user2: any;
14+
};
15+
16+
const metrics = [
17+
{ key: "repoScore", label: "Repos" },
18+
{ key: "prScore", label: "PRs" },
19+
{ key: "contributionScore", label: "Activity" },
20+
];
21+
22+
export function ComparisonChart({ user1, user2 }: Props) {
23+
const data = metrics.map((m) => ({
24+
name: m.label,
25+
[user1.username]: user1[m.key] ?? 0,
26+
[user2.username]: user2[m.key] ?? 0,
27+
}));
28+
29+
return (
30+
<div className="card p-4 h-80 bg-gradient-to-br from-white to-slate-50">
31+
<h3 className="text-sm font-semibold text-slate-800 mb-3">
32+
Score Comparison
33+
</h3>
34+
<ResponsiveContainer width="100%" height="100%">
35+
<BarChart data={data}>
36+
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
37+
<XAxis dataKey="name" />
38+
<YAxis />
39+
<Tooltip
40+
contentStyle={{ borderRadius: 12, border: "1px solid #e2e8f0" }}
41+
/>
42+
<Bar dataKey={user1.username} fill="#3b82f6" radius={[8, 8, 0, 0]} />
43+
<Bar dataKey={user2.username} fill="#a855f7" radius={[8, 8, 0, 0]} />
44+
</BarChart>
45+
</ResponsiveContainer>
46+
</div>
47+
);
48+
}

components/comparison-table.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
type ScoreRow = {
2+
label: string;
3+
key: "finalScore" | "repoScore" | "prScore" | "contributionScore";
4+
};
5+
6+
const rows: ScoreRow[] = [
7+
{ label: "Final Score", key: "finalScore" },
8+
{ label: "Repo Score", key: "repoScore" },
9+
{ label: "PR Score", key: "prScore" },
10+
{ label: "Contribution", key: "contributionScore" },
11+
];
12+
13+
type ComparisonTableProps = {
14+
user1: any;
15+
user2: any;
16+
};
17+
18+
export function ComparisonTable({ user1, user2 }: ComparisonTableProps) {
19+
return (
20+
<div className="card overflow-hidden">
21+
<table className="min-w-full">
22+
<thead className="bg-slate-50 text-left text-xs uppercase text-slate-500">
23+
<tr>
24+
<th className="px-4 py-3">Metric</th>
25+
<th className="px-4 py-3">{user1.username}</th>
26+
<th className="px-4 py-3">{user2.username}</th>
27+
</tr>
28+
</thead>
29+
<tbody className="divide-y divide-slate-100 text-sm">
30+
{rows.map((row) => {
31+
const v1 = user1[row.key] ?? 0;
32+
const v2 = user2[row.key] ?? 0;
33+
const highlight1 = v1 > v2;
34+
const highlight2 = v2 > v1;
35+
return (
36+
<tr key={row.key}>
37+
<td className="px-4 py-3 font-medium text-slate-700">
38+
{row.label}
39+
</td>
40+
<td
41+
className={`px-4 py-3 ${
42+
highlight1 ? "text-blue-600 font-semibold" : "text-slate-800"
43+
}`}
44+
>
45+
{v1.toFixed(2)}
46+
</td>
47+
<td
48+
className={`px-4 py-3 ${
49+
highlight2 ? "text-blue-600 font-semibold" : "text-slate-800"
50+
}`}
51+
>
52+
{v2.toFixed(2)}
53+
</td>
54+
</tr>
55+
);
56+
})}
57+
</tbody>
58+
</table>
59+
</div>
60+
);
61+
}

components/insights-list.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
type Props = {
2+
insights: string[];
3+
title?: string;
4+
};
5+
6+
export function InsightsList({ insights, title = "Insights" }: Props) {
7+
if (!insights?.length) return null;
8+
return (
9+
<div className="card p-4">
10+
<h3 className="text-sm font-semibold text-slate-800 mb-2">{title}</h3>
11+
<ul className="space-y-2 text-sm text-slate-700 list-disc list-inside">
12+
{insights.map((insight, idx) => (
13+
<li key={idx}>{insight}</li>
14+
))}
15+
</ul>
16+
</div>
17+
);
18+
}

0 commit comments

Comments
 (0)