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 */} +
+ +