Skip to content
Open
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
1 change: 1 addition & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @type {import("next").NextConfig} */
const nextConfig = {
transpilePackages: ["driver.js"],
images: {
remotePatterns: [
{
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"html-to-image": "^1.11.13",
"driver.js": "^1.4.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"next": "^14.2.35",
Expand Down
76 changes: 50 additions & 26 deletions src/app/api/user/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ export async function GET(req: NextRequest) {
// Fetch user from Supabase
const { data, error } = await supabaseAdmin
.from("users")
.select("id, github_login, is_public, leaderboard_opt_in")
.select(
"id, github_login, is_public, leaderboard_opt_in, seen_onboarding"
)
.eq("id", user.id)
.single();

if (error) {
console.error("Error fetching user:", error);

return NextResponse.json(
{ error: "Failed to fetch user settings" },
{ status: 500 }
Expand All @@ -46,18 +48,23 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

// Get user ID from Supabase
const user = await resolveAppUser(session.githubId, session.githubLogin);
const user = await resolveAppUser(session.githubId, session.githubLogin);

if (!user) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}
if (!user) {
console.error("Error fetching user");

return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}

let body: {
is_public?: boolean;
leaderboard_opt_in?: boolean;
seen_onboarding?: boolean;
};

// Parse request body
let body: { is_public?: boolean; leaderboard_opt_in?: boolean };
try {
body = await req.json();
} catch {
Expand All @@ -67,49 +74,66 @@ export async function PATCH(req: NextRequest) {
);
}

const { is_public, leaderboard_opt_in } = body;
const {
is_public,
leaderboard_opt_in,
seen_onboarding,
} = body;

if (
typeof is_public !== "boolean" &&
typeof leaderboard_opt_in !== "boolean"
) {
return NextResponse.json(
{ error: "At least one boolean setting is required" },
{ status: 400 }
);
}
const updates: {
is_public?: boolean;
leaderboard_opt_in?: boolean;
seen_onboarding?: boolean;
} = {};

const updates: { is_public?: boolean; leaderboard_opt_in?: boolean } = {};
if (typeof is_public === "boolean") {
updates.is_public = is_public;
}

if (typeof leaderboard_opt_in === "boolean") {
updates.leaderboard_opt_in = leaderboard_opt_in;

// If user opts into leaderboard, profile must be public
if (leaderboard_opt_in) {
updates.is_public = true;
}
}

if (typeof seen_onboarding === "boolean") {
updates.seen_onboarding = seen_onboarding;
}

if (Object.keys(updates).length === 0) {
return NextResponse.json(
{ error: "No valid fields to update" },
{ status: 400 }
);
}

const { data: updated, error: updateError } = await supabaseAdmin
.from("users")
.update(updates)
.eq("id", user.id)
.select("id, github_login, is_public, leaderboard_opt_in")
.select(
"id, github_login, is_public, leaderboard_opt_in, seen_onboarding"
)
.single();

if (updateError || !updated) {
console.error("Error updating settings:", updateError);
console.error("Failed to update settings:", updateError);

return NextResponse.json(
{ error: "Failed to update settings" },
{ status: 500 }
);
}

// Return updated user (only safe fields)
return NextResponse.json({
id: updated.id,
github_login: updated.github_login,
is_public: updated.is_public,
leaderboard_opt_in: updated.leaderboard_opt_in ?? false,
seen_onboarding: updated.seen_onboarding,
});
}

24 changes: 17 additions & 7 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,28 @@ export default async function DashboardPage() {
{/* Row 1: Contribution graph + heatmap + Friend Comparison on left, Streak on right */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<ContributionGraph />
<div id="widget-contribution-graph">
<ContributionGraph />
</div>
<div className="mt-6">
<ContributionHeatmap />
</div>
<div className="mt-6">
<FriendComparison />
</div>
</div>

<div>
<StreakTracker />
<div className="flex flex-col gap-6">
<div id="widget-streak">
<StreakTracker />
</div>
</div>
</div>

{/* Row 2: PR metrics, PR breakdown & Time Chart */}
<div className="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
<PRMetrics />
<div id="widget-pr-metrics">
<PRMetrics />
</div>
<PRBreakdownChart />
<CommitTimeChart />
</div>
Expand All @@ -91,10 +96,15 @@ export default async function DashboardPage() {

{/* Row 5: Top repos + Language breakdown + Goal tracker */}
<div className="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
<TopRepos />
<div id="widget-top-repos">
<TopRepos />
</div>
<LanguageBreakdown />
<GoalTracker />
<div id="widget-goals">
<GoalTracker />
</div>
</div>
</div>
);
}

2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Providers from "./providers";
import "driver.js/dist/driver.css";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });
Expand Down Expand Up @@ -50,3 +51,4 @@ export default function RootLayout({
</html>
);
}

115 changes: 78 additions & 37 deletions src/components/DashboardHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
"use client"
import NotificationBell from "@/components/NotificationBell";
import { useEffect, useState } from "react";
"use client";

import { useEffect, useState, useCallback } from "react";
import { useSession } from "next-auth/react";
import AccountToggle from "@/components/AccountToggle";
import SignOutButton from "@/components/SignOutButton";
import ThemeToggle from "@/components/ThemeToggle";
import UserAvatar from "@/components/UserAvatar";
import NotificationBell from "@/components/NotificationBell";
import KeyboardShortcuts from "@/components/KeyboardShortcuts";
import OnboardingTour from "@/components/OnboardingTour";

interface UserSettings {
is_public: boolean;
seen_onboarding: boolean;
}

export default function DashboardHeader() {
const { data: session } = useSession();
const [isPublic, setIsPublic] = useState<boolean | null>(null);
const [settings, setSettings] = useState<UserSettings | null>(null);
const [showTour, setShowTour] = useState(false);

useEffect(() => {
if (!session) {
setIsPublic(null);
setSettings(null);
return;
}

Expand All @@ -23,52 +31,85 @@ export default function DashboardHeader() {
const res = await fetch("/api/user/settings");
if (res.ok) {
const data = await res.json();
setIsPublic(data.is_public === true);
setSettings(data);
if (!data.seen_onboarding) {
setShowTour(true);
}
} else {
setIsPublic(false);
setSettings({ is_public: false, seen_onboarding: false });
}
} catch (error) {
console.error("Failed to load settings:", error);
setIsPublic(false);
setSettings({ is_public: false, seen_onboarding: false });
}
}

loadSettings();
}, [session]);

const handleTourComplete = useCallback(async () => {
setShowTour(false);
try {
await fetch("/api/user/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seen_onboarding: true }),
});
setSettings((prev) =>
prev ? { ...prev, seen_onboarding: true } : prev
);
} catch (error) {
console.error("Failed to save onboarding state:", error);
}
}, []);

const handleRestartTour = useCallback(() => {
setShowTour(true);
}, []);

return (
<header className="mb-8 border-b border-[var(--border)] p-4 pb-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl md:text-3xl font-bold text-[var(--foreground)]">
Dashboard
</h1>
<p className="mt-1 text-[var(--muted-foreground)]">
Your coding activity at a glance
</p>
</div>
<>
{showTour && <OnboardingTour onComplete={handleTourComplete} />}
<header className="mb-8 border-b border-[var(--border)] p-4 pb-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl md:text-3xl font-bold text-[var(--foreground)]">
Dashboard
</h1>
<p className="mt-1 text-[var(--muted-foreground)]">
Your coding activity at a glance
</p>
</div>

<div className="flex flex-wrap items-center gap-3">
{isPublic === true && session?.githubLogin && (
<a
href={`/u/${session.githubLogin}`}
target="_blank"
rel="noopener noreferrer"
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleRestartTour}
className="px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--control)] text-[var(--card-foreground)] text-sm font-medium hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)] transition-colors"
title="View your public profile"
aria-label="Restart the dashboard tour"
>
Share Profile
</a>
)}
<KeyboardShortcuts />
<NotificationBell />
<UserAvatar />
<ThemeToggle />
<SignOutButton />
Take Tour
</button>
{settings?.is_public && session?.githubLogin && (
<a
href={`/u/${session.githubLogin}`}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--control)] text-[var(--card-foreground)] text-sm font-medium hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)] transition-colors"
title="View your public profile"
>
Share Profile
</a>
)}
<KeyboardShortcuts />
<NotificationBell />
<UserAvatar />
<ThemeToggle />
<SignOutButton />
</div>
</div>
</div>

<AccountToggle />
</header>
<AccountToggle />
</header>
</>
);
}
Loading
Loading