Compare commits
4 Commits
41edc7edf7
...
1a413e3d04
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a413e3d04 | |||
| 24bf946cb0 | |||
| 4219a7b4e7 | |||
| 4b18fc454e |
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+32
-46
@@ -1,51 +1,37 @@
|
|||||||
import Image from 'next/image';
|
import type { SectionRecord } from '$entities/Section';
|
||||||
|
import { getCollection } from '$shared/api';
|
||||||
|
import type { NavItem } from '$widgets/Navigation';
|
||||||
|
import { MobileNav, SidebarNav } from '$widgets/Navigation';
|
||||||
|
import { SectionFactory } from '$widgets/SectionFactory';
|
||||||
|
import { SectionsAccordion } from '$widgets/SectionsAccordion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portfolio home page.
|
||||||
|
*
|
||||||
|
* Fetches all sections at build time (SSG). Renders a fixed sidebar with
|
||||||
|
* section navigation and a scrollable main column with accordion sections.
|
||||||
|
*/
|
||||||
|
export default async function Home() {
|
||||||
|
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
||||||
|
sort: 'order',
|
||||||
|
});
|
||||||
|
|
||||||
|
const navItems: NavItem[] = sections.map((s) => ({
|
||||||
|
id: s.slug,
|
||||||
|
label: s.title,
|
||||||
|
number: s.number,
|
||||||
|
}));
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="min-h-screen lg:flex">
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<SidebarNav items={navItems} />
|
||||||
<Image className="dark:invert" src="/next.svg" alt="Next.js logo" width={100} height={20} priority />
|
<main className="flex-1 lg:ml-[33.333%] px-8 py-12 lg:py-16 lg:px-16">
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
<MobileNav items={navItems} />
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
<SectionsAccordion sections={sections}>
|
||||||
To get started, edit the page.tsx file.
|
{sections.map((s) => (
|
||||||
</h1>
|
<SectionFactory key={s.slug} slug={s.slug} />
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
))}
|
||||||
Looking for a starting point or more instructions? Head over to{' '}
|
</SectionsAccordion>
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{' '}
|
|
||||||
or the{' '}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{' '}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image className="dark:invert" src="/vercel.svg" alt="Vercel logomark" width={16} height={16} />
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,19 +50,19 @@ describe('ProjectMetadata', () => {
|
|||||||
|
|
||||||
it('year section has no brutal-border-top (first section)', () => {
|
it('year section has no brutal-border-top (first section)', () => {
|
||||||
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
const sections = container.firstChild?.childNodes;
|
const sections = container.firstChild?.childNodes as NodeListOf<Element>;
|
||||||
expect(sections[0]).not.toHaveClass('brutal-border-top');
|
expect(sections[0]).not.toHaveClass('brutal-border-top');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('role section has brutal-border-top and pt-6', () => {
|
it('role section has brutal-border-top and pt-6', () => {
|
||||||
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
const sections = container.firstChild?.childNodes;
|
const sections = container.firstChild?.childNodes as NodeListOf<Element>;
|
||||||
expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6');
|
expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stack section has brutal-border-top and pt-6', () => {
|
it('stack section has brutal-border-top and pt-6', () => {
|
||||||
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
const sections = container.firstChild?.childNodes;
|
const sections = container.firstChild?.childNodes as NodeListOf<Element>;
|
||||||
expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6');
|
expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('groupByKey', () => {
|
|||||||
{ category: 'A', order: 1 },
|
{ category: 'A', order: 1 },
|
||||||
{ category: 'A', order: 2 },
|
{ category: 'A', order: 2 },
|
||||||
];
|
];
|
||||||
expect(groupByKey(items, 'category')['A']).toEqual([
|
expect(groupByKey(items, 'category').A).toEqual([
|
||||||
{ category: 'A', order: 1 },
|
{ category: 'A', order: 1 },
|
||||||
{ category: 'A', order: 2 },
|
{ category: 'A', order: 2 },
|
||||||
]);
|
]);
|
||||||
@@ -41,7 +41,7 @@ describe('groupByKey', () => {
|
|||||||
];
|
];
|
||||||
const result = groupByKey(items, 'type');
|
const result = groupByKey(items, 'type');
|
||||||
expect(Object.keys(result)).toHaveLength(1);
|
expect(Object.keys(result)).toHaveLength(1);
|
||||||
expect(result['X']).toHaveLength(2);
|
expect(result.X).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles single item', () => {
|
it('handles single item', () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
import type { PageContentRecord } from '$shared/api';
|
import type { PageContentRecord } from '$shared/api';
|
||||||
import { getFirstRecord } from '$shared/api';
|
import { getFirstRecord } from '$shared/api';
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ export default async function BioSection() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <p>Loading bio content...</p>;
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
import type { PageContentRecord } from '$shared/api';
|
import type { PageContentRecord } from '$shared/api';
|
||||||
import { getFirstRecord } from '$shared/api';
|
import { getFirstRecord } from '$shared/api';
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ export default async function IntroSection() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <p>Loading intro content...</p>;
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type { SectionFactoryProps } from './ui/SectionFactory/SectionFactory';
|
||||||
|
export { SectionFactory } from './ui/SectionFactory/SectionFactory';
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
/**
|
import BioSection from '../../../BioSection/ui/BioSection/BioSection';
|
||||||
* Registry of dynamic section widgets.
|
import ExperienceSection from '../../../ExperienceSection/ui/ExperienceSection/ExperienceSection';
|
||||||
*/
|
import IntroSection from '../../../IntroSection/ui/IntroSection/IntroSection';
|
||||||
const SECTIONS: Record<string, () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>> = {
|
import ProjectsSection from '../../../ProjectsSection/ui/ProjectsSection/ProjectsSection';
|
||||||
intro: () => import('../../../IntroSection/ui/IntroSection/IntroSection'),
|
import SkillsSection from '../../../SkillsSection/ui/SkillsSection/SkillsSection';
|
||||||
bio: () => import('../../../BioSection/ui/BioSection/BioSection'),
|
|
||||||
skills: () => import('../../../SkillsSection/ui/SkillsSection/SkillsSection'),
|
|
||||||
experience: () => import('../../../ExperienceSection/ui/ExperienceSection/ExperienceSection'),
|
|
||||||
projects: () => import('../../../ProjectsSection/ui/ProjectsSection/ProjectsSection'),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the SectionFactory widget.
|
* Props for the SectionFactory widget.
|
||||||
@@ -21,18 +16,25 @@ export type SectionFactoryProps = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// biome-ignore lint/suspicious/noExplicitAny: registry holds heterogeneous RSC components
|
||||||
* Factory widget that dynamically imports and renders the correct section widget.
|
const SECTIONS: Record<string, ComponentType<any>> = {
|
||||||
* Based on the provided slug.
|
intro: IntroSection,
|
||||||
*/
|
bio: BioSection,
|
||||||
export async function SectionFactory({ slug }: SectionFactoryProps) {
|
skills: SkillsSection,
|
||||||
const loadSection = SECTIONS[slug];
|
experience: ExperienceSection,
|
||||||
|
projects: ProjectsSection,
|
||||||
|
};
|
||||||
|
|
||||||
if (!loadSection) {
|
/**
|
||||||
|
* Renders the correct section widget for a given slug.
|
||||||
|
* Uses a static registry — React resolves async RSC children internally.
|
||||||
|
*/
|
||||||
|
export function SectionFactory({ slug }: SectionFactoryProps) {
|
||||||
|
const Component = SECTIONS[slug];
|
||||||
|
|
||||||
|
if (!Component) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { default: SectionComponent } = await loadSection();
|
return <Component />;
|
||||||
|
|
||||||
return <SectionComponent />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
import type { SkillRecord } from '$shared/api';
|
import type { SkillRecord } from '$shared/api';
|
||||||
import { getCollection } from '$shared/api';
|
import { getCollection } from '$shared/api';
|
||||||
|
import { groupByKey } from '$shared/lib';
|
||||||
import { Badge } from '$shared/ui';
|
import { Badge } from '$shared/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,16 +13,11 @@ export default async function SkillsSection() {
|
|||||||
sort: 'category,order',
|
sort: 'category,order',
|
||||||
});
|
});
|
||||||
|
|
||||||
const categories = data.items.reduce(
|
if (!data.items.length) {
|
||||||
(acc, skill) => {
|
notFound();
|
||||||
if (!acc[skill.category]) {
|
|
||||||
acc[skill.category] = [];
|
|
||||||
}
|
}
|
||||||
acc[skill.category].push(skill);
|
|
||||||
return acc;
|
const categories = groupByKey(data.items, 'category');
|
||||||
},
|
|
||||||
{} as Record<string, SkillRecord[]>,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
|
|||||||
Reference in New Issue
Block a user