diff --git a/src/entities/project/index.ts b/src/entities/project/index.ts new file mode 100644 index 0000000..07d5dce --- /dev/null +++ b/src/entities/project/index.ts @@ -0,0 +1,3 @@ +export { ProjectMetadata } from './ui/ProjectMetadata' +export { ProjectCard } from './ui/ProjectCard' +export { DetailedProjectCard } from './ui/DetailedProjectCard' diff --git a/src/entities/project/ui/DetailedProjectCard.test.tsx b/src/entities/project/ui/DetailedProjectCard.test.tsx new file mode 100644 index 0000000..bc3cbae --- /dev/null +++ b/src/entities/project/ui/DetailedProjectCard.test.tsx @@ -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() + expect(screen.getByText('Big Project')).toBeInTheDocument() + }) + + it('renders the description', () => { + render() + expect(screen.getByText('A detailed project description')).toBeInTheDocument() + }) + + it('renders each detail item', () => { + render() + expect(screen.getByText('First detail point')).toBeInTheDocument() + expect(screen.getByText('Second detail point')).toBeInTheDocument() + }) + + it('renders ProjectMetadata with year, role, and stack', () => { + render() + 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() + expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12') + }) + + it('title is rendered as an h3', () => { + render() + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project') + }) + + it('detail items are rendered as

tags with text-base', () => { + render() + 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() + 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() + 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() + expect(container.querySelector('img')).toBeNull() + }) + + it('renders image when imageUrl is provided', () => { + render() + 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() + const imgWrapper = container.querySelector('img')!.parentElement + expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border') + }) + }) +}) diff --git a/src/entities/project/ui/DetailedProjectCard.tsx b/src/entities/project/ui/DetailedProjectCard.tsx new file mode 100644 index 0000000..53a2663 --- /dev/null +++ b/src/entities/project/ui/DetailedProjectCard.tsx @@ -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 ( +

+
+ +
+ +
+ +

{title}

+

{description}

+ + {imageUrl && ( +
+ {title} +
+ )} + +
+ {details.map((detail, index) => ( +

{detail}

+ ))} +
+
+
+
+ ) +} diff --git a/src/entities/project/ui/ProjectCard.test.tsx b/src/entities/project/ui/ProjectCard.test.tsx new file mode 100644 index 0000000..42d579a --- /dev/null +++ b/src/entities/project/ui/ProjectCard.test.tsx @@ -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() + expect(screen.getByText('My Project')).toBeInTheDocument() + }) + + it('renders the year badge', () => { + render() + expect(screen.getByText('2024')).toBeInTheDocument() + }) + + it('renders the description', () => { + render() + expect(screen.getByText('A cool project description')).toBeInTheDocument() + }) + + it('renders each tag', () => { + render() + expect(screen.getByText('React')).toBeInTheDocument() + expect(screen.getByText('Node')).toBeInTheDocument() + }) + + it('renders the View Project button', () => { + render() + expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument() + }) + }) + + describe('structure', () => { + it('card has hover transition classes', () => { + const { container } = render() + const card = container.firstChild as HTMLElement + expect(card).toHaveClass('group', 'transition-all', 'duration-300') + }) + + it('year badge has correct classes', () => { + render() + const yearBadge = screen.getByText('2024') + expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm') + }) + + it('tags have correct classes', () => { + render() + 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() + expect(container.querySelector('img')).toBeNull() + }) + + it('renders image when imageUrl is provided', () => { + render() + 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() + const imgWrapper = container.querySelector('img')!.parentElement + expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border') + }) + }) +}) diff --git a/src/entities/project/ui/ProjectCard.tsx b/src/entities/project/ui/ProjectCard.tsx new file mode 100644 index 0000000..c718b0f --- /dev/null +++ b/src/entities/project/ui/ProjectCard.tsx @@ -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 ( + + +
+ {title} + {year} +
+ {description} +
+ + {imageUrl && ( +
+ {title} +
+ )} + + + {tags.map((tag) => ( + + {tag} + + ))} + + + + + +
+ ) +} diff --git a/src/entities/project/ui/ProjectMetadata.test.tsx b/src/entities/project/ui/ProjectMetadata.test.tsx new file mode 100644 index 0000000..2efc055 --- /dev/null +++ b/src/entities/project/ui/ProjectMetadata.test.tsx @@ -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() + expect(screen.getByText('2024')).toBeInTheDocument() + }) + + it('renders the YEAR label', () => { + render() + expect(screen.getByText('YEAR')).toBeInTheDocument() + }) + + it('renders the role value', () => { + render() + expect(screen.getByText('Frontend Engineer')).toBeInTheDocument() + }) + + it('renders the ROLE label', () => { + render() + expect(screen.getByText('ROLE')).toBeInTheDocument() + }) + + it('renders the STACK label', () => { + render() + expect(screen.getByText('STACK')).toBeInTheDocument() + }) + + it('renders each stack technology', () => { + render() + 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() + expect(container.firstChild).toHaveClass('space-y-6') + }) + + it('year section has no brutal-border-top (first section)', () => { + const { container } = render() + 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() + 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() + 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() + const yearLabel = screen.getByText('YEAR') + expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60') + }) + + it('year value has text-base font-bold', () => { + render() + const yearValue = screen.getByText('2024') + expect(yearValue).toHaveClass('text-base', 'font-bold') + }) + + it('each stack tech is rendered as a

with text-sm', () => { + render() + 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() + expect(container.firstChild).toHaveClass('my-custom') + }) + }) +}) diff --git a/src/entities/project/ui/ProjectMetadata.tsx b/src/entities/project/ui/ProjectMetadata.tsx new file mode 100644 index 0000000..f1b73f5 --- /dev/null +++ b/src/entities/project/ui/ProjectMetadata.tsx @@ -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 ( +

+
+

YEAR

+

{year}

+
+
+

ROLE

+

{role}

+
+
+

STACK

+ {stack.map((tech) => ( +

{tech}

+ ))} +
+
+ ) +}