feat: add project entity (ProjectMetadata, ProjectCard, DetailedProjectCard)
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
export { ProjectMetadata } from './ui/ProjectMetadata'
|
||||||
|
export { ProjectCard } from './ui/ProjectCard'
|
||||||
|
export { DetailedProjectCard } from './ui/DetailedProjectCard'
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { DetailedProjectCard } from './DetailedProjectCard'
|
||||||
|
|
||||||
|
const DEFAULT_PROPS = {
|
||||||
|
title: 'Big Project',
|
||||||
|
year: '2023',
|
||||||
|
role: 'Lead Dev',
|
||||||
|
stack: ['Vue', 'Go'],
|
||||||
|
description: 'A detailed project description',
|
||||||
|
details: ['First detail point', 'Second detail point'],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DetailedProjectCard', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders the project title', () => {
|
||||||
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('Big Project')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the description', () => {
|
||||||
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('A detailed project description')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders each detail item', () => {
|
||||||
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('First detail point')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Second detail point')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders ProjectMetadata with year, role, and stack', () => {
|
||||||
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('2023')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Lead Dev')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Vue')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Go')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('structure', () => {
|
||||||
|
it('outer grid has grid-cols-1 and lg:grid-cols-12', () => {
|
||||||
|
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('title is rendered as an h3', () => {
|
||||||
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detail items are rendered as <p> tags with text-base', () => {
|
||||||
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
const detail = screen.getByText('First detail point')
|
||||||
|
expect(detail.tagName).toBe('P')
|
||||||
|
expect(detail).toHaveClass('text-base')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('details list has brutal-border-top and pt-6', () => {
|
||||||
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
const detail = screen.getByText('First detail point')
|
||||||
|
const detailList = detail.parentElement
|
||||||
|
expect(detailList).toHaveClass('brutal-border-top', 'pt-6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('description has text-lg and mb-6', () => {
|
||||||
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
const desc = screen.getByText('A detailed project description')
|
||||||
|
expect(desc).toHaveClass('text-lg', 'mb-6')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('conditional image rendering', () => {
|
||||||
|
it('does not render image when imageUrl is absent', () => {
|
||||||
|
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(container.querySelector('img')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders image when imageUrl is provided', () => {
|
||||||
|
render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />)
|
||||||
|
const img = screen.getByRole('img')
|
||||||
|
expect(img).toHaveAttribute('src', '/detail.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('image wrapper has aspect-video and brutal-border when imageUrl is provided', () => {
|
||||||
|
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />)
|
||||||
|
const imgWrapper = container.querySelector('img')!.parentElement
|
||||||
|
expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Card } from '$shared/ui'
|
||||||
|
import { ProjectMetadata } from './ProjectMetadata'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* Project name
|
||||||
|
*/
|
||||||
|
title: string
|
||||||
|
/**
|
||||||
|
* Year the project was completed
|
||||||
|
*/
|
||||||
|
year: string
|
||||||
|
/**
|
||||||
|
* Developer role on the project
|
||||||
|
*/
|
||||||
|
role: string
|
||||||
|
/**
|
||||||
|
* Technology stack list
|
||||||
|
*/
|
||||||
|
stack: string[]
|
||||||
|
/**
|
||||||
|
* Project description paragraph
|
||||||
|
*/
|
||||||
|
description: string
|
||||||
|
/**
|
||||||
|
* Bullet-style detail points listed below the description
|
||||||
|
*/
|
||||||
|
details: string[]
|
||||||
|
/**
|
||||||
|
* Optional hero image URL
|
||||||
|
*/
|
||||||
|
imageUrl?: string
|
||||||
|
/**
|
||||||
|
* Reverse layout (reserved for future use)
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
reverse?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-width detailed project card with metadata sidebar.
|
||||||
|
*/
|
||||||
|
export function DetailedProjectCard({
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
role,
|
||||||
|
stack,
|
||||||
|
description,
|
||||||
|
details,
|
||||||
|
imageUrl,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-16">
|
||||||
|
<div className="lg:col-span-2 order-2 lg:order-1">
|
||||||
|
<ProjectMetadata year={year} role={role} stack={stack} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-10 order-1 lg:order-2">
|
||||||
|
<Card background="white">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p className="text-lg mb-6">{description}</p>
|
||||||
|
|
||||||
|
{imageUrl && (
|
||||||
|
<div className="brutal-border aspect-video bg-slate-indigo overflow-hidden">
|
||||||
|
<img src={imageUrl} alt={title} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="max-w-[700px] space-y-4 brutal-border-top pt-6">
|
||||||
|
{details.map((detail, index) => (
|
||||||
|
<p key={index} className="text-base">{detail}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { ProjectCard } from './ProjectCard'
|
||||||
|
|
||||||
|
const DEFAULT_PROPS = {
|
||||||
|
title: 'My Project',
|
||||||
|
year: '2024',
|
||||||
|
description: 'A cool project description',
|
||||||
|
tags: ['React', 'Node'],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProjectCard', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders the project title', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('My Project')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the year badge', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('2024')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the description', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('A cool project description')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders each tag', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('React')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Node')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the View Project button', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('structure', () => {
|
||||||
|
it('card has hover transition classes', () => {
|
||||||
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
const card = container.firstChild as HTMLElement
|
||||||
|
expect(card).toHaveClass('group', 'transition-all', 'duration-300')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('year badge has correct classes', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
const yearBadge = screen.getByText('2024')
|
||||||
|
expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tags have correct classes', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
const tag = screen.getByText('React')
|
||||||
|
expect(tag).toHaveClass('brutal-border', 'bg-white', 'text-carbon-black', 'text-sm', 'uppercase', 'tracking-wide')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('conditional image rendering', () => {
|
||||||
|
it('does not render image when imageUrl is absent', () => {
|
||||||
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />)
|
||||||
|
expect(container.querySelector('img')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders image when imageUrl is provided', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />)
|
||||||
|
const img = screen.getByRole('img')
|
||||||
|
expect(img).toHaveAttribute('src', '/project.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('image wrapper has aspect-video and overflow-hidden when imageUrl is provided', () => {
|
||||||
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />)
|
||||||
|
const imgWrapper = container.querySelector('img')!.parentElement
|
||||||
|
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui'
|
||||||
|
import { cn } from '$shared/lib'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* Project name
|
||||||
|
*/
|
||||||
|
title: string
|
||||||
|
/**
|
||||||
|
* Year the project was completed
|
||||||
|
*/
|
||||||
|
year: string
|
||||||
|
/**
|
||||||
|
* Short project description
|
||||||
|
*/
|
||||||
|
description: string
|
||||||
|
/**
|
||||||
|
* Technology or category tags
|
||||||
|
*/
|
||||||
|
tags: string[]
|
||||||
|
/**
|
||||||
|
* Optional preview image URL
|
||||||
|
*/
|
||||||
|
imageUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact project card for grid/list display.
|
||||||
|
*/
|
||||||
|
export function ProjectCard({ title, year, description, tags, imageUrl }: Props) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'group hover:translate-x-[2px] hover:translate-y-[2px]',
|
||||||
|
'hover:shadow-[10px_10px_0_var(--carbon-black)] transition-all duration-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-row justify-between items-start mb-3">
|
||||||
|
<CardTitle className="flex-1">{title}</CardTitle>
|
||||||
|
<span className="brutal-border px-3 py-1 bg-carbon-black text-ochre-clay text-sm">{year}</span>
|
||||||
|
</div>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{imageUrl && (
|
||||||
|
<div className="brutal-border my-6 aspect-video bg-slate-indigo overflow-hidden">
|
||||||
|
<img src={imageUrl} alt={title} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="brutal-border px-3 py-1 bg-white text-carbon-black text-sm uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter>
|
||||||
|
<Button variant="primary" className="w-full">View Project</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { ProjectMetadata } from './ProjectMetadata'
|
||||||
|
|
||||||
|
const DEFAULT_PROPS = {
|
||||||
|
year: '2024',
|
||||||
|
role: 'Frontend Engineer',
|
||||||
|
stack: ['React', 'TypeScript', 'Tailwind'],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProjectMetadata', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders the year value', () => {
|
||||||
|
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('2024')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the YEAR label', () => {
|
||||||
|
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('YEAR')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the role value', () => {
|
||||||
|
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('Frontend Engineer')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the ROLE label', () => {
|
||||||
|
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('ROLE')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the STACK label', () => {
|
||||||
|
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('STACK')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders each stack technology', () => {
|
||||||
|
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
expect(screen.getByText('React')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('TypeScript')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Tailwind')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('structure', () => {
|
||||||
|
it('outer div has space-y-6', () => {
|
||||||
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
expect(container.firstChild).toHaveClass('space-y-6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('year section has no brutal-border-top (first section)', () => {
|
||||||
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
const sections = container.firstChild!.childNodes
|
||||||
|
expect(sections[0]).not.toHaveClass('brutal-border-top')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('role section has brutal-border-top and pt-6', () => {
|
||||||
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
const sections = container.firstChild!.childNodes
|
||||||
|
expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stack section has brutal-border-top and pt-6', () => {
|
||||||
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
const sections = container.firstChild!.childNodes
|
||||||
|
expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('label has text-xs uppercase tracking-wider opacity-60', () => {
|
||||||
|
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
const yearLabel = screen.getByText('YEAR')
|
||||||
|
expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('year value has text-base font-bold', () => {
|
||||||
|
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
const yearValue = screen.getByText('2024')
|
||||||
|
expect(yearValue).toHaveClass('text-base', 'font-bold')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('each stack tech is rendered as a <p> with text-sm', () => {
|
||||||
|
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||||
|
const techEl = screen.getByText('React')
|
||||||
|
expect(techEl.tagName).toBe('P')
|
||||||
|
expect(techEl).toHaveClass('text-sm')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('className passthrough', () => {
|
||||||
|
it('merges custom className onto outer div', () => {
|
||||||
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} className="my-custom" />)
|
||||||
|
expect(container.firstChild).toHaveClass('my-custom')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { cn } from '$shared/lib'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* Project year
|
||||||
|
*/
|
||||||
|
year: string
|
||||||
|
/**
|
||||||
|
* Developer role on the project
|
||||||
|
*/
|
||||||
|
role: string
|
||||||
|
/**
|
||||||
|
* Technology stack list
|
||||||
|
*/
|
||||||
|
stack: string[]
|
||||||
|
/**
|
||||||
|
* Additional CSS classes
|
||||||
|
*/
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar metadata display for a project: year, role, and stack.
|
||||||
|
*/
|
||||||
|
export function ProjectMetadata({ year, role, stack, className }: Props) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-6', className)}>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wider opacity-60">YEAR</p>
|
||||||
|
<p className="text-base font-bold">{year}</p>
|
||||||
|
</div>
|
||||||
|
<div className="brutal-border-top pt-6">
|
||||||
|
<p className="text-xs uppercase tracking-wider opacity-60">ROLE</p>
|
||||||
|
<p className="text-base font-bold">{role}</p>
|
||||||
|
</div>
|
||||||
|
<div className="brutal-border-top pt-6">
|
||||||
|
<p className="text-xs uppercase tracking-wider opacity-60">STACK</p>
|
||||||
|
{stack.map((tech) => (
|
||||||
|
<p key={tech} className="text-sm">{tech}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user