diff --git a/src/app/api/user/settings/route.ts b/src/app/api/user/settings/route.ts index 572e8e14..8b4630d2 100644 --- a/src/app/api/user/settings/route.ts +++ b/src/app/api/user/settings/route.ts @@ -10,11 +10,21 @@ import { cacheGet, cacheSet, cacheDelete } from "@/lib/metrics-cache"; export const dynamic = "force-dynamic"; +const VALID_WIDGETS = ["streak", "contributions", "languages", "prs"] as const; +type WidgetKey = (typeof VALID_WIDGETS)[number]; + +function sanitizePublicWidgets(input: unknown): WidgetKey[] { + if (!Array.isArray(input)) return ["streak", "contributions"]; + return input.filter((w): w is WidgetKey => + typeof w === "string" && (VALID_WIDGETS as readonly string[]).includes(w) + ); +} + async function fetchUserSettings(userId: string) { - // Tier 1: All columns + // Tier 1: All columns (including public_widgets added by 20260608000000 migration) const res1 = await supabaseAdmin .from("users") - .select("id, github_login, bio, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, weekly_digest_opt_in, discord_webhook_url, timezone, webhook_url, discord_muted_until") + .select("id, github_login, bio, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, weekly_digest_opt_in, discord_webhook_url, timezone, webhook_url, discord_muted_until, public_widgets") .eq("id", userId) .single(); @@ -30,6 +40,7 @@ async function fetchUserSettings(userId: string) { hasBio: true, hasWebhookUrl: true, hasDiscordMutedUntil: true, + hasPublicWidgets: 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 || [], @@ -39,6 +50,7 @@ async function fetchUserSettings(userId: string) { timezone: (res1.data as any).timezone || "UTC", webhook_url: (res1.data as any).webhook_url || null, discord_muted_until: (res1.data as any).discord_muted_until || null, + public_widgets: sanitizePublicWidgets((res1.data as any).public_widgets), }; } @@ -54,6 +66,7 @@ async function fetchUserSettings(userId: string) { hasBio: false, hasWebhookUrl: false, hasDiscordMutedUntil: false, + hasPublicWidgets: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -63,13 +76,14 @@ async function fetchUserSettings(userId: string) { timezone: "UTC", webhook_url: null, discord_muted_until: null, + public_widgets: ["streak", "contributions"] as WidgetKey[], }; } - // Tier 2: Without bio, for deployments that have not run the latest migration. + // Tier 2: Without public_widgets (deployments that haven't run the latest migration yet) const res2 = await supabaseAdmin .from("users") - .select("id, github_login, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, webhook_url") + .select("id, github_login, bio, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, weekly_digest_opt_in, discord_webhook_url, timezone, webhook_url, discord_muted_until") .eq("id", userId) .single(); @@ -80,20 +94,22 @@ async function fetchUserSettings(userId: string) { hasLeaderboardOptIn: true, hasPinnedRepos: true, hasWakatimeKey: true, - hasWeeklyDigestOptIn: false, - hasDiscordSettings: false, - hasBio: false, + hasWeeklyDigestOptIn: true, + hasDiscordSettings: true, + hasBio: true, hasWebhookUrl: true, - hasDiscordMutedUntil: false, + hasDiscordMutedUntil: true, + hasPublicWidgets: false, leaderboard_opt_in: (res2.data as any).leaderboard_opt_in ?? false, - weekly_digest_opt_in: false, + weekly_digest_opt_in: (res2.data as any).weekly_digest_opt_in ?? false, 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", + discord_webhook_url: (res2.data as any).discord_webhook_url || null, + timezone: (res2.data as any).timezone || "UTC", webhook_url: (res2.data as any).webhook_url || null, - discord_muted_until: null, + discord_muted_until: (res2.data as any).discord_muted_until || null, + public_widgets: ["streak", "contributions"] as WidgetKey[], }; } @@ -109,6 +125,7 @@ async function fetchUserSettings(userId: string) { hasBio: false, hasWebhookUrl: false, hasDiscordMutedUntil: false, + hasPublicWidgets: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -118,13 +135,14 @@ async function fetchUserSettings(userId: string) { timezone: "UTC", webhook_url: null, discord_muted_until: null, + public_widgets: ["streak", "contributions"] as WidgetKey[], }; } - // Tier 3: Without public_since and show_weekly_goals (added by migrations) + // Tier 3: Without bio, for deployments that have not run the latest migration. const res3 = await supabaseAdmin .from("users") - .select("id, github_login, is_public, public_since, show_weekly_goals") + .select("id, github_login, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, webhook_url") .eq("id", userId) .single(); @@ -132,13 +150,41 @@ async function fetchUserSettings(userId: string) { return { data: res3.data as any, error: null, + hasLeaderboardOptIn: true, + hasPinnedRepos: true, + hasWakatimeKey: true, + hasWeeklyDigestOptIn: false, + hasDiscordSettings: false, + hasBio: false, + hasWebhookUrl: true, + hasDiscordMutedUntil: false, + hasPublicWidgets: false, + leaderboard_opt_in: (res3.data as any).leaderboard_opt_in ?? false, + weekly_digest_opt_in: false, + pinned_repos: (res3.data as any).pinned_repos || [], + wakatime_api_key_encrypted: (res3.data as any).wakatime_api_key_encrypted || null, + wakatime_api_key_iv: (res3.data as any).wakatime_api_key_iv || null, + discord_webhook_url: null, + timezone: "UTC", + webhook_url: (res3.data as any).webhook_url || null, + discord_muted_until: null, + public_widgets: ["streak", "contributions"] as WidgetKey[], + }; + } + + if (res3.error.code !== "42703") { + return { + data: null, + error: res3.error, hasLeaderboardOptIn: false, hasPinnedRepos: false, hasWakatimeKey: false, hasWeeklyDigestOptIn: false, hasDiscordSettings: false, hasBio: false, + hasWebhookUrl: false, hasDiscordMutedUntil: false, + hasPublicWidgets: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -146,14 +192,47 @@ async function fetchUserSettings(userId: string) { wakatime_api_key_iv: null, discord_webhook_url: null, timezone: "UTC", + webhook_url: null, discord_muted_until: null, + public_widgets: ["streak", "contributions"] as WidgetKey[], }; } - if (res3.error.code !== "42703") { + // Tier 4: Without public_since and show_weekly_goals (added by migrations) + const res4 = await supabaseAdmin + .from("users") + .select("id, github_login, is_public, public_since, show_weekly_goals") + .eq("id", userId) + .single(); + + if (!res4.error) { + return { + data: res4.data as any, + error: null, + hasLeaderboardOptIn: false, + hasPinnedRepos: false, + hasWakatimeKey: false, + hasWeeklyDigestOptIn: false, + hasDiscordSettings: false, + hasBio: false, + hasDiscordMutedUntil: false, + hasPublicWidgets: false, + leaderboard_opt_in: false, + weekly_digest_opt_in: false, + pinned_repos: [] as string[], + wakatime_api_key_encrypted: null, + wakatime_api_key_iv: null, + discord_webhook_url: null, + timezone: "UTC", + discord_muted_until: null, + public_widgets: ["streak", "contributions"] as WidgetKey[], + }; + } + + if (res4.error.code !== "42703") { return { data: null, - error: res3.error, + error: res4.error, hasLeaderboardOptIn: false, hasPinnedRepos: false, hasWakatimeKey: false, @@ -161,6 +240,7 @@ async function fetchUserSettings(userId: string) { hasDiscordSettings: false, hasBio: false, hasDiscordMutedUntil: false, + hasPublicWidgets: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -169,19 +249,20 @@ async function fetchUserSettings(userId: string) { discord_webhook_url: null, timezone: "UTC", discord_muted_until: null, + public_widgets: ["streak", "contributions"] as WidgetKey[], }; } - // Tier 4: Absolute minimum — columns guaranteed in every schema version - const res4 = await supabaseAdmin + // Tier 5: Absolute minimum — columns guaranteed in every schema version + const res5 = await supabaseAdmin .from("users") .select("id, github_login, is_public") .eq("id", userId) .single(); - if (!res4.error) { + if (!res5.error) { return { - data: res4.data as any, + data: res5.data as any, error: null, hasLeaderboardOptIn: false, hasPinnedRepos: false, @@ -190,6 +271,7 @@ async function fetchUserSettings(userId: string) { hasDiscordSettings: false, hasBio: false, hasDiscordMutedUntil: false, + hasPublicWidgets: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -198,12 +280,13 @@ async function fetchUserSettings(userId: string) { discord_webhook_url: null, timezone: "UTC", discord_muted_until: null, + public_widgets: ["streak", "contributions"] as WidgetKey[], }; } return { data: null, - error: res4.error, + error: res5.error, hasLeaderboardOptIn: false, hasPinnedRepos: false, hasWakatimeKey: false, @@ -211,6 +294,7 @@ async function fetchUserSettings(userId: string) { hasDiscordSettings: false, hasBio: false, hasDiscordMutedUntil: false, + hasPublicWidgets: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -219,6 +303,7 @@ async function fetchUserSettings(userId: string) { discord_webhook_url: null, timezone: "UTC", discord_muted_until: null, + public_widgets: ["streak", "contributions"] as WidgetKey[], }; } @@ -267,6 +352,7 @@ export async function GET(req: NextRequest) { timezone: result.timezone, webhook_url: result.webhook_url ?? null, discord_muted_until: result.discord_muted_until ?? null, + public_widgets: result.public_widgets, }; await cacheSet(cacheKey, response, SETTINGS_TTL); @@ -290,14 +376,27 @@ export async function PATCH(req: NextRequest) { ); } - let body: { is_public?: boolean; show_weekly_goals?: 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; webhook_url?: string | null; discord_muted_until?: string | null }; + let body: { + is_public?: boolean; + show_weekly_goals?: 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; + webhook_url?: string | null; + discord_muted_until?: string | null; + public_widgets?: string[]; + }; try { body = await req.json(); } catch (e) { return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } - const { is_public, show_weekly_goals, leaderboard_opt_in, weekly_digest_opt_in, pinned_repos, wakatime_api_key, discord_webhook_url, timezone, bio, webhook_url, discord_muted_until } = body; + const { is_public, show_weekly_goals, leaderboard_opt_in, weekly_digest_opt_in, pinned_repos, wakatime_api_key, discord_webhook_url, timezone, bio, webhook_url, discord_muted_until, public_widgets } = body; // Retrieve supported columns first const settingsResult = await fetchUserSettings(user.id); @@ -306,8 +405,23 @@ export async function PATCH(req: NextRequest) { return NextResponse.json({ error: "Failed to update settings" }, { status: 500 }); } - const { hasLeaderboardOptIn, hasPinnedRepos, hasWakatimeKey, hasWeeklyDigestOptIn, hasDiscordSettings, hasBio, hasWebhookUrl, hasDiscordMutedUntil } = settingsResult; - const updates: { is_public?: boolean; public_since?: string | null; show_weekly_goals?: 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; webhook_url?: string | null; discord_muted_until?: string | null } = {}; + const { hasLeaderboardOptIn, hasPinnedRepos, hasWakatimeKey, hasWeeklyDigestOptIn, hasDiscordSettings, hasBio, hasWebhookUrl, hasDiscordMutedUntil, hasPublicWidgets } = settingsResult; + const updates: { + is_public?: boolean; + public_since?: string | null; + show_weekly_goals?: 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; + webhook_url?: string | null; + discord_muted_until?: string | null; + public_widgets?: WidgetKey[]; + } = {}; if (is_public !== undefined && is_public !== null && typeof is_public === "boolean") { updates.is_public = is_public; @@ -422,6 +536,11 @@ export async function PATCH(req: NextRequest) { } } + // Handle public_widgets update + if (hasPublicWidgets && public_widgets !== undefined && Array.isArray(public_widgets)) { + updates.public_widgets = sanitizePublicWidgets(public_widgets); + } + // If there are no updates (or none that are supported by the schema) if (Object.keys(updates).length === 0) { return NextResponse.json({ @@ -439,6 +558,7 @@ export async function PATCH(req: NextRequest) { timezone: settingsResult.timezone, webhook_url: settingsResult.webhook_url ?? null, discord_muted_until: settingsResult.discord_muted_until ?? null, + public_widgets: settingsResult.public_widgets, }); } @@ -455,6 +575,7 @@ export async function PATCH(req: NextRequest) { if (hasDiscordSettings) selectCols.push("discord_webhook_url", "timezone"); if (hasDiscordMutedUntil) selectCols.push("discord_muted_until"); if (hasWebhookUrl) selectCols.push("webhook_url"); + if (hasPublicWidgets) selectCols.push("public_widgets"); const { data: updated, error: updateError } = await supabaseAdmin .from("users") @@ -502,5 +623,8 @@ export async function PATCH(req: NextRequest) { timezone: (updated as any).timezone || "UTC", webhook_url: (updated as any).webhook_url ?? null, discord_muted_until: (updated as any).discord_muted_until ?? null, + public_widgets: hasPublicWidgets + ? sanitizePublicWidgets((updated as any).public_widgets) + : settingsResult.public_widgets, }); -} +} \ No newline at end of file diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 40611b09..09b86918 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -24,6 +24,7 @@ interface UserSettings { is_public: boolean; public_since?: string | null; show_weekly_goals?: boolean; + public_widgets?: string[]; leaderboard_opt_in: boolean; weekly_digest_opt_in: boolean; has_wakatime_key?: boolean; @@ -187,6 +188,8 @@ function SettingsPageContent() { const [discordMutedUntil, setDiscordMutedUntil] = useState(null); const [muteDuration, setMuteDuration] = useState(1); const [isDirty, setIsDirty] = useState(false); + const [publicWidgets, setPublicWidgets] = useState(["streak", "contributions"]); + const [savingWidgets, setSavingWidgets] = useState(false); const [showConfirmModal, setShowConfirmModal] = useState(false); const [pendingPath, setPendingPath] = useState(null); @@ -294,6 +297,7 @@ function SettingsPageContent() { setTimezone(data.timezone || "UTC"); setDiscordMutedUntil(data.discord_muted_until ?? null); setWebhookUrl(data.webhook_url ?? null); + setPublicWidgets(data.public_widgets ?? ["streak", "contributions"]); } } catch (error) { console.error("Failed to load settings:", error); @@ -486,6 +490,45 @@ function SettingsPageContent() { } }; + const WIDGET_OPTIONS = [ + { key: "streak", label: "Streak stats", description: "Current streak, longest streak, active days" }, + { key: "contributions", label: "Contribution graph", description: "30-day commit activity heatmap" }, + { key: "languages", label: "Top languages", description: "Language breakdown from recent repos" }, + { key: "prs", label: "Pull requests", description: "Total PRs authored" }, + ] as const; + + const handleToggleWidget = (key: string, checked: boolean) => { + setPublicWidgets((prev) => + checked ? [...prev, key] : prev.filter((w) => w !== key) + ); + setIsDirty(true); + }; + + const handleSaveWidgets = async () => { + if (!settings) return; + setSavingWidgets(true); + try { + const res = await fetch("/api/user/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ public_widgets: publicWidgets }), + }); + if (res.ok) { + const updated = await res.json(); + setSettings(updated); + setPublicWidgets(updated.public_widgets ?? ["streak", "contributions"]); + setIsDirty(false); + toast.success("Widget visibility saved!"); + } else { + toast.error("Failed to save widget settings."); + } + } catch { + toast.error("Failed to save widget settings."); + } finally { + setSavingWidgets(false); + } + }; + const handleSaveWakatime = async () => { if (!settings) return; setSavingWakatime(true); @@ -915,6 +958,47 @@ function SettingsPageContent() { )} + {/* ── Public Widget Selector ────────────────────────────────── */} + {settings.is_public && ( +
+
+

+ Visible Widgets +

+

+ Choose which stats are shown on your public profile. +

+
+
+ {WIDGET_OPTIONS.map(({ key, label, description }) => ( + + ))} +
+ +
+ )} + {/* ── Profile Bio with Markdown preview ─────────────────────── */}
diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx index 6c0105f3..956f7065 100644 --- a/src/app/u/[username]/page.tsx +++ b/src/app/u/[username]/page.tsx @@ -3,6 +3,7 @@ export const dynamic = "force-dynamic"; import ProfileThemeWrapper from "@/components/ProfileThemeWrapper"; import { Metadata } from "next"; import Link from "next/link"; +import Image from "next/image"; import { redirect } from "next/navigation"; import { getServerSession } from "next-auth"; import BadgeSection from "@/components/BadgeSection"; @@ -18,6 +19,7 @@ import { getUserByGithubId, getUserByUsername } from "@/lib/supabase"; import { fetchPublicProfile as fetchPublicProfileLib, type PublicProfileData, + type PublicWidgetKey, } from "@/lib/public-profile-data"; // Extend tracking structures to forward gamification flags seamlessly downstream @@ -43,6 +45,7 @@ function getVisualRegressionMockProfile( bio: "Mock public profile used for deterministic visual regression coverage.", isSponsor: true, publicGists: 4, + memberSince: "2023-01-15T00:00:00Z", repos: [ { name: "playwright-user/devtrack-ui", @@ -98,6 +101,7 @@ function getVisualRegressionMockProfile( total: 4, percentage: 75, }, + publicWidgets: ["streak", "contributions", "languages", "prs"], isNightOwl: true, isEarlyBird: false, }; @@ -288,6 +292,7 @@ export default async function PublicProfilePage({ const avatarUrl = `https://avatars.githubusercontent.com/${profile.username}`; const topRepo = profile.repos[0]?.name ?? ""; const gistsUrl = `https://gist.github.com/${profile.username}`; + const githubUrl = `https://github.com/${profile.username}`; const showCompareButton = loggedInUsername !== null && loggedInUsername.toLowerCase() !== profile.username.toLowerCase(); @@ -298,20 +303,48 @@ export default async function PublicProfilePage({ `/u/${profile.username}` )}`; + const widgets = profile.publicWidgets ?? ["streak", "contributions"]; + const showStreak = widgets.includes("streak"); + const showContributions = widgets.includes("contributions"); + const showLanguages = widgets.includes("languages"); + const showPRs = widgets.includes("prs"); + + const memberSinceFormatted = profile.memberSince + ? new Date(profile.memberSince).toLocaleDateString("en-US", { + month: "long", + year: "numeric", + }) + : null; + return (
-
-
+ + {/* ── Header: Avatar + Identity ── */} +
+ {/* GitHub avatar */} +
+ {`${profile.username}'s +
+ + {/* Identity block */} +

- @{profile.username}'s Profile + @{profile.username} {profile.isSponsor && } - - {/* 🎯 Render Server-Calculated Time Distribution Badges Safely on Public Profile View */} + + {/* Night Owl / Early Bird badges */} {profile.isNightOwl && ( - @@ -319,8 +352,8 @@ export default async function PublicProfilePage({ )} {profile.isEarlyBird && ( - @@ -330,41 +363,62 @@ export default async function PublicProfilePage({

-

+ +

GitHub activity and coding stats

- {profile.publicGists > 0 && ( -
+ + {/* Member since + View on GitHub */} +
+ {memberSinceFormatted && ( + Member since {memberSinceFormatted} + )} + + + View on GitHub + + {profile.publicGists > 0 && ( {profile.publicGists} Gists -
- )} - {compareHref && ( - - Compare with me - - )} - {!loggedInUsername && ( - - Log in to compare - - )} + )} +
+ + {/* Compare buttons */} +
+ {compareHref && ( + + Compare with me + + )} + {!loggedInUsername && ( + + Log in to compare + + )} +
-
- {/* Download stats card button — client component */} -
+ + {/* Stats card download */} +
-
+ {/* Share section */}
- {/* Row 1: Contribution graph + Streak */} -
-
- + {/* Row 1: Contribution graph + Streak (gated by public_widgets) */} + {(showContributions || showStreak) && ( +
+ {showContributions && ( +
+ +
+ )} + {showStreak && ( +
+ +
+ )}
-
- + )} + + {/* Languages (gated) */} + {showLanguages && profile.topLanguages.length > 0 && ( +
+
-
+ )} + + {/* Pull Requests (gated) */} + {showPRs && ( +
+ +
+ )} {/* Custom Spotlight Repositories */} {profile.spotlightRepos?.length ? ( @@ -412,12 +486,12 @@ export default async function PublicProfilePage({
)} - {/* Row 2: Top repos */} + {/* Top repos */}
- {/* Row 3: GitHub achievements */} + {/* GitHub achievements */}
- {/* Row 4: Get your badge */} + {/* Get your badge */}
+ + {/* Powered by DevTrack footer badge */} +
); @@ -562,6 +651,50 @@ function PublicStreakTracker({ streak }: { streak: any }) { ); } +function PublicLanguageBreakdown({ + languages, +}: { + languages: Array<{ name: string; count: number; percentage: number }>; +}) { + return ( +
+

+ Top Languages +

+
+ {languages.map((lang) => ( +
+
+ {lang.name} + {lang.percentage}% +
+
+
+
+
+ ))} +
+
+ ); +} + +function PublicPRCount({ pullRequests }: { pullRequests: number }) { + return ( +
+

+ Pull Requests +

+
+ {pullRequests.toLocaleString()} + total PRs authored +
+
+ ); +} + function PublicWeeklyGoalProgress({ progress, }: { diff --git a/src/lib/public-profile-data.ts b/src/lib/public-profile-data.ts index c09b2bd2..024776f0 100644 --- a/src/lib/public-profile-data.ts +++ b/src/lib/public-profile-data.ts @@ -38,11 +38,14 @@ export interface WeeklyGoalProgress { percentage: number; } +export type PublicWidgetKey = "streak" | "contributions" | "languages" | "prs"; + export interface PublicProfileData { username: string; bio: string | null; isSponsor: boolean; publicGists: number; + memberSince: string | null; repos: TopRepo[]; contributions: ContributionData; streak: StreakData; @@ -53,6 +56,7 @@ export interface PublicProfileData { spotlightRepos?: PinnedRepoDetails[]; contributionMilestones?: { label: string; achievedAt: string | null }[]; weeklyGoalProgress: WeeklyGoalProgress | null; + publicWidgets: PublicWidgetKey[]; } async function ghFetch(url: string, token?: string): Promise { @@ -332,11 +336,30 @@ export async function fetchPublicProfile( .order("streak_count", { ascending: false }) .limit(5); + // Fetch public_widgets preference (added by 20260608000000 migration; falls back gracefully) + let publicWidgets: PublicWidgetKey[] = ["streak", "contributions"]; + try { + const { data: widgetsRow } = await supabaseAdmin + .from("users") + .select("public_widgets") + .eq("id", user.id) + .single(); + if (widgetsRow?.public_widgets && Array.isArray(widgetsRow.public_widgets)) { + const valid: PublicWidgetKey[] = ["streak", "contributions", "languages", "prs"]; + publicWidgets = (widgetsRow.public_widgets as string[]).filter( + (w): w is PublicWidgetKey => valid.includes(w as PublicWidgetKey) + ); + } + } catch { + // Column may not exist yet; use defaults + } + return { username: user.github_login, bio: user.bio ?? null, isSponsor: user.is_sponsor ?? false, publicGists, + memberSince: user.created_at ?? null, repos, contributions, streak, @@ -350,5 +373,6 @@ export async function fetchPublicProfile( achievedAt: m.achieved_at ?? null, })), weeklyGoalProgress, + publicWidgets, }; -} +} \ No newline at end of file diff --git a/supabase/migrations/20260608000000_add_public_widgets.sql b/supabase/migrations/20260608000000_add_public_widgets.sql new file mode 100644 index 00000000..2df67289 --- /dev/null +++ b/supabase/migrations/20260608000000_add_public_widgets.sql @@ -0,0 +1,10 @@ +-- Add public_widgets jsonb column to users table +-- This stores which widgets the user has opted to show on their public profile. +-- Default shows streak and contributions; languages and PRs are opt-in. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS public_widgets jsonb NOT NULL DEFAULT '["streak","contributions"]'::jsonb; + +COMMENT ON COLUMN users.public_widgets IS + 'Array of widget keys the user wants visible on their public /u/[username] page. ' + 'Allowed values: "streak", "contributions", "languages", "prs".'; + \ No newline at end of file