From 2d4877a88bd5ab40aae5e96cdcfbd7c860986644 Mon Sep 17 00:00:00 2001 From: Rohan S Mirjankar Date: Sun, 5 Jul 2026 18:13:32 +0530 Subject: [PATCH 1/8] feat(profile): add Bio, Skills, Tech Stack, Experience, Education, and Social Links cards - Add modular profile card components with empty states - Add responsive grid layout to Developer Profile page - Style cards with consistent modern design using Tailwind v4 --- frontend/src/components/profile/BioCard.tsx | 51 +++++++++++++++ .../src/components/profile/EducationCard.tsx | 44 +++++++++++++ .../src/components/profile/ExperienceCard.tsx | 65 +++++++++++++++++++ .../src/components/profile/SkillsCard.tsx | 62 ++++++++++++++++++ .../components/profile/SocialLinksCard.tsx | 61 +++++++++++++++++ .../src/components/profile/TechStackCard.tsx | 45 +++++++++++++ frontend/src/components/profile/index.ts | 6 ++ frontend/src/mocks/seed.ts | 32 ++++++++- .../src/routes/_app.profile.$username.tsx | 46 ++++++++++--- 9 files changed, 401 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/profile/BioCard.tsx create mode 100644 frontend/src/components/profile/EducationCard.tsx create mode 100644 frontend/src/components/profile/ExperienceCard.tsx create mode 100644 frontend/src/components/profile/SkillsCard.tsx create mode 100644 frontend/src/components/profile/SocialLinksCard.tsx create mode 100644 frontend/src/components/profile/TechStackCard.tsx create mode 100644 frontend/src/components/profile/index.ts diff --git a/frontend/src/components/profile/BioCard.tsx b/frontend/src/components/profile/BioCard.tsx new file mode 100644 index 00000000..6e85c57e --- /dev/null +++ b/frontend/src/components/profile/BioCard.tsx @@ -0,0 +1,51 @@ +import { Card } from "@/components/shared/primitives"; +import { MapPin, Clock3, Sparkles } from "lucide-react"; + +export interface BioCardProps { + headline?: string | null; + bio?: string | null; + location?: string | null; + timezone?: string | null; +} + +export function BioCard({ headline, bio, location, timezone }: BioCardProps) { + const hasContent = [headline, bio, location, timezone].some((value) => Boolean(value)); + + return ( + +
+
+ +
+
+

About

+

Profile summary

+
+
+ + {!hasContent ? ( +

No profile details added yet.

+ ) : ( +
+ {headline ?

{headline}

: null} + {bio ?

{bio}

: null} + +
+ {location ? ( + + {location} + + ) : null} + {timezone ? ( + + {timezone} + + ) : null} +
+
+ )} +
+ ); +} + +export default BioCard; diff --git a/frontend/src/components/profile/EducationCard.tsx b/frontend/src/components/profile/EducationCard.tsx new file mode 100644 index 00000000..e81f1d7d --- /dev/null +++ b/frontend/src/components/profile/EducationCard.tsx @@ -0,0 +1,44 @@ +import { Card } from "@/components/shared/primitives"; +import { GraduationCap } from "lucide-react"; + +export interface EducationEntry { + school: string; + degree?: string | null; + years?: string | null; +} + +export interface EducationCardProps { + education?: EducationEntry[]; +} + +export function EducationCard({ education = [] }: EducationCardProps) { + return ( + +
+
+ +
+
+

Education

+

Academic background

+
+
+ + {education.length === 0 ? ( +

No education added yet.

+ ) : ( +
+ {education.map((entry) => ( +
+

{entry.school}

+ {entry.degree ?

{entry.degree}

: null} + {entry.years ?

{entry.years}

: null} +
+ ))} +
+ )} +
+ ); +} + +export default EducationCard; diff --git a/frontend/src/components/profile/ExperienceCard.tsx b/frontend/src/components/profile/ExperienceCard.tsx new file mode 100644 index 00000000..2d7762c0 --- /dev/null +++ b/frontend/src/components/profile/ExperienceCard.tsx @@ -0,0 +1,65 @@ +import { Card } from "@/components/shared/primitives"; +import { BriefcaseBusiness, BadgeCheck } from "lucide-react"; + +export interface ExperienceEntry { + title?: string | null; + company?: string | null; + experienceLevel?: string | null; + period?: string | null; + description?: string | null; +} + +export interface ExperienceCardProps { + role?: string | null; + company?: string | null; + experienceLevel?: string | null; + entries?: ExperienceEntry[]; +} + +export function ExperienceCard({ role, company, experienceLevel, entries }: ExperienceCardProps) { + const fallbackEntries = role || company || experienceLevel + ? [{ title: role, company, experienceLevel }] + : []; + const experienceEntries = (entries?.filter((entry) => Boolean(entry.title || entry.company || entry.experienceLevel)) ?? []).length + ? entries?.filter((entry) => Boolean(entry.title || entry.company || entry.experienceLevel)) ?? [] + : fallbackEntries; + + return ( + +
+
+ +
+
+

Experience

+

Current role and background

+
+
+ + {experienceEntries.length === 0 ? ( +

No experience added yet.

+ ) : ( +
+ {experienceEntries.map((entry, index) => ( +
+ +
+

{entry.title ?? role ?? "Current role"}

+

{entry.company ?? company ?? "Independent"}

+ {entry.experienceLevel || experienceLevel ? ( +
+ {entry.experienceLevel ?? experienceLevel} +
+ ) : null} + {entry.period ?

{entry.period}

: null} + {entry.description ?

{entry.description}

: null} +
+
+ ))} +
+ )} +
+ ); +} + +export default ExperienceCard; diff --git a/frontend/src/components/profile/SkillsCard.tsx b/frontend/src/components/profile/SkillsCard.tsx new file mode 100644 index 00000000..a76c4a71 --- /dev/null +++ b/frontend/src/components/profile/SkillsCard.tsx @@ -0,0 +1,62 @@ +import { Card, TagChip } from "@/components/shared/primitives"; +import { Sparkles } from "lucide-react"; +import type { ProfileSkill } from "@/mocks/seed"; + +export interface SkillsCardProps { + skills: ProfileSkill[]; +} + +const levelOrder = ["Beginner", "Intermediate", "Advanced", "Expert"] as const; + +function normalizeLevel(level?: string): (typeof levelOrder)[number] { + const normalized = level?.toLowerCase(); + const match = levelOrder.find((candidate) => candidate.toLowerCase() === normalized); + return match ?? "Intermediate"; +} + +export function SkillsCard({ skills }: SkillsCardProps) { + const groupedSkills = levelOrder + .map((level) => ({ + level, + items: skills.filter((skill) => normalizeLevel(skill.level) === level), + })) + .filter((group) => group.items.length > 0); + + return ( + +
+
+ +
+
+

Skills

+

Grouped by experience level

+
+
+ + {groupedSkills.length === 0 ? ( +

No skills added yet.

+ ) : ( +
+ {groupedSkills.map((group) => ( +
+

+ {group.level} +

+
+ {group.items.map((skill) => ( + + {skill.name} + {typeof skill.yearsOfExperience === "number" ? ` · ${skill.yearsOfExperience}y` : ""} + + ))} +
+
+ ))} +
+ )} +
+ ); +} + +export default SkillsCard; diff --git a/frontend/src/components/profile/SocialLinksCard.tsx b/frontend/src/components/profile/SocialLinksCard.tsx new file mode 100644 index 00000000..1c93d7b8 --- /dev/null +++ b/frontend/src/components/profile/SocialLinksCard.tsx @@ -0,0 +1,61 @@ +import { Card } from "@/components/shared/primitives"; +import { Github, Linkedin, ExternalLink } from "lucide-react"; + +export interface SocialLinksCardProps { + githubUrl?: string | null; + linkedinUrl?: string | null; + portfolioUrl?: string | null; + website?: string | null; +} + +interface LinkItem { + label: string; + url?: string | null; + icon: React.ElementType; +} + +export function SocialLinksCard({ githubUrl, linkedinUrl, portfolioUrl, website }: SocialLinksCardProps) { + const links: LinkItem[] = [ + { label: "GitHub", url: githubUrl, icon: Github }, + { label: "LinkedIn", url: linkedinUrl, icon: Linkedin }, + { label: "Portfolio", url: portfolioUrl ?? website, icon: ExternalLink }, + ].filter((link) => Boolean(link.url)); + + return ( + +
+
+ +
+
+

Social links

+

Find me online

+
+
+ + {links.length === 0 ? ( +

No links added yet.

+ ) : ( +
+ {links.map((link) => { + const Icon = link.icon; + return ( + + + {link.label} + + ); + })} +
+ )} +
+ ); +} + +export default SocialLinksCard; diff --git a/frontend/src/components/profile/TechStackCard.tsx b/frontend/src/components/profile/TechStackCard.tsx new file mode 100644 index 00000000..a782c7f7 --- /dev/null +++ b/frontend/src/components/profile/TechStackCard.tsx @@ -0,0 +1,45 @@ +import { Card, TagChip } from "@/components/shared/primitives"; +import { Cpu } from "lucide-react"; +import type { ProfileSkill } from "@/mocks/seed"; + +export interface TechStackCardProps { + skills?: ProfileSkill[]; + techStack?: string[]; +} + +export function TechStackCard({ skills = [], techStack }: TechStackCardProps) { + const resolvedStack = (techStack?.filter(Boolean) ?? []).length + ? techStack?.filter(Boolean) ?? [] + : skills.filter((skill) => Boolean(skill.category)).map((skill) => skill.name); + + const fallbackStack = resolvedStack.length === 0 ? skills.map((skill) => skill.name) : []; + const stackItems = resolvedStack.length > 0 ? resolvedStack : fallbackStack; + + return ( + +
+
+ +
+
+

Tech stack

+

Core tools and technologies

+
+
+ + {stackItems.length === 0 ? ( +

No technologies added yet.

+ ) : ( +
+ {stackItems.map((item) => ( + + {item} + + ))} +
+ )} +
+ ); +} + +export default TechStackCard; diff --git a/frontend/src/components/profile/index.ts b/frontend/src/components/profile/index.ts new file mode 100644 index 00000000..beebd66c --- /dev/null +++ b/frontend/src/components/profile/index.ts @@ -0,0 +1,6 @@ +export { BioCard, type BioCardProps } from "./BioCard"; +export { SkillsCard, type SkillsCardProps } from "./SkillsCard"; +export { TechStackCard, type TechStackCardProps } from "./TechStackCard"; +export { ExperienceCard, type ExperienceCardProps, type ExperienceEntry } from "./ExperienceCard"; +export { EducationCard, type EducationCardProps, type EducationEntry } from "./EducationCard"; +export { SocialLinksCard, type SocialLinksCardProps } from "./SocialLinksCard"; diff --git a/frontend/src/mocks/seed.ts b/frontend/src/mocks/seed.ts index 1e130527..cee65a1e 100644 --- a/frontend/src/mocks/seed.ts +++ b/frontend/src/mocks/seed.ts @@ -4,6 +4,17 @@ export type ID = string; export interface Skill { name: string; } +export interface ProfileSkill { + name: string; + level?: string; + category?: string; + yearsOfExperience?: number; +} +export interface EducationEntry { + school: string; + degree?: string | null; + years?: string | null; +} export interface Builder { id: ID; name: string; @@ -16,6 +27,18 @@ export interface Builder { skills: string[]; online: boolean; bio: string; + headline?: string; + location?: string; + timezone?: string; + website?: string; + portfolioUrl?: string; + githubUrl?: string; + linkedinUrl?: string; + experienceLevel?: string; + company?: string; + profileSkills?: ProfileSkill[]; + techStack?: string[]; + education?: EducationEntry[]; } export interface Project { id: ID; @@ -110,7 +133,14 @@ const AV = (seed: string) => `https://api.dicebear.com/9.x/notionists-neutral/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b6e3f4,c0aede,d1d4f9,ffd5dc,ffdfbf`; export const builders: Builder[] = [ - { id: "b1", name: "Priya Sharma", handle: "priya_dev", role: "Frontend Developer", avatar: AV("Priya"), country: "India", yearsExp: 3, matchScore: 92, skills: ["React", "Next.js", "TypeScript"], online: true, bio: "Loves accessible UIs and design systems." }, + { id: "b1", name: "Priya Sharma", handle: "priya_dev", role: "Frontend Developer", avatar: AV("Priya"), country: "India", yearsExp: 3, matchScore: 92, skills: ["React", "Next.js", "TypeScript"], online: true, bio: "Loves accessible UIs and design systems.", headline: "Designing polished, accessible product experiences", location: "Bengaluru, India", timezone: "IST (UTC+5:30)", website: "https://priya.dev", portfolioUrl: "https://priya.dev/portfolio", githubUrl: "https://github.com/priya", linkedinUrl: "https://linkedin.com/in/priya", experienceLevel: "Senior", company: "Northstar Labs", profileSkills: [ + { name: "React", level: "Advanced", category: "frontend", yearsOfExperience: 4 }, + { name: "Next.js", level: "Advanced", category: "frontend", yearsOfExperience: 3 }, + { name: "TypeScript", level: "Expert", category: "frontend", yearsOfExperience: 5 }, + { name: "Tailwind CSS", level: "Advanced", category: "frontend", yearsOfExperience: 3 }, + { name: "Node.js", level: "Intermediate", category: "backend", yearsOfExperience: 2 }, + { name: "Docker", level: "Intermediate", category: "devops", yearsOfExperience: 2 }, + ], techStack: ["React", "Next.js", "TypeScript", "Tailwind CSS", "Figma"], education: [{ school: "IIIT Bangalore", degree: "B.Tech in Computer Science", years: "2018–2022" }] }, { id: "b2", name: "Rahul Verma", handle: "rahul_v", role: "Full Stack Developer", avatar: AV("Rahul"), country: "India", yearsExp: 4, matchScore: 89, skills: ["Node.js", "MongoDB", "Express"], online: true, bio: "Builds end-to-end features fast." }, { id: "b3", name: "Ankit Singh", handle: "ankit_be", role: "Backend Developer", avatar: AV("Ankit"), country: "India", yearsExp: 2, matchScore: 87, skills: ["Python", "FastAPI", "PostgreSQL"], online: false, bio: "APIs, queues and Postgres tuning." }, { id: "b4", name: "Sneha Iyer", handle: "sneha_ux", role: "UI/UX Designer", avatar: AV("Sneha"), country: "India", yearsExp: 3, matchScore: 94, skills: ["Figma", "Adobe XD"], online: true, bio: "Product design for early-stage teams." }, diff --git a/frontend/src/routes/_app.profile.$username.tsx b/frontend/src/routes/_app.profile.$username.tsx index 25b0bb0c..fa308a3c 100644 --- a/frontend/src/routes/_app.profile.$username.tsx +++ b/frontend/src/routes/_app.profile.$username.tsx @@ -1,5 +1,6 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; -import { Card, TagChip, Avatar } from "@/components/shared/primitives"; +import { Card, Avatar } from "@/components/shared/primitives"; +import { BioCard, SkillsCard, TechStackCard, ExperienceCard, EducationCard, SocialLinksCard } from "@/components/profile"; import { builders, currentUser, projects } from "@/mocks/seed"; import { MapPin, Calendar, Link as LinkIcon } from "lucide-react"; @@ -17,7 +18,32 @@ function ProfilePage() { const { username } = Route.useParams(); const me = username === currentUser.handle; const b = me - ? { ...builders[0], name: currentUser.name, handle: currentUser.handle, avatar: currentUser.avatar, bio: "Product engineer. Ships fast, sleeps sometimes.", role: "Full Stack Developer" } + ? { + ...builders[0], + name: currentUser.name, + handle: currentUser.handle, + avatar: currentUser.avatar, + bio: "Product engineer. Ships fast, sleeps sometimes.", + role: "Full Stack Developer", + headline: "Building dependable product experiences across the stack", + location: "Bengaluru, India", + timezone: "IST (UTC+5:30)", + website: "https://devlink.io", + portfolioUrl: "https://devlink.io/portfolio", + githubUrl: "https://github.com/devlink", + linkedinUrl: "https://linkedin.com/company/devlink", + experienceLevel: "Senior", + company: "DevLink", + profileSkills: [ + { name: "React", level: "Advanced", category: "frontend", yearsOfExperience: 4 }, + { name: "Next.js", level: "Advanced", category: "frontend", yearsOfExperience: 3 }, + { name: "TypeScript", level: "Expert", category: "frontend", yearsOfExperience: 5 }, + { name: "Node.js", level: "Intermediate", category: "backend", yearsOfExperience: 2 }, + { name: "Docker", level: "Intermediate", category: "devops", yearsOfExperience: 2 }, + ], + techStack: ["React", "Next.js", "TypeScript", "Node.js", "Docker"], + education: [{ school: "IIT Delhi", degree: "B.Tech in Computer Science", years: "2017–2021" }], + } : builders.find((x) => x.handle === username); if (!b) throw notFound(); @@ -44,14 +70,14 @@ function ProfilePage() { -
- -

Skills

-
- {b.skills.map((s) => {s})} -
-
- +
+ + + + + + +

Projects

    {projects.slice(0, 4).map((p) => ( From 6290e8a7647e4666e4511a7b8c0db541b2912872 Mon Sep 17 00:00:00 2001 From: Rohan S Mirjankar Date: Sun, 5 Jul 2026 18:21:20 +0530 Subject: [PATCH 2/8] feat(profile): add inline edit mode with client-side validation - Add edit toggle visible only to profile owner - Add editable forms for Bio, Skills, Tech Stack, Experience, Social Links - Add reusable validators for URLs, bio length, headline length, duplicate skills - Wire save/cancel with loading and success/error feedback --- frontend/src/components/profile/BioCard.tsx | 79 ++++- .../src/components/profile/ExperienceCard.tsx | 57 ++- .../src/components/profile/SkillsCard.tsx | 88 ++++- .../components/profile/SocialLinksCard.tsx | 50 ++- .../src/components/profile/TechStackCard.tsx | 34 +- frontend/src/lib/validation/profile.ts | 95 +++++ .../src/routes/_app.profile.$username.tsx | 332 ++++++++++++++++-- frontend/src/services/profile.ts | 78 ++++ 8 files changed, 779 insertions(+), 34 deletions(-) create mode 100644 frontend/src/lib/validation/profile.ts create mode 100644 frontend/src/services/profile.ts diff --git a/frontend/src/components/profile/BioCard.tsx b/frontend/src/components/profile/BioCard.tsx index 6e85c57e..64443864 100644 --- a/frontend/src/components/profile/BioCard.tsx +++ b/frontend/src/components/profile/BioCard.tsx @@ -6,10 +6,87 @@ export interface BioCardProps { bio?: string | null; location?: string | null; timezone?: string | null; + editable?: boolean; + formValues?: { + headline: string; + bio: string; + location: string; + timezone: string; + }; + errors?: Record; + onFieldChange?: (field: "headline" | "bio" | "location" | "timezone", value: string) => void; } -export function BioCard({ headline, bio, location, timezone }: BioCardProps) { +export function BioCard({ headline, bio, location, timezone, editable = false, formValues, errors, onFieldChange }: BioCardProps) { const hasContent = [headline, bio, location, timezone].some((value) => Boolean(value)); + const bioLength = (editable ? formValues?.bio : bio ?? "").length; + + if (editable) { + return ( + +
    +
    + +
    +
    +

    About

    +

    Update your profile summary

    +
    +
    + +
    + + +