chore: format codebase and move SectionAccordion to entities/Section
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from './model/types';
|
||||
export * from './ui';
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BaseRecord } from '$shared/api';
|
||||
|
||||
/**
|
||||
* PocketBase collection for site sections and routing.
|
||||
*/
|
||||
export type SectionRecord = BaseRecord & {
|
||||
/**
|
||||
* URL-friendly identifier used for routing
|
||||
*/
|
||||
slug: string;
|
||||
/**
|
||||
* Display name of the section
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Visual numbering prefix (e.g., "01")
|
||||
*/
|
||||
number: string;
|
||||
/**
|
||||
* Sorting weight for section order
|
||||
*/
|
||||
order: number;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { SectionAccordion } from './SectionAccordion'
|
||||
|
||||
const meta: Meta<typeof SectionAccordion> = {
|
||||
title: 'Shared/SectionAccordion',
|
||||
component: SectionAccordion,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="p-8 bg-ochre-clay">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof SectionAccordion>
|
||||
|
||||
export const Active: Story = {
|
||||
args: {
|
||||
number: '01',
|
||||
title: 'Biography',
|
||||
id: 'bio',
|
||||
isActive: true,
|
||||
onClick: () => {},
|
||||
children: (
|
||||
<p>This is the expanded section content. It is visible because isActive is true.</p>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const Collapsed: Story = {
|
||||
args: {
|
||||
number: '02',
|
||||
title: 'Work',
|
||||
id: 'work',
|
||||
isActive: false,
|
||||
onClick: () => console.log('section clicked'),
|
||||
children: (
|
||||
<p>This content is hidden in collapsed state.</p>
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { SectionAccordion } from './SectionAccordion'
|
||||
|
||||
const defaultProps = {
|
||||
number: '01',
|
||||
title: 'About',
|
||||
id: 'about',
|
||||
isActive: false,
|
||||
onClick: vi.fn(),
|
||||
children: <p>Content here</p>,
|
||||
}
|
||||
|
||||
describe('SectionAccordion', () => {
|
||||
describe('collapsed state (isActive=false)', () => {
|
||||
it('renders a section element with the given id', () => {
|
||||
const { container } = render(<SectionAccordion {...defaultProps} />)
|
||||
expect(container.querySelector('section#about')).toBeInTheDocument()
|
||||
})
|
||||
it('renders a button with number and title', () => {
|
||||
render(<SectionAccordion {...defaultProps} />)
|
||||
expect(screen.getByRole('button', { name: /01.*About/i })).toBeInTheDocument()
|
||||
})
|
||||
it('does not render children', () => {
|
||||
render(<SectionAccordion {...defaultProps} />)
|
||||
expect(screen.queryByText('Content here')).not.toBeInTheDocument()
|
||||
})
|
||||
it('calls onClick when button is clicked', async () => {
|
||||
const onClick = vi.fn()
|
||||
render(<SectionAccordion {...defaultProps} onClick={onClick} />)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('active state (isActive=true)', () => {
|
||||
const activeProps = { ...defaultProps, isActive: true }
|
||||
|
||||
it('renders an h1 with number and title', () => {
|
||||
render(<SectionAccordion {...activeProps} />)
|
||||
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument()
|
||||
})
|
||||
it('renders children', () => {
|
||||
render(<SectionAccordion {...activeProps} />)
|
||||
expect(screen.getByText('Content here')).toBeInTheDocument()
|
||||
})
|
||||
it('does not render a button', () => {
|
||||
render(<SectionAccordion {...activeProps} />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
it('content wrapper has animate-fadeIn class', () => {
|
||||
const { container } = render(<SectionAccordion {...activeProps} />)
|
||||
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface SectionAccordionProps {
|
||||
/**
|
||||
* Display number prefix (e.g. "01")
|
||||
*/
|
||||
number: string
|
||||
/**
|
||||
* Section title
|
||||
*/
|
||||
title: string
|
||||
/**
|
||||
* HTML id for anchor navigation
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* Whether this section is expanded
|
||||
*/
|
||||
isActive: boolean
|
||||
/**
|
||||
* Called when the collapsed header is clicked
|
||||
*/
|
||||
onClick: () => void
|
||||
/**
|
||||
* Section content, shown when active
|
||||
*/
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Accordion-style section that collapses to a heading button when inactive.
|
||||
*/
|
||||
export function SectionAccordion({ number, title, id, isActive, onClick, children }: SectionAccordionProps) {
|
||||
return (
|
||||
<section id={id} className="scroll-mt-8">
|
||||
{isActive ? (
|
||||
<div className="mb-12">
|
||||
<div className="mb-16">
|
||||
<h1
|
||||
className="font-heading font-black text-5xl leading-[1.2] mb-0"
|
||||
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
|
||||
>
|
||||
{number}. {title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="animate-fadeIn">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
|
||||
>
|
||||
<h2
|
||||
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity"
|
||||
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
|
||||
>
|
||||
{number}. {title}
|
||||
</h2>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SectionAccordion'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SectionAccordion'
|
||||
@@ -1 +1 @@
|
||||
export { ExperienceCard } from './ui/ExperienceCard'
|
||||
export { ExperienceCard } from './ui/ExperienceCard';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { ExperienceCard } from './ExperienceCard'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { ExperienceCard } from './ExperienceCard';
|
||||
|
||||
const meta: Meta<typeof ExperienceCard> = {
|
||||
title: 'Entities/ExperienceCard',
|
||||
@@ -11,30 +11,28 @@ const meta: Meta<typeof ExperienceCard> = {
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ExperienceCard>
|
||||
type Story = StoryObj<typeof ExperienceCard>;
|
||||
|
||||
const baseArgs = {
|
||||
title: 'Senior Frontend Engineer',
|
||||
company: 'Acme Corp',
|
||||
period: '2021 – 2024',
|
||||
description: 'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.',
|
||||
}
|
||||
description:
|
||||
'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.',
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: baseArgs,
|
||||
}
|
||||
};
|
||||
|
||||
export const SlateBackground: Story = {
|
||||
render: () => (
|
||||
<div className="bg-slate-indigo p-8 max-w-2xl">
|
||||
<ExperienceCard
|
||||
{...baseArgs}
|
||||
className="border-ochre-clay"
|
||||
/>
|
||||
<ExperienceCard {...baseArgs} className="border-ochre-clay" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ExperienceCard } from './ExperienceCard'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ExperienceCard } from './ExperienceCard';
|
||||
|
||||
const DEFAULT_PROPS = {
|
||||
title: 'Senior Developer',
|
||||
company: 'Acme Corp',
|
||||
period: '2021 – 2024',
|
||||
description: 'Built scalable frontend systems.',
|
||||
}
|
||||
};
|
||||
|
||||
describe('ExperienceCard', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders the job title', () => {
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
||||
expect(screen.getByText('Senior Developer')).toBeInTheDocument()
|
||||
})
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText('Senior Developer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the company name', () => {
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
||||
expect(screen.getByText('Acme Corp')).toBeInTheDocument()
|
||||
})
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the period badge', () => {
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
||||
expect(screen.getByText('2021 – 2024')).toBeInTheDocument()
|
||||
})
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText('2021 – 2024')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the description', () => {
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
||||
expect(screen.getByText('Built scalable frontend systems.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText('Built scalable frontend systems.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('structure', () => {
|
||||
it('title is rendered as an h4', () => {
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
||||
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer')
|
||||
})
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer');
|
||||
});
|
||||
|
||||
it('period badge has brutal-border, bg-carbon-black, text-ochre-clay, text-sm', () => {
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
||||
const badge = screen.getByText('2021 – 2024')
|
||||
expect(badge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm')
|
||||
})
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||
const badge = screen.getByText('2021 – 2024');
|
||||
expect(badge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm');
|
||||
});
|
||||
|
||||
it('company paragraph has opacity-80', () => {
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
||||
const company = screen.getByText('Acme Corp')
|
||||
expect(company.tagName).toBe('P')
|
||||
expect(company).toHaveClass('opacity-80')
|
||||
})
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||
const company = screen.getByText('Acme Corp');
|
||||
expect(company.tagName).toBe('P');
|
||||
expect(company).toHaveClass('opacity-80');
|
||||
});
|
||||
|
||||
it('description paragraph has text-base and max-w-[700px]', () => {
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />)
|
||||
const desc = screen.getByText('Built scalable frontend systems.')
|
||||
expect(desc).toHaveClass('text-base', 'max-w-[700px]')
|
||||
})
|
||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||
const desc = screen.getByText('Built scalable frontend systems.');
|
||||
expect(desc).toHaveClass('text-base', 'max-w-[700px]');
|
||||
});
|
||||
|
||||
it('card has brutal-border class (from Card component)', () => {
|
||||
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />)
|
||||
expect(container.firstChild).toHaveClass('brutal-border')
|
||||
})
|
||||
})
|
||||
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||
expect(container.firstChild).toHaveClass('brutal-border');
|
||||
});
|
||||
});
|
||||
|
||||
describe('className passthrough', () => {
|
||||
it('forwards className to the card', () => {
|
||||
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
})
|
||||
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { Card } from '$shared/ui'
|
||||
import { Card } from '$shared/ui';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Job title
|
||||
*/
|
||||
title: string
|
||||
title: string;
|
||||
/**
|
||||
* Company name
|
||||
*/
|
||||
company: string
|
||||
company: string;
|
||||
/**
|
||||
* Employment period (e.g. "2021 – 2024")
|
||||
*/
|
||||
period: string
|
||||
period: string;
|
||||
/**
|
||||
* Description of responsibilities and achievements
|
||||
*/
|
||||
description: string
|
||||
description: string;
|
||||
/**
|
||||
* Additional CSS classes forwarded to the card
|
||||
*/
|
||||
className?: string
|
||||
}
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Work experience card with title, company, period, and description.
|
||||
@@ -34,11 +34,9 @@ export function ExperienceCard({ title, company, period, description, className
|
||||
<h4>{title}</h4>
|
||||
<p className="text-base opacity-80">{company}</p>
|
||||
</div>
|
||||
<span className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay text-sm self-start">
|
||||
{period}
|
||||
</span>
|
||||
<span className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay text-sm self-start">{period}</span>
|
||||
</div>
|
||||
<p className="text-base max-w-[700px]">{description}</p>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './project'
|
||||
export * from './experience'
|
||||
export * from './project';
|
||||
export * from './experience';
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { ProjectMetadata } from './ui/ProjectMetadata'
|
||||
export { ProjectCard } from './ui/ProjectCard'
|
||||
export { DetailedProjectCard } from './ui/DetailedProjectCard'
|
||||
export { ProjectMetadata } from './ui/ProjectMetadata';
|
||||
export { ProjectCard } from './ui/ProjectCard';
|
||||
export { DetailedProjectCard } from './ui/DetailedProjectCard';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { DetailedProjectCard } from './DetailedProjectCard'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { DetailedProjectCard } from './DetailedProjectCard';
|
||||
|
||||
const meta: Meta<typeof DetailedProjectCard> = {
|
||||
title: 'Entities/DetailedProjectCard',
|
||||
@@ -11,32 +11,33 @@ const meta: Meta<typeof DetailedProjectCard> = {
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DetailedProjectCard>
|
||||
type Story = StoryObj<typeof DetailedProjectCard>;
|
||||
|
||||
const baseArgs = {
|
||||
title: 'Design System',
|
||||
year: '2024',
|
||||
role: 'Lead Frontend Engineer',
|
||||
stack: ['React', 'TypeScript', 'Tailwind CSS', 'Storybook'],
|
||||
description: 'A comprehensive design system built for a large-scale SaaS product, covering components, tokens, and documentation.',
|
||||
description:
|
||||
'A comprehensive design system built for a large-scale SaaS product, covering components, tokens, and documentation.',
|
||||
details: [
|
||||
'Established token system covering color, spacing, and typography.',
|
||||
'Built 40+ accessible components with full test coverage.',
|
||||
'Integrated Storybook for visual regression testing and documentation.',
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: baseArgs,
|
||||
}
|
||||
};
|
||||
|
||||
export const WithImage: Story = {
|
||||
args: {
|
||||
...baseArgs,
|
||||
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { DetailedProjectCard } from './DetailedProjectCard'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { DetailedProjectCard } from './DetailedProjectCard';
|
||||
|
||||
const DEFAULT_PROPS = {
|
||||
title: 'Big Project',
|
||||
@@ -9,83 +9,83 @@ const DEFAULT_PROPS = {
|
||||
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()
|
||||
})
|
||||
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()
|
||||
})
|
||||
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()
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />);
|
||||
const imgWrapper = container.querySelector('img')!.parentElement;
|
||||
expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,54 +1,46 @@
|
||||
import { Card } from '$shared/ui'
|
||||
import { ProjectMetadata } from './ProjectMetadata'
|
||||
import { Card } from '$shared/ui';
|
||||
import { ProjectMetadata } from './ProjectMetadata';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Project name
|
||||
*/
|
||||
title: string
|
||||
title: string;
|
||||
/**
|
||||
* Year the project was completed
|
||||
*/
|
||||
year: string
|
||||
year: string;
|
||||
/**
|
||||
* Developer role on the project
|
||||
*/
|
||||
role: string
|
||||
role: string;
|
||||
/**
|
||||
* Technology stack list
|
||||
*/
|
||||
stack: string[]
|
||||
stack: string[];
|
||||
/**
|
||||
* Project description paragraph
|
||||
*/
|
||||
description: string
|
||||
description: string;
|
||||
/**
|
||||
* Bullet-style detail points listed below the description
|
||||
*/
|
||||
details: string[]
|
||||
details: string[];
|
||||
/**
|
||||
* Optional hero image URL
|
||||
*/
|
||||
imageUrl?: string
|
||||
imageUrl?: string;
|
||||
/**
|
||||
* Reverse layout (reserved for future use)
|
||||
* @default false
|
||||
*/
|
||||
reverse?: boolean
|
||||
}
|
||||
reverse?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Full-width detailed project card with metadata sidebar.
|
||||
*/
|
||||
export function DetailedProjectCard({
|
||||
title,
|
||||
year,
|
||||
role,
|
||||
stack,
|
||||
description,
|
||||
details,
|
||||
imageUrl,
|
||||
}: Props) {
|
||||
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">
|
||||
@@ -68,11 +60,13 @@ export function DetailedProjectCard({
|
||||
|
||||
<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>
|
||||
<p key={index} className="text-base">
|
||||
{detail}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { ProjectCard } from './ProjectCard'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { ProjectCard } from './ProjectCard';
|
||||
|
||||
const meta: Meta<typeof ProjectCard> = {
|
||||
title: 'Entities/ProjectCard',
|
||||
@@ -11,11 +11,11 @@ const meta: Meta<typeof ProjectCard> = {
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ProjectCard>
|
||||
type Story = StoryObj<typeof ProjectCard>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
@@ -24,7 +24,7 @@ export const Default: Story = {
|
||||
description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.',
|
||||
tags: ['React', 'TypeScript', 'Next.js'],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WithImage: Story = {
|
||||
args: {
|
||||
@@ -34,4 +34,4 @@ export const WithImage: Story = {
|
||||
tags: ['React', 'TypeScript', 'Next.js'],
|
||||
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,79 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ProjectCard } from './ProjectCard'
|
||||
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()
|
||||
})
|
||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText('My Project')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the year badge', () => {
|
||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
||||
expect(screen.getByText('2024')).toBeInTheDocument()
|
||||
})
|
||||
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()
|
||||
})
|
||||
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()
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
|
||||
const imgWrapper = container.querySelector('img')!.parentElement;
|
||||
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui'
|
||||
import { cn } from '$shared/lib'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Project name
|
||||
*/
|
||||
title: string
|
||||
title: string;
|
||||
/**
|
||||
* Year the project was completed
|
||||
*/
|
||||
year: string
|
||||
year: string;
|
||||
/**
|
||||
* Short project description
|
||||
*/
|
||||
description: string
|
||||
description: string;
|
||||
/**
|
||||
* Technology or category tags
|
||||
*/
|
||||
tags: string[]
|
||||
tags: string[];
|
||||
/**
|
||||
* Optional preview image URL
|
||||
*/
|
||||
imageUrl?: string
|
||||
}
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact project card for grid/list display.
|
||||
@@ -61,8 +61,10 @@ export function ProjectCard({ title, year, description, tags, imageUrl }: Props)
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<Button variant="primary" className="w-full">View Project</Button>
|
||||
<Button variant="primary" className="w-full">
|
||||
View Project
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { ProjectMetadata } from './ProjectMetadata'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { ProjectMetadata } from './ProjectMetadata';
|
||||
|
||||
const meta: Meta<typeof ProjectMetadata> = {
|
||||
title: 'Entities/ProjectMetadata',
|
||||
@@ -11,11 +11,11 @@ const meta: Meta<typeof ProjectMetadata> = {
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ProjectMetadata>
|
||||
type Story = StoryObj<typeof ProjectMetadata>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
@@ -23,4 +23,4 @@ export const Default: Story = {
|
||||
role: 'Lead Frontend Engineer',
|
||||
stack: ['React', 'TypeScript', 'Next.js', 'Tailwind'],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,96 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ProjectMetadata } from './ProjectMetadata'
|
||||
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()
|
||||
})
|
||||
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText('2024')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the YEAR label', () => {
|
||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||
expect(screen.getByText('YEAR')).toBeInTheDocument()
|
||||
})
|
||||
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText('YEAR')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the role value', () => {
|
||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||
expect(screen.getByText('Frontend Engineer')).toBeInTheDocument()
|
||||
})
|
||||
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText('Frontend Engineer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the ROLE label', () => {
|
||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||
expect(screen.getByText('ROLE')).toBeInTheDocument()
|
||||
})
|
||||
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText('ROLE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the STACK label', () => {
|
||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
||||
expect(screen.getByText('STACK')).toBeInTheDocument()
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} className="my-custom" />);
|
||||
expect(container.firstChild).toHaveClass('my-custom');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { cn } from '$shared/lib'
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Project year
|
||||
*/
|
||||
year: string
|
||||
year: string;
|
||||
/**
|
||||
* Developer role on the project
|
||||
*/
|
||||
role: string
|
||||
role: string;
|
||||
/**
|
||||
* Technology stack list
|
||||
*/
|
||||
stack: string[]
|
||||
stack: string[];
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string
|
||||
}
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sidebar metadata display for a project: year, role, and stack.
|
||||
@@ -36,9 +36,11 @@ export function ProjectMetadata({ year, role, stack, className }: Props) {
|
||||
<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>
|
||||
<p key={tech} className="text-sm">
|
||||
{tech}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user