fix: storybook font rendering and shared fonts module #1

Merged
ilia merged 74 commits from feat/portfolio-setup into main 2026-05-18 18:45:22 +00:00
10 changed files with 659 additions and 0 deletions
Showing only changes of commit 4b18fc454e - Show all commits
@@ -0,0 +1,263 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const base = { created: '', updated: '' };
const FIXTURES: Record<string, unknown[]> = {
sections: [
{
id: '1',
collectionId: 'sections',
collectionName: 'sections',
...base,
slug: 'intro',
title: 'Introduction',
number: '01',
order: 1,
},
{
id: '2',
collectionId: 'sections',
collectionName: 'sections',
...base,
slug: 'bio',
title: 'Biography',
number: '02',
order: 2,
},
{
id: '3',
collectionId: 'sections',
collectionName: 'sections',
...base,
slug: 'skills',
title: 'Skills',
number: '03',
order: 3,
},
{
id: '4',
collectionId: 'sections',
collectionName: 'sections',
...base,
slug: 'experience',
title: 'Experience',
number: '04',
order: 4,
},
{
id: '5',
collectionId: 'sections',
collectionName: 'sections',
...base,
slug: 'projects',
title: 'Projects',
number: '05',
order: 5,
},
],
intro: [
{
id: '1',
collectionId: 'intro',
collectionName: 'intro',
...base,
slug: 'intro',
content:
"I'm a software engineer and designer building thoughtful digital products. I combine technical depth with a strong eye for design to create experiences that are both functional and beautiful.",
},
],
bio: [
{
id: '1',
collectionId: 'bio',
collectionName: 'bio',
...base,
slug: 'bio',
content:
"Based in Berlin. I've spent the last 8 years working at the intersection of product, design, and engineering. I believe the best products come from teams where these disciplines overlap and inform each other. When I'm not building, I'm reading, cooking, or somewhere with bad WiFi.",
},
],
skills: [
{
id: 's1',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'TypeScript',
category: 'Frontend',
order: 1,
},
{
id: 's2',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'React',
category: 'Frontend',
order: 2,
},
{
id: 's3',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'Next.js',
category: 'Frontend',
order: 3,
},
{
id: 's4',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'Tailwind CSS',
category: 'Frontend',
order: 4,
},
{
id: 's5',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'Node.js',
category: 'Backend',
order: 1,
},
{ id: 's6', collectionId: 'skills', collectionName: 'skills', ...base, name: 'Go', category: 'Backend', order: 2 },
{
id: 's7',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'PostgreSQL',
category: 'Backend',
order: 3,
},
{
id: 's8',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'Figma',
category: 'Design',
order: 1,
},
{
id: 's9',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'Docker',
category: 'Tools',
order: 1,
},
{ id: 's10', collectionId: 'skills', collectionName: 'skills', ...base, name: 'Git', category: 'Tools', order: 2 },
],
experience: [
{
id: 'e1',
collectionId: 'experience',
collectionName: 'experience',
...base,
company: 'Figma',
role: 'Senior Software Engineer',
start_date: '2022-03-01T00:00:00Z',
end_date: null,
description:
'Building the multiplayer infrastructure for real-time collaborative design tools. Led the migration from polling to WebSocket-based sync, reducing latency by 60%.',
order: 1,
},
{
id: 'e2',
collectionId: 'experience',
collectionName: 'experience',
...base,
company: 'N26',
role: 'Frontend Engineer',
start_date: '2019-06-01T00:00:00Z',
end_date: '2022-02-28T00:00:00Z',
description:
'Built and maintained the mobile banking web app serving 8 million customers. Owned the transaction history feature and drove accessibility improvements to WCAG 2.1 AA.',
order: 2,
},
{
id: 'e3',
collectionId: 'experience',
collectionName: 'experience',
...base,
company: 'Freelance',
role: 'Full-Stack Developer',
start_date: '2017-01-01T00:00:00Z',
end_date: '2019-05-31T00:00:00Z',
description: 'Worked with early-stage startups across fintech and healthtech to design and ship product MVPs.',
order: 3,
},
],
projects: [
{
id: 'p1',
collectionId: 'projects',
collectionName: 'projects',
...base,
title: 'Monograph',
year: '2024',
role: 'Lead Engineer & Designer',
description: 'A personal publishing platform built for long-form writing and visual essays.',
details: ['Custom rich-text editor', 'SSG with 100/100 Lighthouse', 'Sub-second TTFB globally'],
stack: ['Next.js', 'TypeScript', 'Tailwind CSS', 'PocketBase'],
image: '',
order: 1,
},
{
id: 'p2',
collectionId: 'projects',
collectionName: 'projects',
...base,
title: 'Verdant',
year: '2023',
role: 'Full-Stack Developer',
description: 'An inventory and sales management tool for independent plant nurseries.',
details: ['QR-code scanning', 'Offline-first PWA', 'Multi-location sync'],
stack: ['React', 'Go', 'PostgreSQL', 'Docker'],
image: '',
order: 2,
},
{
id: 'p3',
collectionId: 'projects',
collectionName: 'projects',
...base,
title: 'Folio',
year: '2022',
role: 'Designer & Developer',
description: 'A minimal portfolio generator for creative professionals.',
details: ['No-code page builder', 'Custom domain support'],
stack: ['Next.js', 'Prisma', 'Figma'],
image: '',
order: 3,
},
],
};
/**
* Mock API route handler for PocketBase collection records.
* Returns fixture data shaped as a PocketBase list response.
*/
export async function GET(_req: Request, { params }: { params: Promise<{ collection: string }> }) {
const { collection } = await params;
const items = FIXTURES[collection];
if (!items) {
return NextResponse.json({ message: 'Not found' }, { status: 404 });
}
return NextResponse.json({
page: 1,
perPage: items.length,
totalItems: items.length,
totalPages: 1,
items,
});
}
+1
View File
@@ -0,0 +1 @@
export { default as ExperienceSection } from './ui/ExperienceSection/ExperienceSection';
@@ -0,0 +1,87 @@
vi.mock('$shared/api', () => ({
getCollection: vi.fn(),
}));
import { render, screen } from '@testing-library/react';
import { getCollection } from '$shared/api';
import ExperienceSection from './ExperienceSection';
const mockItems = [
{
id: '1',
collectionId: 'c1',
collectionName: 'experience',
created: '',
updated: '',
company: 'Acme Corp',
role: 'Senior Developer',
start_date: '2022-01-01T00:00:00Z',
end_date: null,
description: 'Built critical systems.',
order: 1,
},
{
id: '2',
collectionId: 'c1',
collectionName: 'experience',
created: '',
updated: '',
company: 'Beta Ltd',
role: 'Junior Developer',
start_date: '2020-01-01T00:00:00Z',
end_date: '2021-12-31T00:00:00Z',
description: 'Learned the ropes.',
order: 2,
},
];
const listResponse = (items: typeof mockItems) => ({
items,
page: 1,
perPage: 50,
totalItems: items.length,
totalPages: 1,
});
describe('ExperienceSection', () => {
beforeEach(() => {
vi.mocked(getCollection).mockResolvedValue(listResponse(mockItems) as never);
});
describe('rendering', () => {
it('renders a card for each experience record', async () => {
render(await ExperienceSection());
expect(screen.getByText('Senior Developer')).toBeInTheDocument();
expect(screen.getByText('Junior Developer')).toBeInTheDocument();
});
it('renders company names', async () => {
render(await ExperienceSection());
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
expect(screen.getByText('Beta Ltd')).toBeInTheDocument();
});
it('formats open-ended period as "Present"', async () => {
render(await ExperienceSection());
expect(screen.getByText('2022 — Present')).toBeInTheDocument();
});
it('formats closed period with year range', async () => {
render(await ExperienceSection());
expect(screen.getByText('2020 — 2021')).toBeInTheDocument();
});
it('renders description text', async () => {
render(await ExperienceSection());
expect(screen.getByText('Built critical systems.')).toBeInTheDocument();
});
});
describe('empty state', () => {
it('renders empty container when no items', async () => {
vi.mocked(getCollection).mockResolvedValue(listResponse([]) as never);
const { container } = render(await ExperienceSection());
expect(container.firstChild).toBeEmptyDOMElement();
});
});
});
@@ -0,0 +1,28 @@
import { ExperienceCard } from '$entities/experience';
import type { ExperienceRecord } from '$shared/api';
import { getCollection } from '$shared/api';
import { formatYearRange } from '$shared/lib';
/**
* Experience section component.
* Lists work history entries sorted by order field.
*/
export default async function ExperienceSection() {
const { items } = await getCollection<ExperienceRecord>('experience', {
sort: 'order',
});
return (
<div className="space-y-6">
{items.map((exp) => (
<ExperienceCard
key={exp.id}
title={exp.role}
company={exp.company}
period={formatYearRange(exp.start_date, exp.end_date)}
description={exp.description}
/>
))}
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { default as ProjectsSection } from './ui/ProjectsSection/ProjectsSection';
@@ -0,0 +1,88 @@
vi.mock('$shared/api', () => ({
getCollection: vi.fn(),
}));
import { render, screen } from '@testing-library/react';
import { getCollection } from '$shared/api';
import ProjectsSection from './ProjectsSection';
const mockItems = [
{
id: '1',
collectionId: 'col1',
collectionName: 'projects',
created: '',
updated: '',
title: 'My App',
year: '2024',
role: 'Lead Developer',
description: 'A cool app.',
details: ['Feature A'],
stack: ['React', 'TypeScript'],
image: '',
order: 1,
},
{
id: '2',
collectionId: 'col1',
collectionName: 'projects',
created: '',
updated: '',
title: 'Other Project',
year: '2023',
role: 'Developer',
description: 'Another thing.',
details: [],
stack: ['Go'],
image: '',
order: 2,
},
];
const listResponse = (items: typeof mockItems) => ({
items,
page: 1,
perPage: 50,
totalItems: items.length,
totalPages: 1,
});
describe('ProjectsSection', () => {
beforeEach(() => {
vi.mocked(getCollection).mockResolvedValue(listResponse(mockItems) as never);
});
describe('rendering', () => {
it('renders a card for each project', async () => {
render(await ProjectsSection());
expect(screen.getByText('My App')).toBeInTheDocument();
expect(screen.getByText('Other Project')).toBeInTheDocument();
});
it('renders project descriptions', async () => {
render(await ProjectsSection());
expect(screen.getByText('A cool app.')).toBeInTheDocument();
});
it('renders tech stack tags', async () => {
render(await ProjectsSection());
expect(screen.getByText('React')).toBeInTheDocument();
expect(screen.getByText('TypeScript')).toBeInTheDocument();
expect(screen.getByText('Go')).toBeInTheDocument();
});
it('renders year badge for each project', async () => {
render(await ProjectsSection());
expect(screen.getByText('2024')).toBeInTheDocument();
expect(screen.getByText('2023')).toBeInTheDocument();
});
});
describe('empty state', () => {
it('renders empty grid when no items', async () => {
vi.mocked(getCollection).mockResolvedValue(listResponse([]) as never);
const { container } = render(await ProjectsSection());
expect(container.firstChild).toBeEmptyDOMElement();
});
});
});
@@ -0,0 +1,41 @@
import { ProjectCard } from '$entities/project';
import type { ProjectRecord } from '$shared/api';
import { getCollection } from '$shared/api';
/** Base URL for PocketBase file storage */
const PB_URL = process.env.NEXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090';
/**
* Builds a PocketBase file URL for a project image.
*/
function buildImageUrl(project: ProjectRecord): string | undefined {
if (!project.image) {
return undefined;
}
return `${PB_URL}/api/files/${project.collectionId}/${project.id}/${project.image}`;
}
/**
* Projects section component.
* Displays portfolio projects in a two-column grid, sorted by order field.
*/
export default async function ProjectsSection() {
const { items } = await getCollection<ProjectRecord>('projects', {
sort: 'order',
});
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{items.map((project) => (
<ProjectCard
key={project.id}
title={project.title}
year={project.year}
description={project.description}
tags={project.stack}
imageUrl={buildImageUrl(project)}
/>
))}
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { SectionsAccordion } from './ui/SectionsAccordion/SectionsAccordion';
@@ -0,0 +1,106 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { SectionRecord } from '$entities/Section';
import { SectionsAccordion } from './SectionsAccordion';
const baseRecord = { collectionId: 'c1', collectionName: 'sections', created: '', updated: '' };
const sections: SectionRecord[] = [
{ ...baseRecord, id: '1', slug: 'intro', title: 'Intro', number: '01', order: 1 },
{ ...baseRecord, id: '2', slug: 'bio', title: 'Bio', number: '02', order: 2 },
{ ...baseRecord, id: '3', slug: 'skills', title: 'Skills', number: '03', order: 3 },
];
describe('SectionsAccordion', () => {
describe('initial state', () => {
it('renders the first section as active (h1)', () => {
render(
<SectionsAccordion sections={sections}>
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
});
it('renders remaining sections as collapsed buttons', () => {
render(
<SectionsAccordion sections={sections}>
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2);
});
});
describe('interaction', () => {
it('activates clicked section', async () => {
const user = userEvent.setup();
render(
<SectionsAccordion sections={sections}>
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
});
it('deactivates previously active section after click', async () => {
const user = userEvent.setup();
render(
<SectionsAccordion sections={sections}>
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
expect(screen.queryByRole('heading', { level: 1, name: /01\. Intro/i })).not.toBeInTheDocument();
});
it('only one section is active at a time', async () => {
const user = userEvent.setup();
render(
<SectionsAccordion sections={sections}>
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
await user.click(screen.getByRole('button', { name: /03\. Skills/i }));
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
});
});
describe('content slots', () => {
it('shows active section content', () => {
render(
<SectionsAccordion sections={sections}>
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
expect(screen.getByText('Intro content')).toBeInTheDocument();
});
it('shows correct content after switching sections', async () => {
const user = userEvent.setup();
render(
<SectionsAccordion sections={sections}>
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
expect(screen.getByText('Bio content')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,43 @@
'use client';
import type { ReactNode } from 'react';
import { Children, useState } from 'react';
import type { SectionRecord } from '$entities/Section';
import { SectionAccordion } from '$entities/Section';
type Props = {
/**
* Ordered section metadata — drives navigation labels and IDs
*/
sections: SectionRecord[];
/**
* Pre-rendered RSC content slots, one per section, matched by index
*/
children: ReactNode;
};
/**
* Manages accordion open/close state across all portfolio sections.
* Receives RSC content as opaque children slots, matched positionally to sections.
*/
export function SectionsAccordion({ sections, children }: Props) {
const [activeSlug, setActiveSlug] = useState(sections[0]?.slug ?? '');
const slots = Children.toArray(children);
return (
<div className="space-y-2">
{sections.map((section, i) => (
<SectionAccordion
key={section.slug}
id={section.slug}
number={section.number}
title={section.title}
isActive={activeSlug === section.slug}
onClick={() => setActiveSlug(section.slug)}
>
{slots[i]}
</SectionAccordion>
))}
</div>
);
}