refactor: group project/ui components into subdirectories

This commit is contained in:
Ilia Mashkov
2026-05-13 09:40:00 +03:00
parent e518fc46a9
commit 9cf8caaead
11 changed files with 5 additions and 4 deletions
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ProjectCard } from './ProjectCard';
const meta: Meta<typeof ProjectCard> = {
title: 'Entities/ProjectCard',
component: ProjectCard,
decorators: [
(Story) => (
<div className="p-8 bg-white max-w-md">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ProjectCard>;
export const Default: Story = {
args: {
title: 'Portfolio Website',
year: '2024',
description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.',
tags: ['React', 'TypeScript', 'Next.js'],
},
};
export const WithImage: Story = {
args: {
title: 'Portfolio Website',
year: '2024',
description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.',
tags: ['React', 'TypeScript', 'Next.js'],
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
},
};
@@ -0,0 +1,77 @@
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-blue', 'text-cream', 'text-sm');
});
it('tags have correct classes', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
const tag = screen.getByText('React');
expect(tag).toHaveClass('brutal-border', 'bg-cream', 'text-blue', '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" />);
expect(screen.getByRole('img')).toBeInTheDocument();
});
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 Image from 'next/image';
import { cn } from '$shared/lib';
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '$shared/ui';
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(--blue)] 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-blue text-cream text-sm">{year}</span>
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
{imageUrl && (
<div className="brutal-border my-6 aspect-video bg-blue overflow-hidden relative">
<Image src={imageUrl} alt={title} fill className="object-cover" />
</div>
)}
<CardContent className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span key={tag} className="brutal-border px-3 py-1 bg-cream text-blue text-sm uppercase tracking-wide">
{tag}
</span>
))}
</CardContent>
<CardFooter>
<Button variant="primary" className="w-full">
View Project
</Button>
</CardFooter>
</Card>
);
}