From 4b18fc454ea6fab904ff5b681573869ac8b32532 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 5 May 2026 09:41:39 +0300 Subject: [PATCH] chore: add mock API route handlers and dev env config --- .../collections/[collection]/records/route.ts | 263 ++++++++++++++++++ src/widgets/ExperienceSection/index.ts | 1 + .../ExperienceSection.test.tsx | 87 ++++++ .../ExperienceSection/ExperienceSection.tsx | 28 ++ src/widgets/ProjectsSection/index.ts | 1 + .../ProjectsSection/ProjectsSection.test.tsx | 88 ++++++ .../ui/ProjectsSection/ProjectsSection.tsx | 41 +++ src/widgets/SectionsAccordion/index.ts | 1 + .../SectionsAccordion.test.tsx | 106 +++++++ .../SectionsAccordion/SectionsAccordion.tsx | 43 +++ 10 files changed, 659 insertions(+) create mode 100644 app/api/collections/[collection]/records/route.ts create mode 100644 src/widgets/ExperienceSection/index.ts create mode 100644 src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.test.tsx create mode 100644 src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx create mode 100644 src/widgets/ProjectsSection/index.ts create mode 100644 src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.test.tsx create mode 100644 src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx create mode 100644 src/widgets/SectionsAccordion/index.ts create mode 100644 src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx create mode 100644 src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx diff --git a/app/api/collections/[collection]/records/route.ts b/app/api/collections/[collection]/records/route.ts new file mode 100644 index 0000000..8fb18ed --- /dev/null +++ b/app/api/collections/[collection]/records/route.ts @@ -0,0 +1,263 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const base = { created: '', updated: '' }; + +const FIXTURES: Record = { + 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, + }); +} diff --git a/src/widgets/ExperienceSection/index.ts b/src/widgets/ExperienceSection/index.ts new file mode 100644 index 0000000..78cef7e --- /dev/null +++ b/src/widgets/ExperienceSection/index.ts @@ -0,0 +1 @@ +export { default as ExperienceSection } from './ui/ExperienceSection/ExperienceSection'; diff --git a/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.test.tsx b/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.test.tsx new file mode 100644 index 0000000..2303fde --- /dev/null +++ b/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.test.tsx @@ -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(); + }); + }); +}); diff --git a/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx b/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx new file mode 100644 index 0000000..b7db91f --- /dev/null +++ b/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx @@ -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('experience', { + sort: 'order', + }); + + return ( +
+ {items.map((exp) => ( + + ))} +
+ ); +} diff --git a/src/widgets/ProjectsSection/index.ts b/src/widgets/ProjectsSection/index.ts new file mode 100644 index 0000000..b837da7 --- /dev/null +++ b/src/widgets/ProjectsSection/index.ts @@ -0,0 +1 @@ +export { default as ProjectsSection } from './ui/ProjectsSection/ProjectsSection'; diff --git a/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.test.tsx b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.test.tsx new file mode 100644 index 0000000..e4a79f2 --- /dev/null +++ b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.test.tsx @@ -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(); + }); + }); +}); diff --git a/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx new file mode 100644 index 0000000..56a0f46 --- /dev/null +++ b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx @@ -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('projects', { + sort: 'order', + }); + + return ( +
+ {items.map((project) => ( + + ))} +
+ ); +} diff --git a/src/widgets/SectionsAccordion/index.ts b/src/widgets/SectionsAccordion/index.ts new file mode 100644 index 0000000..4a468c4 --- /dev/null +++ b/src/widgets/SectionsAccordion/index.ts @@ -0,0 +1 @@ +export { SectionsAccordion } from './ui/SectionsAccordion/SectionsAccordion'; diff --git a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx new file mode 100644 index 0000000..9c544f5 --- /dev/null +++ b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx @@ -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( + +
Intro content
+
Bio content
+
Skills content
+
, + ); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro'); + }); + + it('renders remaining sections as collapsed buttons', () => { + render( + +
Intro content
+
Bio content
+
Skills content
+
, + ); + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); + }); + }); + + describe('interaction', () => { + it('activates clicked section', async () => { + const user = userEvent.setup(); + render( + +
Intro content
+
Bio content
+
Skills content
+
, + ); + 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( + +
Intro content
+
Bio content
+
Skills content
+
, + ); + 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( + +
Intro content
+
Bio content
+
Skills content
+
, + ); + 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( + +
Intro content
+
Bio content
+
Skills content
+
, + ); + expect(screen.getByText('Intro content')).toBeInTheDocument(); + }); + + it('shows correct content after switching sections', async () => { + const user = userEvent.setup(); + render( + +
Intro content
+
Bio content
+
Skills content
+
, + ); + await user.click(screen.getByRole('button', { name: /02\. Bio/i })); + expect(screen.getByText('Bio content')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx new file mode 100644 index 0000000..04673b3 --- /dev/null +++ b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx @@ -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 ( +
+ {sections.map((section, i) => ( + setActiveSlug(section.slug)} + > + {slots[i]} + + ))} +
+ ); +}