Skip to content
Closed
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,948 changes: 1,281 additions & 667 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"next-auth": "^4.24.14",
"react": "^18",
"react-dom": "^18",
"react-markdown": "^10.1.0",
"recharts": "^2.12.7",
"rehype-sanitize": "^6.0.0",
"server-only": "^0.0.1",
"sonner": "^2.0.7"
},
Expand Down
1 change: 1 addition & 0 deletions src/app/api/public/[username]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export async function GET(
return NextResponse.json({
username: user.github_login,
userId: user.id,
bio: user.bio ?? "",
repos,
contributions,
streak,
Expand Down
53 changes: 41 additions & 12 deletions src/app/api/user/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ async function fetchUserSettings(userId: string) {
// Tier 1: All columns
const res1 = await supabaseAdmin
.from("users")
.select("id, github_login, is_public, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, weekly_digest_opt_in, discord_webhook_url, timezone")
.select("id, github_login, bio, is_public, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, weekly_digest_opt_in, discord_webhook_url, timezone")
.eq("id", userId)
.single();

Expand All @@ -24,6 +24,7 @@ async function fetchUserSettings(userId: string) {
hasWakatimeKey: true,
hasWeeklyDigestOptIn: true,
hasDiscordSettings: true,
hasBio: true,
leaderboard_opt_in: (res1.data as any).leaderboard_opt_in ?? false,
weekly_digest_opt_in: (res1.data as any).weekly_digest_opt_in ?? false,
pinned_repos: (res1.data as any).pinned_repos || [],
Expand All @@ -43,6 +44,7 @@ async function fetchUserSettings(userId: string) {
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
hasDiscordSettings: false,
hasBio: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
Expand All @@ -53,10 +55,10 @@ async function fetchUserSettings(userId: string) {
};
}

// Tier 2: Without pinned_repos
// Tier 2: Without bio, for deployments that have not run the latest migration.
const res2 = await supabaseAdmin
.from("users")
.select("id, github_login, is_public, leaderboard_opt_in")
.select("id, github_login, is_public, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv")
.eq("id", userId)
.single();

Expand All @@ -65,15 +67,16 @@ async function fetchUserSettings(userId: string) {
data: res2.data as any,
error: null,
hasLeaderboardOptIn: true,
hasPinnedRepos: false,
hasWakatimeKey: false,
hasPinnedRepos: true,
hasWakatimeKey: true,
hasWeeklyDigestOptIn: false,
hasDiscordSettings: false,
hasBio: false,
leaderboard_opt_in: (res2.data as any).leaderboard_opt_in ?? false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
wakatime_api_key_encrypted: null,
wakatime_api_key_iv: null,
pinned_repos: (res2.data as any).pinned_repos || [],
wakatime_api_key_encrypted: (res2.data as any).wakatime_api_key_encrypted || null,
wakatime_api_key_iv: (res2.data as any).wakatime_api_key_iv || null,
discord_webhook_url: null,
timezone: "UTC",
};
Expand All @@ -88,6 +91,7 @@ async function fetchUserSettings(userId: string) {
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
hasDiscordSettings: false,
hasBio: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
Expand All @@ -114,6 +118,7 @@ async function fetchUserSettings(userId: string) {
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
hasDiscordSettings: false,
hasBio: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
Expand All @@ -132,6 +137,7 @@ async function fetchUserSettings(userId: string) {
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
hasDiscordSettings: false,
hasBio: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
Expand Down Expand Up @@ -167,6 +173,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({
id: (result.data as any).id,
github_login: (result.data as any).github_login,
bio: (result.data as any).bio ?? "",
is_public: (result.data as any).is_public,
leaderboard_opt_in: result.leaderboard_opt_in,
weekly_digest_opt_in: result.weekly_digest_opt_in,
Expand All @@ -193,14 +200,14 @@ export async function PATCH(req: NextRequest) {
);
}

let body: { is_public?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key?: string; discord_webhook_url?: string | null; timezone?: string };
let body: { is_public?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key?: string; discord_webhook_url?: string | null; timezone?: string; bio?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

const { is_public, leaderboard_opt_in, weekly_digest_opt_in, pinned_repos, wakatime_api_key, discord_webhook_url, timezone } = body;
const { is_public, leaderboard_opt_in, weekly_digest_opt_in, pinned_repos, wakatime_api_key, discord_webhook_url, timezone, bio } = body;

// Retrieve supported columns first
const settingsResult = await fetchUserSettings(user.id);
Expand All @@ -209,8 +216,8 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ error: "Failed to update settings" }, { status: 500 });
}

const { hasLeaderboardOptIn, hasPinnedRepos, hasWakatimeKey, hasWeeklyDigestOptIn, hasDiscordSettings } = settingsResult;
const updates: { is_public?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key_encrypted?: string | null; wakatime_api_key_iv?: string | null; discord_webhook_url?: string | null; timezone?: string } = {};
const { hasLeaderboardOptIn, hasPinnedRepos, hasWakatimeKey, hasWeeklyDigestOptIn, hasDiscordSettings, hasBio } = settingsResult;
const updates: { is_public?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key_encrypted?: string | null; wakatime_api_key_iv?: string | null; discord_webhook_url?: string | null; timezone?: string; bio?: string } = {};

if (is_public !== undefined && is_public !== null && typeof is_public === "boolean") {
updates.is_public = is_public;
Expand Down Expand Up @@ -244,6 +251,25 @@ export async function PATCH(req: NextRequest) {
updates.pinned_repos = pinned_repos;
}

if (!hasBio && bio !== undefined) {
return NextResponse.json(
{ error: "Bio settings are not available until the latest database migration is applied" },
{ status: 400 }
);
}

if (hasBio && bio !== undefined) {
if (typeof bio !== "string") {
return NextResponse.json({ error: "Bio must be a string" }, { status: 400 });
}

if (bio.length > 500) {
return NextResponse.json({ error: "Bio must be 500 characters or fewer" }, { status: 400 });
}

updates.bio = bio;
}

if (hasWakatimeKey && wakatime_api_key !== undefined) {
if (wakatime_api_key === "") {
updates.wakatime_api_key_encrypted = null;
Expand Down Expand Up @@ -292,6 +318,7 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({
id: (settingsResult.data as any).id,
github_login: (settingsResult.data as any).github_login,
bio: (settingsResult.data as any).bio ?? "",
is_public: (settingsResult.data as any).is_public,
leaderboard_opt_in: settingsResult.leaderboard_opt_in,
weekly_digest_opt_in: settingsResult.weekly_digest_opt_in,
Expand All @@ -304,6 +331,7 @@ export async function PATCH(req: NextRequest) {

// Query only supported columns in the returning select statement
const selectCols = ["id", "github_login", "is_public"];
if (hasBio) selectCols.push("bio");
if (hasLeaderboardOptIn) selectCols.push("leaderboard_opt_in");
if (hasWeeklyDigestOptIn) selectCols.push("weekly_digest_opt_in");
if (hasPinnedRepos) selectCols.push("pinned_repos");
Expand All @@ -328,6 +356,7 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({
id: (updated as any).id,
github_login: (updated as any).github_login,
bio: (updated as any).bio ?? "",
is_public: (updated as any).is_public,
leaderboard_opt_in: (updated as any).leaderboard_opt_in ?? false,
weekly_digest_opt_in: (updated as any).weekly_digest_opt_in ?? false,
Expand Down
106 changes: 106 additions & 0 deletions src/app/dashboard/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { redirect, useSearchParams } from "next/navigation";
import { useHeatmapTheme } from "@/hooks/useHeatmapTheme";
import PrivacySettings from "@/components/PrivacySettings";
import ConfirmModal from "@/components/ConfirmModal";
import MarkdownBio from "@/components/MarkdownBio";
import { toast } from "sonner";
import Link from "next/link";
import { useRouter } from "next/navigation";

interface UserSettings {
id: string;
github_login: string;
bio: string;
is_public: boolean;
leaderboard_opt_in: boolean;
weekly_digest_opt_in: boolean;
Expand Down Expand Up @@ -125,6 +127,9 @@ function SettingsPageContent() {
null
);
const [wakatimeKey, setWakatimeKey] = useState("");
const [bioDraft, setBioDraft] = useState("");
const [showBioPreview, setShowBioPreview] = useState(false);
const [savingBio, setSavingBio] = useState(false);
const [savingWakatime, setSavingWakatime] = useState(false);
const [discordWebhook, setDiscordWebhook] = useState("");
const [timezone, setTimezone] = useState("");
Expand Down Expand Up @@ -232,6 +237,7 @@ function SettingsPageContent() {
if (res.ok) {
const data = await res.json();
setSettings(data);
setBioDraft(data.bio ?? "");
setDiscordWebhook(data.discord_webhook_url || "");
setTimezone(data.timezone || "UTC");
}
Expand Down Expand Up @@ -445,6 +451,35 @@ function SettingsPageContent() {
}
};

const handleSaveBio = async () => {
if (!settings || bioDraft.length > 500) return;

setSavingBio(true);
try {
const res = await fetch("/api/user/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bio: bioDraft }),
});

if (res.ok) {
const updated = await res.json();
setSettings(updated);
setBioDraft(updated.bio ?? "");
setIsDirty(false);
toast.success("Bio saved successfully!");
} else {
const errorData = await res.json();
toast.error(errorData.error || "Failed to update bio");
}
} catch (error) {
console.error("Error updating bio:", error);
toast.error("Failed to update bio");
} finally {
setSavingBio(false);
}
};

const handleSaveDiscord = async () => {
if (!settings) return;
setSavingDiscord(true);
Expand Down Expand Up @@ -665,6 +700,77 @@ function SettingsPageContent() {
</div>
)}

<div className="mt-6 pt-6 border-t border-[var(--border)]">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--card-foreground)]">
Profile Bio
</h3>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
Add a short Markdown bio for your public profile.
</p>
</div>
<button
type="button"
onClick={() => setShowBioPreview((value) => !value)}
className="rounded-lg border border-[var(--border)] px-3 py-2 text-sm font-medium text-[var(--card-foreground)] transition-colors hover:bg-[var(--control)]"
>
{showBioPreview ? "Hide Preview" : "Show Preview"}
</button>
</div>

<textarea
value={bioDraft}
onChange={(e) => {
setBioDraft(e.target.value);
setIsDirty(true);
}}
maxLength={500}
rows={5}
placeholder="Write a short bio with **bold**, _italic_, `code`, or links."
className="w-full resize-y rounded-lg border border-[var(--border)] bg-[var(--control)] px-4 py-3 text-sm text-[var(--card-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
/>

{showBioPreview && (
<div className="mt-3 min-h-[128px] rounded-lg border border-[var(--border)] bg-[var(--control)] p-4 text-[var(--card-foreground)]">
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[var(--muted-foreground)]">
Live Preview
</p>
{bioDraft.trim() ? (
<MarkdownBio bio={bioDraft} />
) : (
<p className="text-sm text-[var(--muted-foreground)]">
Nothing to preview yet.
</p>
)}
</div>
)}

<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<span
className={`text-xs ${
bioDraft.length > 500
? "text-[var(--error)]"
: "text-[var(--muted-foreground)]"
}`}
>
{bioDraft.length}/500 characters
</span>
<button
type="button"
onClick={handleSaveBio}
disabled={
savingBio ||
bioDraft.length > 500 ||
bioDraft === (settings.bio ?? "")
}
className="rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--accent-foreground)] transition-opacity hover:opacity-90 disabled:opacity-60"
>
{savingBio ? "Saving..." : "Save Bio"}
</button>
</div>
</div>

<div className="mt-6 pt-6 border-t border-[var(--border)]">
<h3 className="text-sm font-semibold text-[var(--card-foreground)] mb-3">
Heatmap colour scheme
Expand Down
10 changes: 10 additions & 0 deletions src/app/u/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Metadata } from "next";
import { redirect } from "next/navigation";
import BadgeSection from "@/components/BadgeSection";
import GitHubAchievements from "@/components/GitHubAchievements";
import MarkdownBio from "@/components/MarkdownBio";
import StatsCard from "@/components/StatsCard";
import ShareProfileSection from "@/components/ShareProfileSection";
import ThemeToggle from "@/components/ThemeToggle";
Expand Down Expand Up @@ -56,6 +57,7 @@ async function fetchPublicProfile(
return {
username: user.github_login,
userId: user.id,
bio: user.bio ?? null,
isSponsor: user.is_sponsor ?? false,
repos,
contributions,
Expand Down Expand Up @@ -166,6 +168,14 @@ export default async function PublicProfilePage({
<p className="mt-2 text-[var(--muted-foreground)]">
GitHub activity and coding stats
</p>
{profile.bio && (
<div className="mt-4 max-w-2xl rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 text-[var(--card-foreground)] shadow-[var(--shadow-soft)]">
<MarkdownBio
bio={profile.bio}
className="text-[var(--muted-foreground)]"
/>
</div>
)}
</div>
{/* Download stats card button — client component */}
<div className="flex items-center gap-3">
Expand Down
Loading
Loading