Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions SKILLS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@


---

## Session Learnings (2026-07-15 13:32)


### Code Conventions

- remove the title input and it should be add by role
3 changes: 3 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const nextConfig: NextConfig = {
{ hostname: "*.unsplash.com", protocol: "https" },
{ hostname: "avatars.githubusercontent.com", protocol: "https" },
{ hostname: "cdn.jsdelivr.net", protocol: "https" },
{ hostname: "res.cloudinary.com", protocol: "https" },
{ hostname: "*.gravatar.com", protocol: "https" },
{ hostname: "gravatar.com", protocol: "https" },
],
},
};
Expand Down
3 changes: 1 addition & 2 deletions src/app/(public)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,12 @@ export default async function Page() {
<Community contributors={data.contributors} wall={data.wall} />
</div>
</section>
<section className="w-full py-16 sm:py-24">
<section className="w-full py-28 sm:py-44">
<div className="mx-auto max-w-7xl px-4">
<MembersSpotlightSection />
</div>
</section>
<Cta stats={data.stats} />
<section className=""></section>
</div>
);
}
2 changes: 1 addition & 1 deletion src/features/home/community.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export const Community = ({ contributors, wall }: CommunityProps) => {
<span className="text-muted-foreground font-mono text-xs">#{index + 1}</span>
</div>
<div className="mt-auto space-y-1">
<p className="text-xl font-medium">{contributor.name ?? contributor.login}</p>
<p className="text-xl font-medium text-black">{contributor.name ?? contributor.login}</p>
<p className="text-muted-foreground font-mono text-xs">@{contributor.login}</p>
</div>
<p className="font-mono text-sm">
Expand Down
2 changes: 1 addition & 1 deletion src/features/home/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const Hero = ({ featured, stats, faces }: HeroProps) => {
</motion.div>
</div>
{featured && (
<motion.div {...enter(0.36)} className="relative w-full">
<motion.div {...enter(0.36)} className="relative min-h-150 w-full">
<div aria-hidden className="bg-main/25 absolute inset-x-1/4 top-1/4 bottom-1/4 rounded-full blur-3xl" />
{reducedMotion ? (
<div className="border-border-default bg-background relative mx-auto w-fit rounded-3xl border shadow-xl">
Expand Down
78 changes: 50 additions & 28 deletions src/features/members/member-card.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { ExternalLink } from "lucide-react";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";

import type { MemberProfile } from "./types";
import Image from "next/image";

const MAX_VISIBLE_SKILLS = 4;

Expand All @@ -30,36 +29,59 @@ export const MemberCard = ({ member }: { member: MemberProfile }) => {
const overflowCount = member.skills.length - visibleSkills.length;

return (
<div className="border-border-default bg-background flex h-full w-full flex-col gap-4 rounded-2xl border p-5 transition-transform hover:-translate-y-1 hover:shadow-lg">
<div className="flex items-center gap-3">
<Avatar size="lg">
{member.avatarUrl && <AvatarImage src={member.avatarUrl} alt={member.displayName} />}
<AvatarFallback>{initialsOf(member.displayName)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-semibold">{member.displayName}</p>
<p className="text-muted-foreground truncate text-sm">{member.title}</p>
<div className="group border-border relative h-80 w-full overflow-hidden rounded-xl border shadow-sm">
{/* Avatar */}
{member.avatarUrl ? (
<Image alt={member.displayName} className="object-cover" fill sizes="100%" src={member.avatarUrl} />
) : (
<div className="bg-muted text-muted-foreground flex h-full w-full items-center justify-center text-4xl font-bold">
{initialsOf(member.displayName)}
</div>
</div>
)}

<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline">{member.experience}</Badge>
{visibleSkills.map((skill) => (
<Badge key={skill} variant="secondary">
{skill}
</Badge>
))}
{overflowCount > 0 && <Badge variant="ghost">+{overflowCount}</Badge>}
{/* Gradient overlay always visible at bottom */}
<div className="absolute inset-x-0 bottom-0 bg-linear-to-t from-black/80 to-transparent px-4 pt-10 pb-3">
<p className="truncate text-lg font-semibold text-white">{member.displayName}</p>
</div>

{member.portfolioUrl && (
<Button asChild variant="outline" size="sm" className="mt-auto gap-1.5">
<a href={member.portfolioUrl} target="_blank" rel="noopener noreferrer">
View Portfolio
<ExternalLink />
</a>
</Button>
)}
{/* Hover panel */}
<div className="absolute inset-x-0 bottom-0 translate-y-full bg-black/85 p-4 backdrop-blur-sm transition-transform duration-300 group-hover:translate-y-0">
<p className="truncate text-base font-semibold text-white">{member.displayName}</p>
<p className="text-sm text-white/70">{member.title}</p>

{member.bio && <p className="mt-2 line-clamp-2 text-xs text-white/60">{member.bio}</p>}

{member.skills.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{visibleSkills.map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
{overflowCount > 0 && (
<Badge variant="outline" className="text-xs text-white/70">
+{overflowCount}
</Badge>
)}
</div>
)}

<div className="mt-3 flex items-center justify-between">
<Badge className="text-xs">{member.experience}</Badge>
{member.portfolioUrl && (
<Button
asChild
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs text-white hover:bg-white/20 hover:text-white"
>
<a href={member.portfolioUrl} target="_blank" rel="noopener noreferrer">
Portfolio <ExternalLink className="h-3 w-3" />
</a>
</Button>
)}
</div>
</div>
</div>
);
};
28 changes: 22 additions & 6 deletions src/features/members/profile-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { z } from "zod";

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { EXPERIENCE_LEVELS, type MemberProfile } from "./types";
import { EXPERIENCE_LEVELS, MEMBER_ROLES, type MemberProfile } from "./types";
import { useMyProfile, useUpdateProfile } from "./hooks";
import { Textarea } from "@/components/ui/textarea";
import { profileSchema } from "./profile-schema";
Expand All @@ -26,7 +26,8 @@ const MAX_AVATAR_BYTES = 2 * 1024 * 1024;

const toFormInput = (profile: MemberProfile): ProfileFormInput => ({
displayName: profile.displayName,
title: profile.title,
// Legacy profiles may hold a free-text title; force a fresh role pick for those.
title: (MEMBER_ROLES as readonly string[]).includes(profile.title) ? profile.title : "",
bio: profile.bio ?? "",
portfolioUrl: profile.portfolioUrl ?? "",
githubUrl: profile.githubUrl ?? "",
Expand Down Expand Up @@ -202,10 +203,25 @@ export const ProfileForm = () => {
</div>

<div className="space-y-1.5">
<label htmlFor="title" className="text-sm font-medium">
Title
</label>
<Input id="title" placeholder="Senior Backend Dev" {...form.register("title")} />
<span className="text-sm font-medium">Role</span>
<Controller
control={form.control}
name="title"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a role" />
</SelectTrigger>
<SelectContent>
{MEMBER_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{form.formState.errors.title && (
<p className="text-destructive text-xs">{form.formState.errors.title.message}</p>
)}
Expand Down
6 changes: 4 additions & 2 deletions src/features/members/profile-schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";

import { EXPERIENCE_LEVELS } from "./types";
import { EXPERIENCE_LEVELS, MEMBER_ROLES } from "./types";

/** An optional URL field: accepts a valid URL, or an empty string normalized to `undefined`. */
const optionalUrl = z
Expand All @@ -22,7 +22,9 @@ export const profileSchema = z.object({
.trim()
.min(1, "Display name is required.")
.max(100, "Display name must be 100 characters or fewer."),
title: z.string().trim().min(1, "Title is required.").max(100, "Title must be 100 characters or fewer."),
title: z.string().refine((value) => (MEMBER_ROLES as readonly string[]).includes(value), {
message: "Choose a role.",
}),
bio: z
.string()
.trim()
Expand Down
2 changes: 1 addition & 1 deletion src/features/members/spotlight-carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const SpotlightCarousel = () => {
return (
<div className="flex snap-x snap-mandatory scrollbar-none gap-4 overflow-x-auto pb-4 [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{members.map((member) => (
<div key={member.id} className="w-72 shrink-0 snap-start">
<div key={member.id} className="aspect-2/3 w-72 shrink-0 snap-start">
<MemberCard member={member} />
</div>
))}
Expand Down
24 changes: 17 additions & 7 deletions src/features/members/spotlight-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,31 @@

import { SpotlightCarousel } from "./spotlight-carousel";

import { Reveal } from "@/features/home";

/**
* Self-contained "Member Spotlight" section for the home page: heading plus the
* spotlight carousel. Mount directly inside a `<section>` wrapper.
* Self-contained "Member Spotlight" section for the home page: eyebrow label,
* editorial heading, and the spotlight carousel — matching the other home
* sections. Mount directly inside a `<section>` wrapper.
*
* @returns The spotlight section.
* @example
* <section className="py-5 sm:py-10">
* <div className="mx-auto max-w-7xl space-y-4">
* <section className="w-full py-28 sm:py-44">
* <div className="mx-auto max-w-7xl px-4">
* <MembersSpotlightSection />
* </div>
* </section>
*/
export const MembersSpotlightSection = () => (
<div className="space-y-6">
<h2 className="text-3xl font-medium sm:text-4xl">Member Spotlight</h2>
<SpotlightCarousel />
<div className="space-y-20">
<Reveal>
<p className="text-muted-foreground mb-8 font-mono text-xs tracking-[0.35em] uppercase">[ Member Spotlight ]</p>
<h2 className="max-w-3xl text-4xl leading-tight font-medium text-balance sm:text-6xl">
Meet the people you&apos;ll ship with.
</h2>
</Reveal>
<Reveal delay={0.1}>
<SpotlightCarousel />
</Reveal>
</div>
);
17 changes: 17 additions & 0 deletions src/features/members/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ export const EXPERIENCE_LEVELS = ["Junior", "Mid", "Senior", "Lead"] as const;
/** Union of valid `MemberProfile.experience` values. */
export type ExperienceLevel = (typeof EXPERIENCE_LEVELS)[number];

/** Roles a member can pick for their profile; stored as `title` on the backend. */
export const MEMBER_ROLES = [
"Frontend Developer",
"Backend Developer",
"Fullstack Developer",
"Mobile Developer",
"DevOps Engineer",
"Data Engineer",
"Machine Learning Engineer",
"UI/UX Designer",
"QA Engineer",
"Product Manager",
] as const;

/** Union of valid member role values. */
export type MemberRole = (typeof MEMBER_ROLES)[number];

/** A member's public profile, as returned by every `/members/*` endpoint. */
export type MemberProfile = {
id: string;
Expand Down
Loading