From ce86eaf55be2502654fcc73ad00dabcbd4650524 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?=
Date: Tue, 27 Jan 2026 13:49:16 +0300
Subject: [PATCH] feat(user): add bio and custom links functionality
---
messages/en.json | 26 +++
.../migration.sql | 3 +
prisma/schema.prisma | 2 +
src/app/[username]/page.tsx | 36 ++--
src/app/api/user/profile/route.ts | 14 +-
src/app/settings/page.tsx | 2 +
src/components/settings/profile-form.tsx | 180 +++++++++++++++++-
src/components/user/profile-links.tsx | 178 +++++++++++++++++
8 files changed, 426 insertions(+), 15 deletions(-)
create mode 100644 prisma/migrations/20260127100000_add_user_bio_and_custom_links/migration.sql
create mode 100644 src/components/user/profile-links.tsx
diff --git a/messages/en.json b/messages/en.json
index d06b4cea..43cc1281 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -837,6 +837,32 @@
"profileUrl": "Your profile URL",
"email": "Email",
"emailCannotChange": "Email cannot be changed",
+ "bio": "Bio",
+ "bioPlaceholder": "Tell us a bit about yourself...",
+ "bioCharCount": "{count}/250",
+ "customLinks": "Links",
+ "customLinksDescription": "Add links to your social profiles and websites",
+ "addLink": "Add Link",
+ "linkType": "Type",
+ "linkUrl": "URL",
+ "linkLabel": "Label (optional)",
+ "linkLabelPlaceholder": "Custom label",
+ "removeLink": "Remove",
+ "maxLinksReached": "Maximum 5 links allowed",
+ "invalidUrl": "Please enter a valid URL",
+ "linkTypes": {
+ "website": "Website",
+ "github": "GitHub",
+ "twitter": "X (Twitter)",
+ "linkedin": "LinkedIn",
+ "instagram": "Instagram",
+ "youtube": "YouTube",
+ "twitch": "Twitch",
+ "discord": "Discord",
+ "mastodon": "Mastodon",
+ "bluesky": "Bluesky",
+ "sponsor": "Sponsor"
+ },
"saveChanges": "Save Changes",
"profileUpdated": "Profile updated successfully",
"usernameTaken": "This username is already taken"
diff --git a/prisma/migrations/20260127100000_add_user_bio_and_custom_links/migration.sql b/prisma/migrations/20260127100000_add_user_bio_and_custom_links/migration.sql
new file mode 100644
index 00000000..c23cba81
--- /dev/null
+++ b/prisma/migrations/20260127100000_add_user_bio_and_custom_links/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN "bio" VARCHAR(250);
+ALTER TABLE "users" ADD COLUMN "customLinks" JSONB;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c2999099..1c1ab567 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -15,6 +15,8 @@ model User {
name String?
password String?
avatar String?
+ bio String? @db.VarChar(250)
+ customLinks Json? // Array of {type: string, url: string, label?: string}
role UserRole @default(USER)
locale String @default("en")
emailVerified DateTime?
diff --git a/src/app/[username]/page.tsx b/src/app/[username]/page.tsx
index e0e523be..c2a04816 100644
--- a/src/app/[username]/page.tsx
+++ b/src/app/[username]/page.tsx
@@ -18,6 +18,7 @@ import { Masonry } from "@/components/ui/masonry";
import { McpServerPopup } from "@/components/mcp/mcp-server-popup";
import { PrivatePromptsNote } from "@/components/prompts/private-prompts-note";
import { ActivityChartWrapper } from "@/components/user/activity-chart-wrapper";
+import { ProfileLinks, type CustomLink } from "@/components/user/profile-links";
interface UserProfilePageProps {
params: Promise<{ username: string }>;
@@ -80,6 +81,8 @@ export default async function UserProfilePage({ params, searchParams }: UserProf
role: true,
verified: true,
createdAt: true,
+ bio: true,
+ customLinks: true,
_count: {
select: {
prompts: true,
@@ -465,6 +468,26 @@ export default async function UserProfilePage({ params, searchParams }: UserProf
+ {/* Actions - mobile only */}
+
+ {config.features.mcp !== false && }
+ {isOwner && (
+
+ )}
+
+
+ {/* Bio and Social Links */}
+
+
{/* Stats - stacked on mobile, inline on desktop */}
@@ -488,19 +511,6 @@ export default async function UserProfilePage({ params, searchParams }: UserProf
- {/* Actions - mobile only */}
-
- {config.features.mcp !== false && }
- {isOwner && (
-
- )}
-
-
{/* Activity Chart - above tabs */}
diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts
index e753159b..b080cd8f 100644
--- a/src/app/api/user/profile/route.ts
+++ b/src/app/api/user/profile/route.ts
@@ -3,6 +3,12 @@ import { z } from "zod";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
+const customLinkSchema = z.object({
+ type: z.enum(["website", "github", "twitter", "linkedin", "instagram", "youtube", "twitch", "discord", "mastodon", "bluesky", "sponsor"]),
+ url: z.string().url(),
+ label: z.string().max(30).optional(),
+});
+
const updateProfileSchema = z.object({
name: z.string().min(1).max(100),
username: z
@@ -11,6 +17,8 @@ const updateProfileSchema = z.object({
.max(30)
.regex(/^[a-zA-Z0-9_]+$/),
avatar: z.string().url().optional().or(z.literal("")),
+ bio: z.string().max(250).optional().or(z.literal("")),
+ customLinks: z.array(customLinkSchema).max(5).optional(),
});
export async function PATCH(request: NextRequest) {
@@ -33,7 +41,7 @@ export async function PATCH(request: NextRequest) {
);
}
- const { name, username, avatar } = parsed.data;
+ const { name, username, avatar, bio, customLinks } = parsed.data;
// Check if username is taken by another user
if (username !== session.user.username) {
@@ -57,6 +65,8 @@ export async function PATCH(request: NextRequest) {
name,
username,
avatar: avatar || null,
+ bio: bio || null,
+ customLinks: customLinks && customLinks.length > 0 ? customLinks : null,
},
select: {
id: true,
@@ -64,6 +74,8 @@ export async function PATCH(request: NextRequest) {
username: true,
email: true,
avatar: true,
+ bio: true,
+ customLinks: true,
},
});
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index dd91ebde..1adc8da9 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -25,6 +25,8 @@ export default async function SettingsPage() {
verified: true,
apiKey: true,
mcpPromptsPublicByDefault: true,
+ bio: true,
+ customLinks: true,
},
});
diff --git a/src/components/settings/profile-form.tsx b/src/components/settings/profile-form.tsx
index 9262136c..50efc97f 100644
--- a/src/components/settings/profile-form.tsx
+++ b/src/components/settings/profile-form.tsx
@@ -7,15 +7,32 @@ import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
-import { Loader2, BadgeCheck, ExternalLink, X } from "lucide-react";
+import { Loader2, BadgeCheck, ExternalLink, X, Plus, Trash2, Globe, Github, Linkedin, Instagram, Youtube, Twitch, Heart, ChevronUp, ChevronDown } from "lucide-react";
+
+function XTwitterIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Textarea } from "@/components/ui/textarea";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import type { CustomLink, CustomLinkType } from "@/components/user/profile-links";
import { toast } from "sonner";
import { analyticsProfile } from "@/lib/analytics";
+const customLinkSchema = z.object({
+ type: z.enum(["website", "github", "twitter", "linkedin", "instagram", "youtube", "twitch", "discord", "mastodon", "bluesky", "sponsor"]),
+ url: z.string().url("Please enter a valid URL"),
+ label: z.string().max(30).optional(),
+});
+
const profileSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
username: z
@@ -24,8 +41,24 @@ const profileSchema = z.object({
.max(30)
.regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"),
avatar: z.string().url().optional().or(z.literal("")),
+ bio: z.string().max(250).optional().or(z.literal("")),
+ customLinks: z.array(customLinkSchema).max(5).optional(),
});
+const LINK_TYPES: { value: CustomLinkType; label: string; icon: React.ElementType }[] = [
+ { value: "website", label: "Website", icon: Globe },
+ { value: "github", label: "GitHub", icon: Github },
+ { value: "twitter", label: "X (Twitter)", icon: XTwitterIcon },
+ { value: "linkedin", label: "LinkedIn", icon: Linkedin },
+ { value: "instagram", label: "Instagram", icon: Instagram },
+ { value: "youtube", label: "YouTube", icon: Youtube },
+ { value: "twitch", label: "Twitch", icon: Twitch },
+ { value: "discord", label: "Discord", icon: Globe },
+ { value: "mastodon", label: "Mastodon", icon: Globe },
+ { value: "bluesky", label: "Bluesky", icon: Globe },
+ { value: "sponsor", label: "Sponsor", icon: Heart },
+];
+
type ProfileFormValues = z.infer;
interface ProfileFormProps {
@@ -36,6 +69,8 @@ interface ProfileFormProps {
email: string;
avatar: string | null;
verified: boolean;
+ bio?: string | null;
+ customLinks?: CustomLink[] | null;
};
showVerifiedSection?: boolean;
}
@@ -67,9 +102,37 @@ export function ProfileForm({ user, showVerifiedSection = false }: ProfileFormPr
name: user.name || "",
username: user.username,
avatar: user.avatar || "",
+ bio: user.bio || "",
+ customLinks: (user.customLinks as CustomLink[]) || [],
},
});
+ const customLinks = form.watch("customLinks") || [];
+ const bioValue = form.watch("bio") || "";
+
+ const addLink = () => {
+ if (customLinks.length >= 5) return;
+ form.setValue("customLinks", [...customLinks, { type: "website" as CustomLinkType, url: "", label: "" }]);
+ };
+
+ const removeLink = (index: number) => {
+ form.setValue("customLinks", customLinks.filter((_, i) => i !== index));
+ };
+
+ const updateLink = (index: number, field: keyof CustomLink, value: string) => {
+ const updated = [...customLinks];
+ updated[index] = { ...updated[index], [field]: value };
+ form.setValue("customLinks", updated);
+ };
+
+ const moveLink = (index: number, direction: "up" | "down") => {
+ const newIndex = direction === "up" ? index - 1 : index + 1;
+ if (newIndex < 0 || newIndex >= customLinks.length) return;
+ const updated = [...customLinks];
+ [updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
+ form.setValue("customLinks", updated);
+ };
+
const watchedAvatar = form.watch("avatar");
const watchedName = form.watch("name");
@@ -243,6 +306,121 @@ export function ProfileForm({ user, showVerifiedSection = false }: ProfileFormPr
{t("emailCannotChange")}
+
+ {/* Bio */}
+
+
+
+
+ {form.formState.errors.bio && (
+
+ {form.formState.errors.bio.message}
+
+ )}
+
+ {t("bioCharCount", { count: bioValue.length })}
+
+
+
+
+ {/* Custom Links */}
+
+
+
+
+
{t("customLinksDescription")}
+
+
+
+ {customLinks.map((link, index) => (
+
+
+
+
+
+
+
updateLink(index, "url", e.target.value)}
+ className="flex-1"
+ />
+
updateLink(index, "label", e.target.value)}
+ className="w-[120px]"
+ />
+
+
+ ))}
+
+ {customLinks.length < 5 && (
+
+ )}
+ {customLinks.length >= 5 && (
+
{t("maxLinksReached")}
+ )}
+
diff --git a/src/components/user/profile-links.tsx b/src/components/user/profile-links.tsx
new file mode 100644
index 00000000..c3e0f344
--- /dev/null
+++ b/src/components/user/profile-links.tsx
@@ -0,0 +1,178 @@
+"use client";
+
+import {
+ Globe,
+ Github,
+ Linkedin,
+ Instagram,
+ Youtube,
+ Twitch,
+ Heart,
+ type LucideIcon
+} from "lucide-react";
+
+function XIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+import { cn } from "@/lib/utils";
+
+// Custom icons for platforms not in Lucide
+function DiscordIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function MastodonIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function BlueskyIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+export type CustomLinkType =
+ | "website"
+ | "github"
+ | "twitter"
+ | "linkedin"
+ | "instagram"
+ | "youtube"
+ | "twitch"
+ | "discord"
+ | "mastodon"
+ | "bluesky"
+ | "sponsor";
+
+export interface CustomLink {
+ type: CustomLinkType;
+ url: string;
+ label?: string;
+}
+
+interface LinkIconConfig {
+ icon: LucideIcon | (({ className }: { className?: string }) => JSX.Element);
+ color: string;
+ hoverColor: string;
+}
+
+const linkIcons: Record = {
+ website: { icon: Globe, color: "text-muted-foreground", hoverColor: "hover:text-foreground" },
+ github: { icon: Github, color: "text-muted-foreground", hoverColor: "hover:text-foreground" },
+ twitter: { icon: XIcon, color: "text-muted-foreground", hoverColor: "hover:text-foreground" },
+ linkedin: { icon: Linkedin, color: "text-muted-foreground", hoverColor: "hover:text-blue-600" },
+ instagram: { icon: Instagram, color: "text-muted-foreground", hoverColor: "hover:text-pink-500" },
+ youtube: { icon: Youtube, color: "text-muted-foreground", hoverColor: "hover:text-red-500" },
+ twitch: { icon: Twitch, color: "text-muted-foreground", hoverColor: "hover:text-purple-500" },
+ discord: { icon: DiscordIcon, color: "text-muted-foreground", hoverColor: "hover:text-indigo-500" },
+ mastodon: { icon: MastodonIcon, color: "text-muted-foreground", hoverColor: "hover:text-purple-600" },
+ bluesky: { icon: BlueskyIcon, color: "text-muted-foreground", hoverColor: "hover:text-sky-400" },
+ sponsor: { icon: Heart, color: "text-muted-foreground", hoverColor: "hover:text-pink-500" },
+};
+
+interface ProfileLinksProps {
+ bio?: string | null;
+ customLinks?: CustomLink[] | null;
+ className?: string;
+}
+
+function parseBioText(text: string): React.ReactNode[] {
+ const parts: React.ReactNode[] = [];
+ let key = 0;
+
+ // Regex to match URLs and **bold** text
+ const regex = /(https?:\/\/[^\s]+)|(\*\*[^*]+\*\*)/g;
+ let lastIndex = 0;
+ let match;
+
+ while ((match = regex.exec(text)) !== null) {
+ // Add text before the match
+ if (match.index > lastIndex) {
+ parts.push(text.slice(lastIndex, match.index));
+ }
+
+ if (match[1]) {
+ // URL match
+ const url = match[1];
+ parts.push(
+
+ {url.replace(/^https?:\/\//, "").replace(/\/$/, "")}
+
+ );
+ } else if (match[2]) {
+ // Bold match - remove ** markers
+ const boldText = match[2].slice(2, -2);
+ parts.push({boldText});
+ }
+
+ lastIndex = regex.lastIndex;
+ }
+
+ // Add remaining text
+ if (lastIndex < text.length) {
+ parts.push(text.slice(lastIndex));
+ }
+
+ return parts.length > 0 ? parts : [text];
+}
+
+export function ProfileLinks({ bio, customLinks, className }: ProfileLinksProps) {
+ const hasContent = bio || (customLinks && customLinks.length > 0);
+
+ if (!hasContent) return null;
+
+ return (
+
+ {bio && (
+
+ {parseBioText(bio)}
+
+ )}
+ {customLinks && customLinks.length > 0 && (
+
+ {customLinks.map((link, index) => {
+ const config = linkIcons[link.type] || linkIcons.website;
+ const Icon = config.icon;
+ return (
+
+
+ {link.label && {link.label}}
+
+ );
+ })}
+
+ )}
+
+ );
+}