feat: add project entity (ProjectMetadata, ProjectCard, DetailedProjectCard)

This commit is contained in:
Ilia Mashkov
2026-04-19 08:37:21 +03:00
parent 0fa66f275d
commit eeef679f69
7 changed files with 459 additions and 0 deletions
+3
View File
@@ -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')
})
})
})
+68
View File
@@ -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>
)
}