Skip to content

Commit 6276317

Browse files
devakoneclaude
andcommitted
feat: add public profile pages with username system and privacy controls
Add shareable public profile pages at /u/{username} with per-repo VCP views at /u/{username}/repo/{repoSlug}. Includes a username system (auto-populated from GitHub, editable), granular privacy settings for controlling what's visible, server-rendered pages with OG metadata for social sharing, and a settings page for managing public profile options. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 404b273 commit 6276317

22 files changed

Lines changed: 2292 additions & 4 deletions

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { ImageResponse } from "@vercel/og";
2+
import { createClient } from "@supabase/supabase-js";
3+
import { getPersonaAura } from "@/lib/persona-auras";
4+
5+
export const runtime = "edge";
6+
7+
const OG_WIDTH = 1200;
8+
const OG_HEIGHT = 630;
9+
const SCALE = 2;
10+
11+
/**
12+
* GET /api/og/u/[username]
13+
* Generates a 1200x630 OG image for a public profile.
14+
*/
15+
export async function GET(
16+
_request: Request,
17+
{ params }: { params: Promise<{ username: string }> }
18+
) {
19+
try {
20+
const { username } = await params;
21+
22+
// Font loading
23+
const fontNormalPromise = fetch(
24+
new URL(
25+
"https://unpkg.com/@fontsource/space-grotesk@5.0.13/files/space-grotesk-latin-400-normal.woff"
26+
)
27+
)
28+
.then((res) => (res.ok ? res.arrayBuffer() : null))
29+
.catch(() => null);
30+
31+
const fontBoldPromise = fetch(
32+
new URL(
33+
"https://unpkg.com/@fontsource/space-grotesk@5.0.13/files/space-grotesk-latin-700-normal.woff"
34+
)
35+
)
36+
.then((res) => (res.ok ? res.arrayBuffer() : null))
37+
.catch(() => null);
38+
39+
// Supabase
40+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
41+
const supabaseKey =
42+
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
43+
44+
if (!supabaseUrl || !supabaseKey) {
45+
throw new Error("Missing Supabase credentials");
46+
}
47+
48+
const supabase = createClient(supabaseUrl, supabaseKey);
49+
50+
// Fetch user by username
51+
const { data: user } = await supabase
52+
.from("users")
53+
.select("id, username, public_profile_settings")
54+
.eq("username", username.toLowerCase())
55+
.single();
56+
57+
if (!user) {
58+
return new Response("Profile not found", { status: 404 });
59+
}
60+
61+
const settings = user.public_profile_settings as Record<string, boolean> | null;
62+
if (!settings?.profile_enabled) {
63+
return new Response("Profile not public", { status: 404 });
64+
}
65+
66+
// Fetch profile
67+
const { data: profile } = await supabase
68+
.from("user_profiles")
69+
.select(
70+
"persona_id, persona_name, persona_tagline, persona_confidence, total_repos, total_commits, axes_json, narrative_json"
71+
)
72+
.eq("user_id", user.id)
73+
.single();
74+
75+
if (!profile) {
76+
return new Response("Profile not found", { status: 404 });
77+
}
78+
79+
const personaName = profile.persona_name || "Unknown Vibe";
80+
const personaTagline = profile.persona_tagline || "Coding with vibes";
81+
const personaConfidence = profile.persona_confidence || "High";
82+
const totalRepos = profile.total_repos || 0;
83+
const totalCommits = profile.total_commits || 0;
84+
const aura = getPersonaAura(profile.persona_id);
85+
86+
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:8108";
87+
const displayUrl = `${new URL(baseUrl).host}/u/${user.username}`;
88+
const bgUrl = new URL(aura.background, baseUrl).toString();
89+
90+
const [fontDataNormal, fontDataBold] = await Promise.all([
91+
fontNormalPromise,
92+
fontBoldPromise,
93+
]);
94+
95+
const fonts: Array<{ name: string; data: ArrayBuffer; style: string; weight: number }> = [];
96+
if (fontDataNormal)
97+
fonts.push({ name: "Space Grotesk", data: fontDataNormal, style: "normal", weight: 400 });
98+
if (fontDataBold)
99+
fonts.push({ name: "Space Grotesk", data: fontDataBold, style: "normal", weight: 700 });
100+
101+
const width = OG_WIDTH * SCALE;
102+
const height = OG_HEIGHT * SCALE;
103+
const pad = 60 * SCALE;
104+
105+
return new ImageResponse(
106+
(
107+
<div
108+
style={{
109+
width: "100%",
110+
height: "100%",
111+
display: "flex",
112+
flexDirection: "column",
113+
position: "relative",
114+
backgroundColor: "#111",
115+
backgroundImage: `url(${bgUrl})`,
116+
backgroundSize: "cover",
117+
backgroundPosition: "center",
118+
fontFamily: '"Space Grotesk", sans-serif',
119+
fontSize: 24 * SCALE,
120+
}}
121+
>
122+
{/* Overlay */}
123+
<div
124+
style={{ position: "absolute", inset: 0, backgroundColor: "rgba(0,0,0,0.45)" }}
125+
/>
126+
127+
{/* Content */}
128+
<div
129+
style={{
130+
position: "relative",
131+
display: "flex",
132+
flexDirection: "column",
133+
justifyContent: "space-between",
134+
width: "100%",
135+
height: "100%",
136+
padding: `${pad}px`,
137+
}}
138+
>
139+
{/* Top */}
140+
<div style={{ display: "flex", flexDirection: "column" }}>
141+
<div
142+
style={{
143+
display: "flex",
144+
fontSize: 16 * SCALE,
145+
fontWeight: 700,
146+
letterSpacing: "0.2em",
147+
textTransform: "uppercase",
148+
color: "rgba(255,255,255,0.6)",
149+
marginBottom: 12 * SCALE,
150+
}}
151+
>
152+
@{user.username}&apos;s VCP
153+
</div>
154+
<div
155+
style={{
156+
display: "flex",
157+
fontSize: 56 * SCALE,
158+
fontWeight: 700,
159+
color: "white",
160+
lineHeight: 1,
161+
marginBottom: 8 * SCALE,
162+
maxWidth: "90%",
163+
}}
164+
>
165+
{personaName}
166+
</div>
167+
<div
168+
style={{
169+
display: "flex",
170+
fontSize: 24 * SCALE,
171+
color: "rgba(255,255,255,0.85)",
172+
fontWeight: 400,
173+
marginBottom: 12 * SCALE,
174+
maxWidth: "85%",
175+
}}
176+
>
177+
{personaTagline}
178+
</div>
179+
<div
180+
style={{
181+
display: "flex",
182+
fontSize: 18 * SCALE,
183+
fontWeight: 500,
184+
color: "rgba(255,255,255,0.6)",
185+
}}
186+
>
187+
{personaConfidence} confidence
188+
</div>
189+
</div>
190+
191+
{/* Footer */}
192+
<div
193+
style={{
194+
display: "flex",
195+
justifyContent: "space-between",
196+
borderTop: `${1 * SCALE}px solid rgba(255,255,255,0.15)`,
197+
paddingTop: 16 * SCALE,
198+
}}
199+
>
200+
<div
201+
style={{
202+
display: "flex",
203+
fontSize: 18 * SCALE,
204+
fontWeight: 500,
205+
color: "rgba(255,255,255,0.6)",
206+
}}
207+
>
208+
{displayUrl}
209+
</div>
210+
<div
211+
style={{
212+
display: "flex",
213+
fontSize: 18 * SCALE,
214+
color: "rgba(255,255,255,0.6)",
215+
}}
216+
>
217+
{totalRepos} repos &bull; {totalCommits.toLocaleString()} commits
218+
</div>
219+
</div>
220+
</div>
221+
</div>
222+
),
223+
{
224+
width,
225+
height,
226+
fonts: fonts.length > 0 ? (fonts as never) : undefined,
227+
}
228+
);
229+
} catch (e: unknown) {
230+
const message = e instanceof Error ? e.message : "Unknown error";
231+
console.error("OG image error:", message);
232+
return new Response(`Server Error: ${message}`, { status: 500 });
233+
}
234+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { NextResponse } from "next/server";
2+
import { createSupabaseServerClient } from "@/lib/supabase/server";
3+
import { createSupabaseServiceClient } from "@/lib/supabase/service";
4+
import type { PublicProfileSettings } from "@/types/public-profile";
5+
import { DEFAULT_PUBLIC_PROFILE_SETTINGS } from "@/types/public-profile";
6+
7+
export const runtime = "nodejs";
8+
9+
const VALID_KEYS = new Set(Object.keys(DEFAULT_PUBLIC_PROFILE_SETTINGS));
10+
11+
/**
12+
* GET /api/profile/public-settings
13+
* Returns the current user's public profile settings.
14+
*/
15+
export async function GET() {
16+
const supabase = await createSupabaseServerClient();
17+
const {
18+
data: { user },
19+
} = await supabase.auth.getUser();
20+
21+
if (!user) {
22+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
23+
}
24+
25+
const { data, error } = await supabase
26+
.from("users")
27+
.select()
28+
.eq("id", user.id)
29+
.single();
30+
31+
if (error) {
32+
return NextResponse.json({ error: "fetch_failed" }, { status: 500 });
33+
}
34+
35+
const row = data as { username?: string | null; public_profile_settings?: Record<string, unknown> | null } | null;
36+
const settings = {
37+
...DEFAULT_PUBLIC_PROFILE_SETTINGS,
38+
...(row?.public_profile_settings as Partial<PublicProfileSettings> | null),
39+
};
40+
41+
return NextResponse.json({
42+
username: row?.username ?? null,
43+
settings,
44+
});
45+
}
46+
47+
/**
48+
* PUT /api/profile/public-settings
49+
* Updates the current user's public profile settings.
50+
*/
51+
export async function PUT(request: Request) {
52+
const supabase = await createSupabaseServerClient();
53+
const {
54+
data: { user },
55+
} = await supabase.auth.getUser();
56+
57+
if (!user) {
58+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
59+
}
60+
61+
let body: { settings?: Partial<PublicProfileSettings> };
62+
try {
63+
body = await request.json();
64+
} catch {
65+
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
66+
}
67+
68+
if (!body.settings || typeof body.settings !== "object") {
69+
return NextResponse.json({ error: "settings_required" }, { status: 400 });
70+
}
71+
72+
// Validate all keys are known boolean settings
73+
for (const [key, value] of Object.entries(body.settings)) {
74+
if (!VALID_KEYS.has(key)) {
75+
return NextResponse.json({ error: `Unknown setting: ${key}` }, { status: 400 });
76+
}
77+
if (typeof value !== "boolean") {
78+
return NextResponse.json({ error: `Setting ${key} must be a boolean` }, { status: 400 });
79+
}
80+
}
81+
82+
// Fetch current user data
83+
const { data: currentData } = await supabase
84+
.from("users")
85+
.select()
86+
.eq("id", user.id)
87+
.single();
88+
89+
const currentRow = currentData as { username?: string | null; public_profile_settings?: Record<string, unknown> | null } | null;
90+
91+
// If enabling profile, verify username exists
92+
if (body.settings.profile_enabled && !currentRow?.username) {
93+
return NextResponse.json(
94+
{ error: "You must set a username before enabling your public profile" },
95+
{ status: 400 }
96+
);
97+
}
98+
99+
const merged: PublicProfileSettings = {
100+
...DEFAULT_PUBLIC_PROFILE_SETTINGS,
101+
...(currentRow?.public_profile_settings as Partial<PublicProfileSettings> | null),
102+
...body.settings,
103+
};
104+
105+
const service = createSupabaseServiceClient();
106+
const { error: updateError } = await service
107+
.from("users")
108+
.update({ public_profile_settings: JSON.parse(JSON.stringify(merged)) })
109+
.eq("id", user.id);
110+
111+
if (updateError) {
112+
console.error("Settings update failed:", updateError);
113+
return NextResponse.json({ error: "update_failed" }, { status: 500 });
114+
}
115+
116+
return NextResponse.json({ settings: merged });
117+
}

0 commit comments

Comments
 (0)