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
21 changes: 21 additions & 0 deletions app/builders/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Metadata } from 'next';

import { BuildersView } from '@/components/discover/builders-view';
import { SiteFooter } from '@/components/layout/site-footer';
import { SiteHeader } from '@/components/layout/site-header';

export const metadata: Metadata = {
title: 'Builders',
description:
'Discover the builders shipping across the Boundless ecosystem.',
};

export default function BuildersPage() {
return (
<>
<SiteHeader />
<BuildersView />
<SiteFooter />
</>
);
}
26 changes: 19 additions & 7 deletions components/cards/builder-card-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { Skeleton } from '@/components/ui/skeleton';

/** Loading placeholder that mirrors the BuilderCard layout. */
export function BuilderCardSkeleton() {
/**
* Loading placeholder that mirrors the BuilderCard layout. `showStats` must
* match the card it stands in for: the directory omits follower and project
* counts, so reserving room for them here makes the grid jump on load.
*/
export function BuilderCardSkeleton({
showStats = true,
}: {
showStats?: boolean;
} = {}) {
return (
<div className='flex flex-col gap-5 rounded-2xl border border-border bg-ink p-4'>
<div className='flex items-center gap-3'>
Expand All @@ -22,12 +30,16 @@ export function BuilderCardSkeleton() {
<Skeleton className='h-5 w-14 rounded-full' />
</div>

<span aria-hidden className='h-px w-full bg-border' />
{showStats && (
<>
<span aria-hidden className='h-px w-full bg-border' />

<div className='flex items-center gap-4'>
<Skeleton className='h-4 w-24' />
<Skeleton className='h-4 w-20' />
</div>
<div className='flex items-center gap-4'>
<Skeleton className='h-4 w-24' />
<Skeleton className='h-4 w-20' />
</div>
</>
)}
</div>
);
}
167 changes: 102 additions & 65 deletions components/cards/builder-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ function Meta({
);
}

/** A single builder card in the discovery grid or /builders directory. */
export function BuilderCard({
function BuilderCardBody({
builder,
}: {
builder: BuilderCardView;
Expand All @@ -45,82 +44,120 @@ export function BuilderCard({
skills: rawSkills,
followers,
projects,
detailUrl,
} = builder;
const skills = rawSkills ?? [];
// Directory rows have no follower/project counts, so the footer (and its
// divider) is skipped entirely rather than rendering a stray rule.
const showStats = followers !== undefined || projects !== undefined;

return (
<Link
href={detailUrl}
aria-label={displayName}
className='group block h-full min-w-0 rounded-2xl focus-visible:ring-2 focus-visible:ring-primary-500/50 focus-visible:outline-none'
>
<article className='flex h-full min-w-0 flex-col gap-5 rounded-2xl border border-border bg-ink p-4 transition-[transform,border-color] duration-200 group-hover:border-[#2a3a37] motion-reduce:transition-none'>
{/* Header: avatar + name + username */}
<div className='flex items-center gap-3'>
<Avatar
src={avatarSrc}
initials={deriveInitials(displayName)}
alt={displayName}
size='md'
className='shrink-0'
/>
<div className='min-w-0 flex-1'>
<h3 className='truncate text-base font-semibold text-foreground'>
{displayName}
</h3>
<>
{/* Header: avatar + name + username */}
<div className='flex items-center gap-3'>
<Avatar
src={avatarSrc}
initials={deriveInitials(displayName)}
alt={displayName}
size='md'
className='shrink-0'
/>
<div className='min-w-0 flex-1'>
<h3 className='truncate text-base font-semibold text-foreground'>
{displayName}
</h3>
{username && (
<p className='truncate text-sm text-muted-foreground'>
@{username}
</p>
</div>
)}
</div>
</div>

{/* Variable content: role, location, skills — stretches to keep cards equal-height */}
<div className='flex flex-1 flex-col gap-5'>
{/* Role / title */}
{role && (
<p className='text-body-sm text-muted-foreground'>{role}</p>
)}
{/* Variable content: role, location, skills — stretches to keep cards equal-height */}
<div className='flex flex-1 flex-col gap-5'>
{/* Role / title */}
{role && (
<p className='text-body-sm text-muted-foreground'>{role}</p>
)}

{/* Location */}
{location && (
<Meta icon={MapPin} className='shrink-0'>
{location}
</Meta>
)}
{/* Location */}
{location && (
<Meta icon={MapPin} className='shrink-0'>
{location}
</Meta>
)}

{/* Skills */}
{skills.length > 0 && (
<div className='flex flex-wrap gap-1.5'>
{skills.map((skill) => (
<span
key={skill}
className='rounded-[12px] bg-[rgba(234,253,247,0.08)] px-2 py-0.5 text-xs font-medium text-primary-700'
>
{skill}
</span>
))}
</div>
)}
</div>
{/* Skills */}
{skills.length > 0 && (
<div className='flex flex-wrap gap-1.5'>
{skills.map((skill) => (
<span
key={skill}
className='rounded-[12px] bg-[rgba(234,253,247,0.08)] px-2 py-0.5 text-xs font-medium text-primary-700'
>
{skill}
</span>
))}
</div>
)}
</div>

<span aria-hidden className='h-px w-full bg-border' />
{showStats && (
<>
<span aria-hidden className='h-px w-full bg-border' />

{/* Follower / project counts */}
<div className='flex items-center justify-between gap-4'>
{followers !== undefined && (
<Meta icon={Users} className='shrink-0'>
{followers.toLocaleString()}{' '}
{followers === 1 ? 'follower' : 'followers'}
</Meta>
)}
{projects !== undefined && (
<Meta icon={Folder} className='shrink-0'>
{projects.toLocaleString()}{' '}
{projects === 1 ? 'project' : 'projects'}
</Meta>
)}
</div>
{/* Follower / project counts */}
<div className='flex items-center justify-between gap-4'>
{followers !== undefined && (
<Meta icon={Users} className='shrink-0'>
{followers.toLocaleString()}{' '}
{followers === 1 ? 'follower' : 'followers'}
</Meta>
)}
{projects !== undefined && (
<Meta icon={Folder} className='shrink-0'>
{projects.toLocaleString()}{' '}
{projects === 1 ? 'project' : 'projects'}
</Meta>
)}
</div>
</>
)}
</>
);
}

/** A single builder card in the discovery grid or /builders directory. */
export function BuilderCard({
builder,
}: {
builder: BuilderCardView;
}) {
const { displayName, detailUrl } = builder;

const articleClassName = cn(
'flex h-full min-w-0 flex-col gap-5 rounded-2xl border border-border bg-ink p-4',
// Hover lift belongs to the link wrapper; plain cards (no profile yet) stay static.
detailUrl &&
'transition-[transform,border-color] duration-200 group-hover:border-[#2a3a37] motion-reduce:transition-none'
);

if (!detailUrl) {
return (
<article className={articleClassName}>
<BuilderCardBody builder={builder} />
</article>
);
}

return (
<Link
href={detailUrl}
aria-label={displayName}
className='group block h-full min-w-0 rounded-2xl focus-visible:ring-2 focus-visible:ring-primary-500/50 focus-visible:outline-none'
>
<article className={articleClassName}>
<BuilderCardBody builder={builder} />
</article>
</Link>
);
Expand Down
4 changes: 2 additions & 2 deletions components/cards/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ export interface BuilderCardView {
followers?: number;
/** Project count when available. */
projects?: number;
/** Profile page path so cards can link out. */
detailUrl: string;
/** Profile page path so cards can link out. Absent when no profile target exists (e.g. a directory row with a null username). */
detailUrl?: string;
}

export interface OpportunityCardView {
Expand Down
81 changes: 81 additions & 0 deletions components/discover/builders-grid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';

import { BuilderCard } from '@/components/cards/builder-card';
import { BuilderCardSkeleton } from '@/components/cards/builder-card-skeleton';
import { Pagination } from '@/components/ui/pagination';
import type { Paginated } from '@/lib/api/types';

import { toDirectoryBuilderCard } from './to-builder-directory-card';
import type { BuilderListItemDto } from './use-builders';

const GRID_CLASS = 'grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4';

export function BuildersGrid({
data,
isPending,
isError,
isNarrowed,
page,
pageSize,
onPageChange,
}: {
data?: Paginated<BuilderListItemDto>;
isPending: boolean;
isError: boolean;
/** A search or filter is applied, so an empty result is a miss, not an empty directory. */
isNarrowed: boolean;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
}) {
if (isPending) {
return (
<div className={GRID_CLASS}>
{Array.from({ length: pageSize }, (_, index) => (
<BuilderCardSkeleton key={index} showStats={false} />
))}
</div>
);
}

if (isError || !data) {
return (
<p className='py-12 text-center text-body-sm text-muted-foreground'>
Builders could not be loaded right now.
</p>
);
}

if (data.data.length === 0) {
return (
<p className='py-12 text-center text-body-sm text-muted-foreground'>
{isNarrowed
? 'No builders match these filters.'
: 'No builders to show yet.'}
</p>
);
}

return (
<div className='flex flex-col gap-8'>
<div className={GRID_CLASS}>
{data.data.map((builder) => (
<BuilderCard
key={builder.id}
builder={toDirectoryBuilderCard(builder)}
/>
))}
</div>

{data.pagination.total > 0 && (
<Pagination
page={page}
pageSize={pageSize}
totalItems={data.pagination.total}
onPageChange={onPageChange}
className='justify-between'
/>
)}
</div>
);
}
Loading