fix: storybook font rendering and shared fonts module #1
+13
@@ -20,6 +20,8 @@
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
/docs
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
@@ -43,5 +45,16 @@ next-env.d.ts
|
|||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
|
||||||
|
# hidden files (allow some, ignore others)
|
||||||
|
.**
|
||||||
|
!/.storybook
|
||||||
|
!/.yarn
|
||||||
|
!/yarnrc.yml
|
||||||
|
!/.claude
|
||||||
|
!/.vscode
|
||||||
|
!/.gitattributes
|
||||||
|
!/.gitignore
|
||||||
|
!/biome.json
|
||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import React from 'react'
|
||||||
import type { Preview } from '@storybook/nextjs-vite'
|
import type { Preview } from '@storybook/nextjs-vite'
|
||||||
|
import { fraunces, publicSans } from '../src/shared/lib'
|
||||||
import '../app/globals.css'
|
import '../app/globals.css'
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
@@ -16,6 +18,13 @@ const preview: Preview = {
|
|||||||
test: 'todo',
|
test: 'todo',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div className={`${fraunces.variable} ${publicSans.variable}`}>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default preview
|
export default preview
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import type { SectionRecord } from '$entities/Section';
|
||||||
|
import { getCollection } from '$shared/api';
|
||||||
|
import { SectionFactory } from '$widgets/SectionFactory';
|
||||||
|
import { SectionsAccordion } from '$widgets/SectionsAccordion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional catchall: `/` → first section, `/:slug` → that section.
|
||||||
|
*/
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
||||||
|
sort: 'order',
|
||||||
|
tags: ['sections'],
|
||||||
|
});
|
||||||
|
return [{}, ...sections.map((s) => ({ slug: [s.slug] }))];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ slug?: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portfolio page — one route per section, sections list always visible.
|
||||||
|
*/
|
||||||
|
export default async function SectionPage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
||||||
|
sort: 'order',
|
||||||
|
tags: ['sections'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sections.length === 0) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSlug = slug?.[0] ?? sections[0].slug;
|
||||||
|
|
||||||
|
if (!sections.some((s) => s.slug === activeSlug)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="px-8 py-12 lg:py-16 lg:px-16">
|
||||||
|
<SectionsAccordion sections={sections} activeSlug={activeSlug}>
|
||||||
|
{sections.map((s) => (
|
||||||
|
<SectionFactory key={s.slug} slug={s.slug} />
|
||||||
|
))}
|
||||||
|
</SectionsAccordion>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
const base = { created: '', updated: '' };
|
||||||
|
|
||||||
|
const FIXTURES: Record<string, unknown[]> = {
|
||||||
|
site_settings: [
|
||||||
|
{
|
||||||
|
id: 'ss1',
|
||||||
|
collectionId: 'site_settings',
|
||||||
|
collectionName: 'site_settings',
|
||||||
|
...base,
|
||||||
|
cv: '',
|
||||||
|
contacts: 'c1',
|
||||||
|
expand: {
|
||||||
|
contacts: {
|
||||||
|
id: 'c1',
|
||||||
|
collectionId: 'contacts',
|
||||||
|
collectionName: 'contacts',
|
||||||
|
...base,
|
||||||
|
email: 'hello@allmy.work',
|
||||||
|
socials: ['s1', 's2'],
|
||||||
|
expand: {
|
||||||
|
socials: [
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
collectionId: 'contact',
|
||||||
|
collectionName: 'contact',
|
||||||
|
...base,
|
||||||
|
label: 'GitHub',
|
||||||
|
url: 'https://github.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2',
|
||||||
|
collectionId: 'contact',
|
||||||
|
collectionName: 'contact',
|
||||||
|
...base,
|
||||||
|
label: 'LinkedIn',
|
||||||
|
url: 'https://linkedin.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contacts: [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
collectionId: 'contacts',
|
||||||
|
collectionName: 'contacts',
|
||||||
|
...base,
|
||||||
|
email: 'hello@allmy.work',
|
||||||
|
socials: ['s1', 's2'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contact: [
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
collectionId: 'contact',
|
||||||
|
collectionName: 'contact',
|
||||||
|
...base,
|
||||||
|
label: 'GitHub',
|
||||||
|
url: 'https://github.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2',
|
||||||
|
collectionId: 'contact',
|
||||||
|
collectionName: 'contact',
|
||||||
|
...base,
|
||||||
|
label: 'LinkedIn',
|
||||||
|
url: 'https://linkedin.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return Object.keys(FIXTURES).map((collection) => ({ collection }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { revalidateTag } from 'next/cache';
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/revalidate
|
||||||
|
*
|
||||||
|
* Webhook endpoint for on-demand ISR. PocketBase (or any external
|
||||||
|
* caller) sends this request after mutating CMS content so the
|
||||||
|
* relevant tag is purged from the Next.js data cache.
|
||||||
|
*
|
||||||
|
* Expected body: `{ "tag": "<collection-name>" }`
|
||||||
|
* Required header: `x-revalidate-secret: <REVALIDATE_SECRET>`
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const secret = request.headers.get('x-revalidate-secret');
|
||||||
|
|
||||||
|
if (secret !== process.env.REVALIDATE_SECRET) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof body !== 'object' ||
|
||||||
|
body === null ||
|
||||||
|
!('tag' in body) ||
|
||||||
|
typeof (body as Record<string, unknown>).tag !== 'string'
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'Missing or invalid "tag" field' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = (body as { tag: string }).tag;
|
||||||
|
|
||||||
|
/* Second arg is required by the Next.js 15 type signature;
|
||||||
|
* "max" means the purge propagates indefinitely — correct for
|
||||||
|
* an on-demand webhook that has no TTL of its own. */
|
||||||
|
revalidateTag(tag, 'max');
|
||||||
|
|
||||||
|
return NextResponse.json({ revalidated: true, tag }, { status: 200 });
|
||||||
|
}
|
||||||
+8
-23
@@ -1,28 +1,12 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next';
|
||||||
import { Fraunces, Public_Sans } from 'next/font/google'
|
import { fraunces, publicSans } from '$shared/lib';
|
||||||
import './globals.css'
|
import { Footer } from '$widgets/Footer';
|
||||||
|
import './globals.css';
|
||||||
/**
|
|
||||||
* Heading font — variable axes for brutalist variation settings
|
|
||||||
*/
|
|
||||||
const fraunces = Fraunces({
|
|
||||||
subsets: ['latin'],
|
|
||||||
variable: '--font-fraunces',
|
|
||||||
axes: ['opsz', 'SOFT', 'WONK'],
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Body font
|
|
||||||
*/
|
|
||||||
const publicSans = Public_Sans({
|
|
||||||
subsets: ['latin'],
|
|
||||||
variable: '--font-public-sans',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Portfolio',
|
title: 'Portfolio',
|
||||||
description: 'Portfolio',
|
description: 'Portfolio',
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root layout — injects font CSS variables used by theme.css
|
* Root layout — injects font CSS variables used by theme.css
|
||||||
@@ -30,9 +14,10 @@ export const metadata: Metadata = {
|
|||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${fraunces.variable} ${publicSans.variable}`}>
|
<body className={`${fraunces.variable} ${publicSans.variable} flex flex-col min-h-screen`}>
|
||||||
{children}
|
{children}
|
||||||
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Link } from '$shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom 404 page — shown for any unmatched route.
|
||||||
|
*/
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="flex-1 flex flex-col items-center justify-center gap-6">
|
||||||
|
<h1 className="font-heading text-[clamp(8rem,20vw,18rem)] leading-none">404</h1>
|
||||||
|
<Link href="/">Back to main</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
|
||||||
<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">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<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{" "}
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false,
|
||||||
|
"includes": ["src/**/*", "app/**/*", "*.ts", "*.tsx", "*.js", "*.jsx"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 120
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedVariables": "warn"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "warn",
|
||||||
|
"useBlockStatements": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"jsxQuoteStyle": "double",
|
||||||
|
"semicolons": "always",
|
||||||
|
"trailingCommas": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"formatter": {
|
||||||
|
"trailingCommas": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# URL-Driven Section Routing — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-07
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the single-page client-state accordion with multi-page URL-driven routing. Each portfolio section gets its own static URL. The sections list remains visible at all times; clicking a section heading navigates to its page.
|
||||||
|
|
||||||
|
## Route Structure
|
||||||
|
|
||||||
|
Delete `app/page.tsx`. Create `app/[[...slug]]/page.tsx` (optional catchall).
|
||||||
|
|
||||||
|
| URL | Active section |
|
||||||
|
|---|---|
|
||||||
|
| `/` | `sections[0].slug` (first section, URL stays `/`) |
|
||||||
|
| `/intro` | `intro` |
|
||||||
|
| `/bio` | `bio` |
|
||||||
|
| `/skills` | `skills` |
|
||||||
|
| `/experience` | `experience` |
|
||||||
|
| `/projects` | `projects` |
|
||||||
|
|
||||||
|
`generateStaticParams` emits one entry per section plus the root:
|
||||||
|
```ts
|
||||||
|
[{}, { slug: ['intro'] }, { slug: ['bio'] }, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Changes
|
||||||
|
|
||||||
|
### `SectionAccordion` (entity)
|
||||||
|
|
||||||
|
- Replace `onClick: () => void` prop with `href: string`
|
||||||
|
- Inactive state: render `<Link href={href}>` instead of `<button onClick>`
|
||||||
|
- No `'use client'` needed (already a server component)
|
||||||
|
|
||||||
|
### `SectionsAccordion` (widget)
|
||||||
|
|
||||||
|
- Remove `'use client'` directive and `useState`
|
||||||
|
- Add `activeSlug: string` prop (passed from page server component)
|
||||||
|
- Pass `href={`/${section.slug}`}` to each `SectionAccordion`
|
||||||
|
- Keep `children` slot pattern for RSC content
|
||||||
|
|
||||||
|
### `SidebarNav` (widget)
|
||||||
|
|
||||||
|
- Remove `IntersectionObserver` and `scrollToSection`
|
||||||
|
- Add `usePathname()` hook for active detection
|
||||||
|
- Active rule: `pathname === `/${item.id}`` or `(pathname === '/' && item is first)`
|
||||||
|
- Items become `<Link href={`/${item.id}`}>` instead of `<button onClick>`
|
||||||
|
- Keep `'use client'` (required for `usePathname`)
|
||||||
|
|
||||||
|
### `MobileNav` (widget)
|
||||||
|
|
||||||
|
- Section items become `<Link>` that also close the menu on navigate
|
||||||
|
- Use `usePathname` in a `useEffect` to close menu on route change (replaces manual close-on-click)
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
[[...slug]]/page.tsx (RSC)
|
||||||
|
├─ fetch sections[]
|
||||||
|
├─ activeSlug = params?.slug?.[0] ?? sections[0].slug
|
||||||
|
├─ notFound() if activeSlug not in sections
|
||||||
|
├─ SidebarNav items={navItems} ← usePathname for active state
|
||||||
|
└─ SectionsAccordion sections activeSlug
|
||||||
|
├─ SectionAccordion href="/" isActive=true → SectionFactory content
|
||||||
|
├─ SectionAccordion href="/bio" → Link
|
||||||
|
└─ SectionAccordion href="/skills" → Link
|
||||||
|
```
|
||||||
|
|
||||||
|
No client state in the section list. `SidebarNav` remains client-only for `usePathname`.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Unknown slug → `notFound()` at page level (404 static page)
|
||||||
|
- Empty sections list → `notFound()` at page level
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `SectionsAccordion`: drop interaction (click/activate) tests; replace with prop-driven assertions — correct `isActive` and `href` per section given `activeSlug`
|
||||||
|
- `SidebarNav`: drop `IntersectionObserver` mock; mock `usePathname`; assert active link class
|
||||||
|
- `MobileNav`: items become links; assert close-on-navigate via `usePathname` effect
|
||||||
|
- `[[...slug]]/page.tsx`: no unit tests (pure orchestration of tested components)
|
||||||
|
|
||||||
|
## No New Dependencies
|
||||||
|
|
||||||
|
`next/link` and `next/navigation` already present.
|
||||||
@@ -0,0 +1,882 @@
|
|||||||
|
# URL-Driven Section Routing Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Replace the single-page client-state accordion with URL-driven routing — each section gets its own static URL (`/intro`, `/bio`, etc.), clicking a section heading navigates between pages.
|
||||||
|
|
||||||
|
**Architecture:** `app/[[...slug]]/page.tsx` is the single RSC that handles all routes. It resolves the active slug from URL params (defaulting to first section at `/`), then passes `activeSlug` down to `SectionsAccordion` (now a server component). `SectionAccordion` entity renders inactive sections as `<Link>` elements. `SidebarNav` uses `usePathname()` for active state.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 16 App Router, React 19, Vitest + RTL, TypeScript strict, Biome, `next/link`, `next/navigation`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 0: Commit next.config.ts fix
|
||||||
|
|
||||||
|
The `output: 'export'` was already made conditional on `NODE_ENV === 'production'` to allow route handlers in dev. Commit it.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Already modified: `next.config.ts`
|
||||||
|
|
||||||
|
**Step 1: Verify file is correct**
|
||||||
|
|
||||||
|
`next.config.ts` should read:
|
||||||
|
```ts
|
||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const isExport = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* output: 'export' only applies at build time — enabling it in dev mode
|
||||||
|
* breaks route handlers (incompatible with force-dynamic in Next.js 16) */
|
||||||
|
...(isExport ? { output: 'export' } : {}),
|
||||||
|
images: { unoptimized: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add next.config.ts
|
||||||
|
git commit -m "fix: make output export build-only so dev route handlers work"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Update SectionAccordion entity — onClick → href (TDD)
|
||||||
|
|
||||||
|
Replace the inactive `<button onClick>` with `<Link href>`. The entity already has tests — update them first.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx`
|
||||||
|
- Modify: `src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx`
|
||||||
|
|
||||||
|
**Step 1: Update the tests**
|
||||||
|
|
||||||
|
Replace `src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx` entirely:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { SectionAccordion } from './SectionAccordion';
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
number: '01',
|
||||||
|
title: 'About',
|
||||||
|
id: 'about',
|
||||||
|
isActive: false,
|
||||||
|
href: '/about',
|
||||||
|
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 link with number and title', () => {
|
||||||
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('link points to the correct href', () => {
|
||||||
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render children', () => {
|
||||||
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
|
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a button', () => {
|
||||||
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 link', () => {
|
||||||
|
render(<SectionAccordion {...activeProps} />);
|
||||||
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('content wrapper has animate-fadeIn class', () => {
|
||||||
|
const { container } = render(<SectionAccordion {...activeProps} />);
|
||||||
|
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests — verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test src/entities/Section/ui/SectionAccordion --run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — tests expecting `link` role but component still renders `button`.
|
||||||
|
|
||||||
|
**Step 3: Update the component**
|
||||||
|
|
||||||
|
Replace `src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Link from 'next/link';
|
||||||
|
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;
|
||||||
|
/**
|
||||||
|
* Navigation URL for the collapsed heading link
|
||||||
|
*/
|
||||||
|
href: string;
|
||||||
|
/**
|
||||||
|
* Section content, shown when active
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accordion-style section that collapses to a navigation link when inactive.
|
||||||
|
*/
|
||||||
|
export function SectionAccordion({ number, title, id, isActive, href, 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>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="block 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>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run tests — verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test src/entities/Section/ui/SectionAccordion --run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/entities/Section/ui/SectionAccordion/
|
||||||
|
git commit -m "feat: SectionAccordion inactive state uses Link href instead of button onClick"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Update SectionsAccordion widget — drop client state, add activeSlug prop (TDD)
|
||||||
|
|
||||||
|
The widget becomes a server component. `activeSlug` is passed as a prop from the page. `children` is a single RSC slot for the active section content only.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx`
|
||||||
|
- Modify: `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx`
|
||||||
|
|
||||||
|
**Step 1: Rewrite the tests**
|
||||||
|
|
||||||
|
Replace `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
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('active section rendering', () => {
|
||||||
|
it('renders the active section as h1', () => {
|
||||||
|
render(
|
||||||
|
<SectionsAccordion sections={sections} activeSlug="bio">
|
||||||
|
<div>Bio content</div>
|
||||||
|
</SectionsAccordion>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders active section children', () => {
|
||||||
|
render(
|
||||||
|
<SectionsAccordion sections={sections} activeSlug="bio">
|
||||||
|
<div>Bio content</div>
|
||||||
|
</SectionsAccordion>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Bio content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inactive section rendering', () => {
|
||||||
|
it('renders inactive sections as links', () => {
|
||||||
|
render(
|
||||||
|
<SectionsAccordion sections={sections} activeSlug="bio">
|
||||||
|
<div>Bio content</div>
|
||||||
|
</SectionsAccordion>,
|
||||||
|
);
|
||||||
|
const links = screen.getAllByRole('link');
|
||||||
|
expect(links).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inactive links point to correct hrefs', () => {
|
||||||
|
render(
|
||||||
|
<SectionsAccordion sections={sections} activeSlug="bio">
|
||||||
|
<div>Bio content</div>
|
||||||
|
</SectionsAccordion>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: /01.*Intro/i })).toHaveAttribute('href', '/intro');
|
||||||
|
expect(screen.getByRole('link', { name: /03.*Skills/i })).toHaveAttribute('href', '/skills');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render children for inactive sections', () => {
|
||||||
|
render(
|
||||||
|
<SectionsAccordion sections={sections} activeSlug="bio">
|
||||||
|
<div>Bio content</div>
|
||||||
|
</SectionsAccordion>,
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText('Bio content')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('first section default', () => {
|
||||||
|
it('shows first section as active when activeSlug matches first', () => {
|
||||||
|
render(
|
||||||
|
<SectionsAccordion sections={sections} activeSlug="intro">
|
||||||
|
<div>Intro content</div>
|
||||||
|
</SectionsAccordion>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests — verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test src/widgets/SectionsAccordion --run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — component still uses `useState` and doesn't accept `activeSlug` prop.
|
||||||
|
|
||||||
|
**Step 3: Rewrite the component**
|
||||||
|
|
||||||
|
Replace `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } 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[];
|
||||||
|
/**
|
||||||
|
* Slug of the currently active section
|
||||||
|
*/
|
||||||
|
activeSlug: string;
|
||||||
|
/**
|
||||||
|
* Content for the active section — rendered inside the expanded accordion item
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders all portfolio sections as an accordion list.
|
||||||
|
* Active section is determined by URL (activeSlug from page params).
|
||||||
|
* Inactive sections render as navigation links.
|
||||||
|
*/
|
||||||
|
export function SectionsAccordion({ sections, activeSlug, children }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<SectionAccordion
|
||||||
|
key={section.slug}
|
||||||
|
id={section.slug}
|
||||||
|
number={section.number}
|
||||||
|
title={section.title}
|
||||||
|
isActive={activeSlug === section.slug}
|
||||||
|
href={`/${section.slug}`}
|
||||||
|
>
|
||||||
|
{activeSlug === section.slug ? children : null}
|
||||||
|
</SectionAccordion>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run tests — verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test src/widgets/SectionsAccordion --run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/widgets/SectionsAccordion/
|
||||||
|
git commit -m "refactor: SectionsAccordion server component, activeSlug prop replaces useState"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Update SidebarNav — IntersectionObserver → usePathname (TDD)
|
||||||
|
|
||||||
|
Items become `<Link>` elements. Active state driven by `usePathname()`. The first section is also active at `/`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/widgets/Navigation/ui/SidebarNav.test.tsx`
|
||||||
|
- Modify: `src/widgets/Navigation/ui/SidebarNav.tsx`
|
||||||
|
|
||||||
|
**Step 1: Rewrite the tests**
|
||||||
|
|
||||||
|
Replace `src/widgets/Navigation/ui/SidebarNav.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import type { NavItem } from '../model/types';
|
||||||
|
import { SidebarNav } from './SidebarNav';
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
usePathname: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
const ITEMS: NavItem[] = [
|
||||||
|
{ id: 'bio', label: 'Bio', number: '01' },
|
||||||
|
{ id: 'work', label: 'Work', number: '02' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('SidebarNav', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(usePathname).mockReturnValue('/bio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a nav element', () => {
|
||||||
|
render(<SidebarNav items={ITEMS} />);
|
||||||
|
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Index" heading', () => {
|
||||||
|
render(<SidebarNav items={ITEMS} />);
|
||||||
|
expect(screen.getByText('Index')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Digital Monograph" subtitle', () => {
|
||||||
|
render(<SidebarNav items={ITEMS} />);
|
||||||
|
expect(screen.getByText('Digital Monograph')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders each item label and number', () => {
|
||||||
|
render(<SidebarNav items={ITEMS} />);
|
||||||
|
expect(screen.getByText('Bio')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('01')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Work')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('02')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Quick Links" section', () => {
|
||||||
|
render(<SidebarNav items={ITEMS} />);
|
||||||
|
expect(screen.getByText('Quick Links')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Email quick link', () => {
|
||||||
|
render(<SidebarNav items={ITEMS} />);
|
||||||
|
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a link for each item', () => {
|
||||||
|
render(<SidebarNav items={ITEMS} />);
|
||||||
|
expect(screen.getByRole('link', { name: /Bio/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /Work/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('active state', () => {
|
||||||
|
it('marks matching pathname item as active', () => {
|
||||||
|
vi.mocked(usePathname).mockReturnValue('/bio');
|
||||||
|
render(<SidebarNav items={ITEMS} />);
|
||||||
|
const activeLink = screen.getByRole('link', { name: /Bio/i });
|
||||||
|
expect(activeLink).not.toHaveClass('opacity-40');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks non-matching item as inactive', () => {
|
||||||
|
vi.mocked(usePathname).mockReturnValue('/bio');
|
||||||
|
render(<SidebarNav items={ITEMS} />);
|
||||||
|
const inactiveLink = screen.getByRole('link', { name: /Work/i });
|
||||||
|
expect(inactiveLink).toHaveClass('opacity-40');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks first item active at root path', () => {
|
||||||
|
vi.mocked(usePathname).mockReturnValue('/');
|
||||||
|
render(<SidebarNav items={ITEMS} />);
|
||||||
|
const firstLink = screen.getByRole('link', { name: /Bio/i });
|
||||||
|
expect(firstLink).not.toHaveClass('opacity-40');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests — verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test src/widgets/Navigation/ui/SidebarNav.test.tsx --run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — component still uses `IntersectionObserver` and renders buttons, not links.
|
||||||
|
|
||||||
|
**Step 3: Rewrite the component**
|
||||||
|
|
||||||
|
Replace `src/widgets/Navigation/ui/SidebarNav.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { CONTACT_LINKS, cn } from '$shared/lib';
|
||||||
|
import type { NavItem } from '../model/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Navigation items to render
|
||||||
|
*/
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed sidebar navigation, visible on lg+ screens.
|
||||||
|
* Active section determined by current URL pathname.
|
||||||
|
*/
|
||||||
|
export function SidebarNav({ items }: Props) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An item is active when its slug matches the current pathname,
|
||||||
|
* or when the pathname is root and it is the first item.
|
||||||
|
*/
|
||||||
|
function isActive(item: NavItem): boolean {
|
||||||
|
if (pathname === `/${item.id}`) return true;
|
||||||
|
if (pathname === '/' && items[0]?.id === item.id) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed top-0 left-0 h-screen w-full lg:w-1/3 bg-ochre-clay brutal-border-right hidden lg:block overflow-y-auto z-50">
|
||||||
|
<div className="px-8 py-12 space-y-2">
|
||||||
|
<div className="mb-12">
|
||||||
|
<h2>Index</h2>
|
||||||
|
<div className="brutal-border-top pt-4">
|
||||||
|
<p className="text-sm opacity-60">Digital Monograph</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={`/${item.id}`}
|
||||||
|
className={cn(
|
||||||
|
'block w-full text-left brutal-border bg-ochre-clay px-6 py-4 transition-all duration-300',
|
||||||
|
isActive(item)
|
||||||
|
? 'shadow-[12px_12px_0_var(--carbon-black)] opacity-100 translate-x-0'
|
||||||
|
: 'opacity-40 shadow-none hover:opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline gap-4">
|
||||||
|
<span className="text-sm opacity-60">{item.number}</span>
|
||||||
|
<span className="font-heading text-xl font-black">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-12 pt-12 brutal-border-top">
|
||||||
|
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<a href={`mailto:${CONTACT_LINKS.email}`} className="block">
|
||||||
|
Email
|
||||||
|
</a>
|
||||||
|
<a href={CONTACT_LINKS.linkedin} className="block">
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
<a href={CONTACT_LINKS.instagram} className="block">
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
<a href={CONTACT_LINKS.arena} className="block">
|
||||||
|
Are.na
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run tests — verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test src/widgets/Navigation/ui/SidebarNav.test.tsx --run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/widgets/Navigation/ui/SidebarNav.tsx src/widgets/Navigation/ui/SidebarNav.test.tsx
|
||||||
|
git commit -m "refactor: SidebarNav uses usePathname and Link instead of IntersectionObserver"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Update MobileNav — section buttons → Link (TDD)
|
||||||
|
|
||||||
|
Section items become `<Link>` elements that close the menu `onClick`. The `scrollToSection` function is removed.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/widgets/Navigation/ui/MobileNav.test.tsx`
|
||||||
|
- Modify: `src/widgets/Navigation/ui/MobileNav.tsx`
|
||||||
|
|
||||||
|
**Step 1: Update the tests**
|
||||||
|
|
||||||
|
Replace `src/widgets/Navigation/ui/MobileNav.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { NavItem } from '../model/types';
|
||||||
|
import { MobileNav } from './MobileNav';
|
||||||
|
|
||||||
|
const ITEMS: NavItem[] = [{ id: 'about', label: 'About', number: '01' }];
|
||||||
|
|
||||||
|
describe('MobileNav', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders title "allmy.work"', () => {
|
||||||
|
render(<MobileNav items={ITEMS} />);
|
||||||
|
expect(screen.getByText('allmy.work')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders toggle button with text "Menu" initially', () => {
|
||||||
|
render(<MobileNav items={ITEMS} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('menu items are hidden initially', () => {
|
||||||
|
render(<MobileNav items={ITEMS} />);
|
||||||
|
expect(screen.queryByRole('link', { name: /About/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interactions', () => {
|
||||||
|
it('click toggle shows item links and changes label to "Close"', async () => {
|
||||||
|
render(<MobileNav items={ITEMS} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
||||||
|
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /About/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('item links point to correct href', async () => {
|
||||||
|
render(<MobileNav items={ITEMS} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
||||||
|
expect(screen.getByRole('link', { name: /About/i })).toHaveAttribute('href', '/about');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('click item link closes the menu', async () => {
|
||||||
|
render(<MobileNav items={ITEMS} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
||||||
|
await userEvent.click(screen.getByRole('link', { name: /About/i }));
|
||||||
|
expect(screen.queryByText('Close')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests — verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test src/widgets/Navigation/ui/MobileNav.test.tsx --run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — component still uses `button` for items, not `link`.
|
||||||
|
|
||||||
|
**Step 3: Update the component**
|
||||||
|
|
||||||
|
Replace `src/widgets/Navigation/ui/MobileNav.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
import type { NavItem } from '../model/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Navigation items to render
|
||||||
|
*/
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile navigation overlay, hidden on lg+ screens.
|
||||||
|
* Section items are links that close the menu on navigate.
|
||||||
|
*/
|
||||||
|
export function MobileNav({ items }: Props) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:hidden fixed top-0 left-0 right-0 bg-ochre-clay brutal-border-bottom z-50">
|
||||||
|
<div className="px-6 py-4 flex items-center justify-between">
|
||||||
|
<h4>allmy.work</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
|
||||||
|
>
|
||||||
|
{isOpen ? 'Close' : 'Menu'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={`/${item.id}`}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="block w-full text-left brutal-border bg-ochre-clay px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className={cn('flex items-baseline gap-3')}>
|
||||||
|
<span className="text-sm opacity-60 font-body">{item.number}</span>
|
||||||
|
<span
|
||||||
|
className="font-heading text-lg font-black"
|
||||||
|
style={{ fontVariationSettings: '"WONK" 1, "SOFT" 0' }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run tests — verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test src/widgets/Navigation/ui/MobileNav.test.tsx --run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/widgets/Navigation/ui/MobileNav.tsx src/widgets/Navigation/ui/MobileNav.test.tsx
|
||||||
|
git commit -m "refactor: MobileNav section items use Link instead of scrollToSection"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Create [[...slug]]/page.tsx and delete app/page.tsx
|
||||||
|
|
||||||
|
Wire everything together with `generateStaticParams` for SSG.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/[[...slug]]/page.tsx`
|
||||||
|
- Delete: `app/page.tsx`
|
||||||
|
|
||||||
|
**Step 1: Create the route file**
|
||||||
|
|
||||||
|
Create `app/[[...slug]]/page.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates static params for all section pages plus the root.
|
||||||
|
*/
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
||||||
|
sort: 'order',
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
{},
|
||||||
|
...sections.map((s) => ({ slug: [s.slug] })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portfolio page — handles all section routes via optional catchall.
|
||||||
|
*
|
||||||
|
* `/` → first section shown as active
|
||||||
|
* `/{slug}` → that section shown as active
|
||||||
|
*/
|
||||||
|
export default async function SectionPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug?: string[] }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
||||||
|
sort: 'order',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sections.length) notFound();
|
||||||
|
|
||||||
|
const activeSlug = slug?.[0] ?? sections[0].slug;
|
||||||
|
|
||||||
|
if (!sections.find((s) => s.slug === activeSlug)) notFound();
|
||||||
|
|
||||||
|
const navItems: NavItem[] = sections.map((s) => ({
|
||||||
|
id: s.slug,
|
||||||
|
label: s.title,
|
||||||
|
number: s.number,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen lg:flex">
|
||||||
|
<SidebarNav items={navItems} />
|
||||||
|
<main className="flex-1 lg:ml-[33.333%] px-8 py-12 lg:py-16 lg:px-16">
|
||||||
|
<MobileNav items={navItems} />
|
||||||
|
<SectionsAccordion sections={sections} activeSlug={activeSlug}>
|
||||||
|
<SectionFactory slug={activeSlug} />
|
||||||
|
</SectionsAccordion>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Delete app/page.tsx**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm app/page.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: TypeScript check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/
|
||||||
|
git commit -m "feat: URL-driven section routing via optional catchall with generateStaticParams"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Final Verification
|
||||||
|
|
||||||
|
**Step 1: Full test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test --run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests PASS.
|
||||||
|
|
||||||
|
**Step 2: TypeScript check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
**Step 3: Lint check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors. If auto-fixable issues: `yarn check:fix` then re-run.
|
||||||
|
|
||||||
|
**Step 4: Dev server smoke test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:3000` — should show first section (intro) as active, others as links. Clicking a section link should change the URL and show that section's content.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
pre-commit:
|
||||||
|
parallel: true
|
||||||
|
commands:
|
||||||
|
biome-check:
|
||||||
|
glob: "*.{js,ts,jsx,tsx,json,css}"
|
||||||
|
run: yarn biome check --write {staged_files}
|
||||||
|
stage_fixed: true
|
||||||
|
tests:
|
||||||
|
run: yarn test
|
||||||
+23
-5
@@ -1,8 +1,26 @@
|
|||||||
import type { NextConfig } from 'next'
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
/* PocketBase origin — used to allowlist remote images.
|
||||||
|
* PB_HOSTNAME and PB_PORT are server-only env vars; safe to read here. */
|
||||||
|
const pbHostname = process.env.PB_HOSTNAME ?? '127.0.0.1';
|
||||||
|
const pbPort = process.env.PB_PORT ?? '8090';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'export',
|
output: 'standalone',
|
||||||
images: { unoptimized: true },
|
poweredByHeader: false,
|
||||||
}
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: pbHostname,
|
||||||
|
port: pbPort,
|
||||||
|
pathname: '/api/files/**',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
viewTransition: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig;
|
||||||
|
|||||||
+10
-1
@@ -5,8 +5,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"export": "STATIC_EXPORT=true next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "biome lint --write .",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"check": "biome check --write .",
|
||||||
|
"lint:ci": "biome lint .",
|
||||||
|
"format:ci": "biome format .",
|
||||||
|
"check:ci": "biome check .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
@@ -14,12 +20,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"html-react-parser": "^6.1.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.4.13",
|
||||||
"@chromatic-com/storybook": "^5.1.2",
|
"@chromatic-com/storybook": "^5.1.2",
|
||||||
"@storybook/addon-a11y": "^10.3.5",
|
"@storybook/addon-a11y": "^10.3.5",
|
||||||
"@storybook/addon-docs": "^10.3.5",
|
"@storybook/addon-docs": "^10.3.5",
|
||||||
@@ -40,6 +48,7 @@
|
|||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"eslint-plugin-storybook": "^10.3.5",
|
"eslint-plugin-storybook": "^10.3.5",
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
|
"lefthook": "^2.1.6",
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
"storybook": "^10.3.5",
|
"storybook": "^10.3.5",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './model/types';
|
||||||
|
export * from './ui';
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
|
/**
|
||||||
|
* Sorting weight for section order
|
||||||
|
*/
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
+11
-15
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { SectionAccordion } from './SectionAccordion'
|
import { SectionAccordion } from './SectionAccordion';
|
||||||
|
|
||||||
const meta: Meta<typeof SectionAccordion> = {
|
const meta: Meta<typeof SectionAccordion> = {
|
||||||
title: 'Shared/SectionAccordion',
|
title: 'Shared/SectionAccordion',
|
||||||
@@ -11,11 +11,11 @@ const meta: Meta<typeof SectionAccordion> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof SectionAccordion>
|
type Story = StoryObj<typeof SectionAccordion>;
|
||||||
|
|
||||||
export const Active: Story = {
|
export const Active: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -23,12 +23,10 @@ export const Active: Story = {
|
|||||||
title: 'Biography',
|
title: 'Biography',
|
||||||
id: 'bio',
|
id: 'bio',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
onClick: () => {},
|
href: '/bio',
|
||||||
children: (
|
children: <p>This is the expanded section content. It is visible because isActive is true.</p>,
|
||||||
<p>This is the expanded section content. It is visible because isActive is true.</p>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Collapsed: Story = {
|
export const Collapsed: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -36,9 +34,7 @@ export const Collapsed: Story = {
|
|||||||
title: 'Work',
|
title: 'Work',
|
||||||
id: 'work',
|
id: 'work',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
onClick: () => console.log('section clicked'),
|
href: '/work',
|
||||||
children: (
|
children: <p>This content is hidden in collapsed state.</p>,
|
||||||
<p>This content is hidden in collapsed state.</p>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { SectionAccordion } from './SectionAccordion';
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
number: '01',
|
||||||
|
title: 'About',
|
||||||
|
id: 'about',
|
||||||
|
isActive: false,
|
||||||
|
href: '/about',
|
||||||
|
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 link with number and title', () => {
|
||||||
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('link points to the correct href', () => {
|
||||||
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render children', () => {
|
||||||
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
|
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a button', () => {
|
||||||
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 link', () => {
|
||||||
|
render(<SectionAccordion {...activeProps} />);
|
||||||
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('content wrapper has section-content class', () => {
|
||||||
|
const { container } = render(<SectionAccordion {...activeProps} />);
|
||||||
|
expect(container.querySelector('.section-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { ViewTransitionWrapper } from '$shared/ui';
|
||||||
|
|
||||||
|
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;
|
||||||
|
/**
|
||||||
|
* Navigation URL for the collapsed heading link
|
||||||
|
*/
|
||||||
|
href: string;
|
||||||
|
/**
|
||||||
|
* Section content, shown when active
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accordion-style section that collapses to a navigation link when inactive.
|
||||||
|
*/
|
||||||
|
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
|
||||||
|
const heading = `${number}. ${title}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={id} className="scroll-mt-8">
|
||||||
|
{isActive ? (
|
||||||
|
<div className="mb-12">
|
||||||
|
<ViewTransitionWrapper name="section-content">
|
||||||
|
<div className="mb-16">
|
||||||
|
<h1 className="font-heading font-black text-section-title leading-[1.2] mb-0">{heading}</h1>
|
||||||
|
</div>
|
||||||
|
</ViewTransitionWrapper>
|
||||||
|
<ViewTransitionWrapper name="section-body">
|
||||||
|
<div className="section-content">{children}</div>
|
||||||
|
</ViewTransitionWrapper>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
aria-label={heading}
|
||||||
|
className="block w-full text-left mb-3 py-3 group border-b-0 hover:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="block font-heading font-wonk font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-60 transition-opacity duration-200">
|
||||||
|
{heading}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './SectionAccordion';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './SectionAccordion';
|
||||||
@@ -1 +1 @@
|
|||||||
export { ExperienceCard } from './ui/ExperienceCard'
|
export { ExperienceCard } from './ui';
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the company name', () => {
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the description', () => {
|
|
||||||
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')
|
|
||||||
})
|
|
||||||
|
|
||||||
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')
|
|
||||||
})
|
|
||||||
|
|
||||||
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')
|
|
||||||
})
|
|
||||||
|
|
||||||
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]')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('card has brutal-border class (from Card component)', () => {
|
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Card } from '$shared/ui'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
/**
|
|
||||||
* Job title
|
|
||||||
*/
|
|
||||||
title: string
|
|
||||||
/**
|
|
||||||
* Company name
|
|
||||||
*/
|
|
||||||
company: string
|
|
||||||
/**
|
|
||||||
* Employment period (e.g. "2021 – 2024")
|
|
||||||
*/
|
|
||||||
period: string
|
|
||||||
/**
|
|
||||||
* Description of responsibilities and achievements
|
|
||||||
*/
|
|
||||||
description: string
|
|
||||||
/**
|
|
||||||
* Additional CSS classes forwarded to the card
|
|
||||||
*/
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Work experience card with title, company, period, and description.
|
|
||||||
*/
|
|
||||||
export function ExperienceCard({ title, company, period, description, className }: Props) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between mb-4 gap-4">
|
|
||||||
<div className="flex-1 max-w-[700px]">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<p className="text-base max-w-[700px]">{description}</p>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+11
-13
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { ExperienceCard } from './ExperienceCard'
|
import { ExperienceCard } from './ExperienceCard';
|
||||||
|
|
||||||
const meta: Meta<typeof ExperienceCard> = {
|
const meta: Meta<typeof ExperienceCard> = {
|
||||||
title: 'Entities/ExperienceCard',
|
title: 'Entities/ExperienceCard',
|
||||||
@@ -11,30 +11,28 @@ const meta: Meta<typeof ExperienceCard> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof ExperienceCard>
|
type Story = StoryObj<typeof ExperienceCard>;
|
||||||
|
|
||||||
const baseArgs = {
|
const baseArgs = {
|
||||||
title: 'Senior Frontend Engineer',
|
title: 'Senior Frontend Engineer',
|
||||||
company: 'Acme Corp',
|
company: 'Acme Corp',
|
||||||
period: '2021 – 2024',
|
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 = {
|
export const Default: Story = {
|
||||||
args: baseArgs,
|
args: baseArgs,
|
||||||
}
|
};
|
||||||
|
|
||||||
export const SlateBackground: Story = {
|
export const SlateBackground: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="bg-slate-indigo p-8 max-w-2xl">
|
<div className="bg-slate-indigo p-8 max-w-2xl">
|
||||||
<ExperienceCard
|
<ExperienceCard {...baseArgs} className="border-ochre-clay" />
|
||||||
{...baseArgs}
|
|
||||||
className="border-ochre-clay"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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.',
|
||||||
|
stack: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ExperienceCard', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders the job title', () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the period badge', () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('layout', () => {
|
||||||
|
it('period badge is inside the sidebar column', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const badge = screen.getByText('2021 – 2024');
|
||||||
|
expect(badge.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('company name is inside the sidebar column', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const company = screen.getByText('Acme Corp');
|
||||||
|
expect(company.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title is outside the sidebar column', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const title = screen.getByText('Senior Developer');
|
||||||
|
expect(title.closest('.brutal-border-sidebar')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('description is outside the sidebar column', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const desc = screen.getByText('Built scalable frontend systems.');
|
||||||
|
expect(desc.closest('.brutal-border-sidebar')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('structure', () => {
|
||||||
|
it('title is rendered as an h3', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Senior Developer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('period has left border accent styling', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const period = screen.getByText('2021 – 2024');
|
||||||
|
expect(period.tagName).toBe('P');
|
||||||
|
expect(period).toHaveClass('brutal-border-left', 'text-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('description renders via RichText with rich-text class', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const desc = screen.getByText('Built scalable frontend systems.');
|
||||||
|
expect(desc.closest('.rich-text')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('card has brutal-border class', () => {
|
||||||
|
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(container.firstChild).toHaveClass('brutal-border');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stack tags', () => {
|
||||||
|
it('renders stack tags in the sidebar as xs outline badges', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} stack={['React', 'TypeScript']} />);
|
||||||
|
const react = screen.getByText('React');
|
||||||
|
const ts = screen.getByText('TypeScript');
|
||||||
|
expect(react.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
expect(ts.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
expect(react).toHaveClass('brutal-border', 'bg-transparent', 'px-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing extra when stack is empty', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} stack={[]} />);
|
||||||
|
expect(screen.queryByRole('list')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('className passthrough', () => {
|
||||||
|
it('forwards className to the card', () => {
|
||||||
|
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />);
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* Job title
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Company name
|
||||||
|
*/
|
||||||
|
company: string;
|
||||||
|
/**
|
||||||
|
* Employment period (e.g. "Jan 2021 – Dec 2024")
|
||||||
|
*/
|
||||||
|
period: string;
|
||||||
|
/**
|
||||||
|
* Description of responsibilities and achievements
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* Technologies used during this role
|
||||||
|
*/
|
||||||
|
stack: string[];
|
||||||
|
/**
|
||||||
|
* Additional CSS classes forwarded to the card
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work experience card with sidebar layout.
|
||||||
|
* Sidebar: period, company, stack tags.
|
||||||
|
* Main: job title and rich-text description.
|
||||||
|
*/
|
||||||
|
export function ExperienceCard({ title, company, period, description, stack, className }: Props) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardSidebar
|
||||||
|
sidebar={
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm font-medium brutal-border-left pl-3">{period}</p>
|
||||||
|
<p className="text-base font-medium">{company}</p>
|
||||||
|
{stack.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stack.map((tech) => (
|
||||||
|
<Badge key={tech} variant="outline" size="xs">
|
||||||
|
{tech}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CardTitle className="font-heading">{title}</CardTitle>
|
||||||
|
<RichText html={description} />
|
||||||
|
</div>
|
||||||
|
</CardSidebar>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ExperienceCard } from './ExperienceCard/ExperienceCard';
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './project'
|
export * from './experience';
|
||||||
export * from './experience'
|
export * from './project';
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
export { ProjectMetadata } from './ui/ProjectMetadata'
|
export { DetailedProjectCard, ProjectCard, ProjectMetadata } from './ui';
|
||||||
export { ProjectCard } from './ui/ProjectCard'
|
|
||||||
export { DetailedProjectCard } from './ui/DetailedProjectCard'
|
|
||||||
|
|||||||
+10
-9
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { DetailedProjectCard } from './DetailedProjectCard'
|
import { DetailedProjectCard } from './DetailedProjectCard';
|
||||||
|
|
||||||
const meta: Meta<typeof DetailedProjectCard> = {
|
const meta: Meta<typeof DetailedProjectCard> = {
|
||||||
title: 'Entities/DetailedProjectCard',
|
title: 'Entities/DetailedProjectCard',
|
||||||
@@ -11,32 +11,33 @@ const meta: Meta<typeof DetailedProjectCard> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof DetailedProjectCard>
|
type Story = StoryObj<typeof DetailedProjectCard>;
|
||||||
|
|
||||||
const baseArgs = {
|
const baseArgs = {
|
||||||
title: 'Design System',
|
title: 'Design System',
|
||||||
year: '2024',
|
year: '2024',
|
||||||
role: 'Lead Frontend Engineer',
|
role: 'Lead Frontend Engineer',
|
||||||
stack: ['React', 'TypeScript', 'Tailwind CSS', 'Storybook'],
|
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: [
|
details: [
|
||||||
'Established token system covering color, spacing, and typography.',
|
'Established token system covering color, spacing, and typography.',
|
||||||
'Built 40+ accessible components with full test coverage.',
|
'Built 40+ accessible components with full test coverage.',
|
||||||
'Integrated Storybook for visual regression testing and documentation.',
|
'Integrated Storybook for visual regression testing and documentation.',
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: baseArgs,
|
args: baseArgs,
|
||||||
}
|
};
|
||||||
|
|
||||||
export const WithImage: Story = {
|
export const WithImage: Story = {
|
||||||
args: {
|
args: {
|
||||||
...baseArgs,
|
...baseArgs,
|
||||||
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
|
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
+53
-55
@@ -1,6 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { DetailedProjectCard } from './DetailedProjectCard';
|
||||||
import { DetailedProjectCard } from './DetailedProjectCard'
|
|
||||||
|
|
||||||
const DEFAULT_PROPS = {
|
const DEFAULT_PROPS = {
|
||||||
title: 'Big Project',
|
title: 'Big Project',
|
||||||
@@ -9,83 +8,82 @@ const DEFAULT_PROPS = {
|
|||||||
stack: ['Vue', 'Go'],
|
stack: ['Vue', 'Go'],
|
||||||
description: 'A detailed project description',
|
description: 'A detailed project description',
|
||||||
details: ['First detail point', 'Second detail point'],
|
details: ['First detail point', 'Second detail point'],
|
||||||
}
|
};
|
||||||
|
|
||||||
describe('DetailedProjectCard', () => {
|
describe('DetailedProjectCard', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders the project title', () => {
|
it('renders the project title', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('Big Project')).toBeInTheDocument()
|
expect(screen.getByText('Big Project')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders the description', () => {
|
it('renders the description', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('A detailed project description')).toBeInTheDocument()
|
expect(screen.getByText('A detailed project description')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders each detail item', () => {
|
it('renders each detail item', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('First detail point')).toBeInTheDocument()
|
expect(screen.getByText('First detail point')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Second detail point')).toBeInTheDocument()
|
expect(screen.getByText('Second detail point')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders ProjectMetadata with year, role, and stack', () => {
|
it('renders ProjectMetadata with year, role, and stack', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('2023')).toBeInTheDocument()
|
expect(screen.getByText('2023')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Lead Dev')).toBeInTheDocument()
|
expect(screen.getByText('Lead Dev')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Vue')).toBeInTheDocument()
|
expect(screen.getByText('Vue')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Go')).toBeInTheDocument()
|
expect(screen.getByText('Go')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('structure', () => {
|
describe('structure', () => {
|
||||||
it('outer grid has grid-cols-1 and lg:grid-cols-12', () => {
|
it('outer grid has grid-cols-1 and lg:grid-cols-12', () => {
|
||||||
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12')
|
expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('title is rendered as an h3', () => {
|
it('title is rendered as an h3', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project')
|
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('detail items are rendered as <p> tags with text-base', () => {
|
it('detail items are rendered as <p> tags with text-base', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
const detail = screen.getByText('First detail point')
|
const detail = screen.getByText('First detail point');
|
||||||
expect(detail.tagName).toBe('P')
|
expect(detail.tagName).toBe('P');
|
||||||
expect(detail).toHaveClass('text-base')
|
expect(detail).toHaveClass('text-base');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('details list has brutal-border-top and pt-6', () => {
|
it('details list has brutal-border-top and pt-6', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
const detail = screen.getByText('First detail point')
|
const detail = screen.getByText('First detail point');
|
||||||
const detailList = detail.parentElement
|
const detailList = detail.parentElement;
|
||||||
expect(detailList).toHaveClass('brutal-border-top', 'pt-6')
|
expect(detailList).toHaveClass('brutal-border-top', 'pt-6');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('description has text-lg and mb-6', () => {
|
it('description has text-lg and mb-6', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
const desc = screen.getByText('A detailed project description')
|
const desc = screen.getByText('A detailed project description');
|
||||||
expect(desc).toHaveClass('text-lg', 'mb-6')
|
expect(desc).toHaveClass('text-lg', 'mb-6');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('conditional image rendering', () => {
|
describe('conditional image rendering', () => {
|
||||||
it('does not render image when imageUrl is absent', () => {
|
it('does not render image when imageUrl is absent', () => {
|
||||||
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />)
|
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(container.querySelector('img')).toBeNull()
|
expect(container.querySelector('img')).toBeNull();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders image when imageUrl is provided', () => {
|
it('renders image when imageUrl is provided', () => {
|
||||||
render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />)
|
render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />);
|
||||||
const img = screen.getByRole('img')
|
expect(screen.getByRole('img')).toBeInTheDocument();
|
||||||
expect(img).toHaveAttribute('src', '/detail.jpg')
|
});
|
||||||
})
|
|
||||||
|
|
||||||
it('image wrapper has aspect-video and brutal-border when imageUrl is provided', () => {
|
it('image wrapper has aspect-video and brutal-border when imageUrl is provided', () => {
|
||||||
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />)
|
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />);
|
||||||
const imgWrapper = container.querySelector('img')!.parentElement
|
const imgWrapper = container.querySelector('img')?.parentElement;
|
||||||
expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border')
|
expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
+23
-28
@@ -1,54 +1,47 @@
|
|||||||
import { Card } from '$shared/ui'
|
import Image from 'next/image';
|
||||||
import { ProjectMetadata } from './ProjectMetadata'
|
import { Card, RichText } from '$shared/ui';
|
||||||
|
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
* Project name
|
* Project name
|
||||||
*/
|
*/
|
||||||
title: string
|
title: string;
|
||||||
/**
|
/**
|
||||||
* Year the project was completed
|
* Year the project was completed
|
||||||
*/
|
*/
|
||||||
year: string
|
year: string;
|
||||||
/**
|
/**
|
||||||
* Developer role on the project
|
* Developer role on the project
|
||||||
*/
|
*/
|
||||||
role: string
|
role: string;
|
||||||
/**
|
/**
|
||||||
* Technology stack list
|
* Technology stack list
|
||||||
*/
|
*/
|
||||||
stack: string[]
|
stack: string[];
|
||||||
/**
|
/**
|
||||||
* Project description paragraph
|
* Project description as HTML from the PocketBase rich-text editor
|
||||||
*/
|
*/
|
||||||
description: string
|
description: string;
|
||||||
/**
|
/**
|
||||||
* Bullet-style detail points listed below the description
|
* Bullet-style detail points listed below the description
|
||||||
*/
|
*/
|
||||||
details: string[]
|
details: string[];
|
||||||
/**
|
/**
|
||||||
* Optional hero image URL
|
* Optional hero image URL
|
||||||
*/
|
*/
|
||||||
imageUrl?: string
|
imageUrl?: string;
|
||||||
/**
|
/**
|
||||||
* Reverse layout (reserved for future use)
|
* Reverse layout (reserved for future use)
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
reverse?: boolean
|
reverse?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-width detailed project card with metadata sidebar.
|
* Full-width detailed project card with metadata sidebar.
|
||||||
*/
|
*/
|
||||||
export function DetailedProjectCard({
|
export function DetailedProjectCard({ title, year, role, stack, description, details, imageUrl }: Props) {
|
||||||
title,
|
|
||||||
year,
|
|
||||||
role,
|
|
||||||
stack,
|
|
||||||
description,
|
|
||||||
details,
|
|
||||||
imageUrl,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-16">
|
<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">
|
<div className="lg:col-span-2 order-2 lg:order-1">
|
||||||
@@ -56,23 +49,25 @@ export function DetailedProjectCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-10 order-1 lg:order-2">
|
<div className="lg:col-span-10 order-1 lg:order-2">
|
||||||
<Card background="white">
|
<Card>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<p className="text-lg mb-6">{description}</p>
|
<RichText html={description} className="text-lg mb-6" />
|
||||||
|
|
||||||
{imageUrl && (
|
{imageUrl && (
|
||||||
<div className="brutal-border aspect-video bg-slate-indigo overflow-hidden">
|
<div className="brutal-border aspect-video bg-blue overflow-hidden relative">
|
||||||
<img src={imageUrl} alt={title} className="w-full h-full object-cover" />
|
<Image src={imageUrl} alt={title} fill className="object-cover" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-w-[700px] space-y-4 brutal-border-top pt-6">
|
<div className="max-w-[700px] space-y-4 brutal-border-top pt-6">
|
||||||
{details.map((detail, index) => (
|
{details.map((detail) => (
|
||||||
<p key={index} className="text-base">{detail}</p>
|
<p key={detail} className="text-base">
|
||||||
|
{detail}
|
||||||
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the year badge', () => {
|
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
|
||||||
expect(screen.getByText('2024')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the description', () => {
|
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
|
||||||
expect(screen.getByText('A cool project description')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders each tag', () => {
|
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
|
||||||
expect(screen.getByText('React')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Node')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the View Project button', () => {
|
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
|
||||||
expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('structure', () => {
|
|
||||||
it('card has hover transition classes', () => {
|
|
||||||
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />)
|
|
||||||
const card = container.firstChild as HTMLElement
|
|
||||||
expect(card).toHaveClass('group', 'transition-all', 'duration-300')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('year badge has correct classes', () => {
|
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />)
|
|
||||||
const yearBadge = screen.getByText('2024')
|
|
||||||
expect(yearBadge).toHaveClass('brutal-border', 'bg-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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('conditional image rendering', () => {
|
|
||||||
it('does not render image when imageUrl is absent', () => {
|
|
||||||
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />)
|
|
||||||
expect(container.querySelector('img')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders image when imageUrl is provided', () => {
|
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />)
|
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui'
|
|
||||||
import { cn } from '$shared/lib'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
/**
|
|
||||||
* Project name
|
|
||||||
*/
|
|
||||||
title: string
|
|
||||||
/**
|
|
||||||
* Year the project was completed
|
|
||||||
*/
|
|
||||||
year: string
|
|
||||||
/**
|
|
||||||
* Short project description
|
|
||||||
*/
|
|
||||||
description: string
|
|
||||||
/**
|
|
||||||
* Technology or category tags
|
|
||||||
*/
|
|
||||||
tags: string[]
|
|
||||||
/**
|
|
||||||
* Optional preview image URL
|
|
||||||
*/
|
|
||||||
imageUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact project card for grid/list display.
|
|
||||||
*/
|
|
||||||
export function ProjectCard({ title, year, description, tags, imageUrl }: Props) {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
'group hover:translate-x-[2px] hover:translate-y-[2px]',
|
|
||||||
'hover:shadow-[10px_10px_0_var(--carbon-black)] transition-all duration-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-row justify-between items-start mb-3">
|
|
||||||
<CardTitle className="flex-1">{title}</CardTitle>
|
|
||||||
<span className="brutal-border px-3 py-1 bg-carbon-black text-ochre-clay text-sm">{year}</span>
|
|
||||||
</div>
|
|
||||||
<CardDescription>{description}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{imageUrl && (
|
|
||||||
<div className="brutal-border my-6 aspect-video bg-slate-indigo overflow-hidden">
|
|
||||||
<img src={imageUrl} alt={title} className="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="brutal-border px-3 py-1 bg-white text-carbon-black text-sm uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter>
|
|
||||||
<Button variant="primary" className="w-full">View Project</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+7
-7
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { ProjectCard } from './ProjectCard'
|
import { ProjectCard } from './ProjectCard';
|
||||||
|
|
||||||
const meta: Meta<typeof ProjectCard> = {
|
const meta: Meta<typeof ProjectCard> = {
|
||||||
title: 'Entities/ProjectCard',
|
title: 'Entities/ProjectCard',
|
||||||
@@ -11,11 +11,11 @@ const meta: Meta<typeof ProjectCard> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof ProjectCard>
|
type Story = StoryObj<typeof ProjectCard>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -24,7 +24,7 @@ export const Default: Story = {
|
|||||||
description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.',
|
description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.',
|
||||||
tags: ['React', 'TypeScript', 'Next.js'],
|
tags: ['React', 'TypeScript', 'Next.js'],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const WithImage: Story = {
|
export const WithImage: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -34,4 +34,4 @@ export const WithImage: Story = {
|
|||||||
tags: ['React', 'TypeScript', 'Next.js'],
|
tags: ['React', 'TypeScript', 'Next.js'],
|
||||||
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
|
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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'],
|
||||||
|
url: 'https://example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ProjectCard', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders the project title', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('My Project')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the year', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('2024')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the description', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('A cool project description')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders each tag', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('React')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Node')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the View Project button', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByRole('link', { name: /view project/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('View Project link points to the project url', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByRole('link', { name: /view project/i })).toHaveAttribute('href', 'https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('View Project link opens in a new tab', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByRole('link', { name: /view project/i })).toHaveAttribute('target', '_blank');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('layout', () => {
|
||||||
|
it('year is inside the sidebar column', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('2024').closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags are inside the sidebar column', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('React').closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Node').closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('View Project button is inside the sidebar column', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
const btn = screen.getByRole('link', { name: /view project/i });
|
||||||
|
expect(btn.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title is outside the sidebar column', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('My Project').closest('.brutal-border-sidebar')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('description is outside the sidebar column', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('A cool project description').closest('.brutal-border-sidebar')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('structure', () => {
|
||||||
|
it('card has hover transition classes', () => {
|
||||||
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(container.firstChild).toHaveClass('group', 'transition-shadow', 'duration-300');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title renders as h3', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('My Project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('year has period-style left border', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
const year = screen.getByText('2024');
|
||||||
|
expect(year.tagName).toBe('P');
|
||||||
|
expect(year).toHaveClass('brutal-border-left', 'text-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('View Project button uses sm size', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
const btn = screen.getByRole('link', { name: /view project/i });
|
||||||
|
expect(btn).toHaveClass('px-4', 'py-2', 'text-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags are xs outline badges', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
const tag = screen.getByText('React');
|
||||||
|
expect(tag).toHaveClass('brutal-border', 'bg-transparent', 'px-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('conditional image rendering', () => {
|
||||||
|
it('does not render image when imageUrl is absent', () => {
|
||||||
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(container.querySelector('img')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders image when imageUrl is provided', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
|
||||||
|
expect(screen.getByRole('img')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('image wrapper has aspect-video and overflow-hidden', () => {
|
||||||
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
|
||||||
|
const imgWrapper = container.querySelector('img')?.parentElement;
|
||||||
|
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Image from 'next/image';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
import { Badge, Button, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* Project name
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Year the project was completed
|
||||||
|
*/
|
||||||
|
year: string;
|
||||||
|
/**
|
||||||
|
* Short project description
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* Technology or category tags
|
||||||
|
*/
|
||||||
|
tags: string[];
|
||||||
|
/**
|
||||||
|
* Project's URL
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* Optional preview image URL
|
||||||
|
*/
|
||||||
|
imageUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project card with sidebar layout.
|
||||||
|
* Sidebar: year badge, stack tags, View Project button.
|
||||||
|
* Main: title, optional image, description.
|
||||||
|
*/
|
||||||
|
export function ProjectCard({ title, year, description, tags, url, imageUrl }: Props) {
|
||||||
|
return (
|
||||||
|
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
|
||||||
|
<CardSidebar
|
||||||
|
sidebar={
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm font-medium brutal-border-left pl-3">{year}</p>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline" size="xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button href={url} variant="primary" size="sm" className="w-full">
|
||||||
|
View Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CardTitle className="font-heading">{title}</CardTitle>
|
||||||
|
{imageUrl && (
|
||||||
|
<div className="brutal-border aspect-video bg-blue overflow-hidden relative">
|
||||||
|
<Image src={imageUrl} alt={title} fill className="object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<RichText html={description} />
|
||||||
|
</div>
|
||||||
|
</CardSidebar>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the YEAR label', () => {
|
|
||||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
|
||||||
expect(screen.getByText('YEAR')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the role value', () => {
|
|
||||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
|
||||||
expect(screen.getByText('Frontend Engineer')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the ROLE label', () => {
|
|
||||||
render(<ProjectMetadata {...DEFAULT_PROPS} />)
|
|
||||||
expect(screen.getByText('ROLE')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the STACK label', () => {
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('structure', () => {
|
|
||||||
it('outer div has 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')
|
|
||||||
})
|
|
||||||
|
|
||||||
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')
|
|
||||||
})
|
|
||||||
|
|
||||||
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')
|
|
||||||
})
|
|
||||||
|
|
||||||
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')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('year value has 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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
+6
-6
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { ProjectMetadata } from './ProjectMetadata'
|
import { ProjectMetadata } from './ProjectMetadata';
|
||||||
|
|
||||||
const meta: Meta<typeof ProjectMetadata> = {
|
const meta: Meta<typeof ProjectMetadata> = {
|
||||||
title: 'Entities/ProjectMetadata',
|
title: 'Entities/ProjectMetadata',
|
||||||
@@ -11,11 +11,11 @@ const meta: Meta<typeof ProjectMetadata> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof ProjectMetadata>
|
type Story = StoryObj<typeof ProjectMetadata>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -23,4 +23,4 @@ export const Default: Story = {
|
|||||||
role: 'Lead Frontend Engineer',
|
role: 'Lead Frontend Engineer',
|
||||||
stack: ['React', 'TypeScript', 'Next.js', 'Tailwind'],
|
stack: ['React', 'TypeScript', 'Next.js', 'Tailwind'],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the YEAR label', () => {
|
||||||
|
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('YEAR')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the role value', () => {
|
||||||
|
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('Frontend Engineer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the ROLE label', () => {
|
||||||
|
render(<ProjectMetadata {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('ROLE')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the STACK label', () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('structure', () => {
|
||||||
|
it('outer div has 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 as NodeListOf<Element>;
|
||||||
|
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 as NodeListOf<Element>;
|
||||||
|
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 as NodeListOf<Element>;
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('year value has 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-8
@@ -1,23 +1,23 @@
|
|||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
* Project year
|
* Project year
|
||||||
*/
|
*/
|
||||||
year: string
|
year: string;
|
||||||
/**
|
/**
|
||||||
* Developer role on the project
|
* Developer role on the project
|
||||||
*/
|
*/
|
||||||
role: string
|
role: string;
|
||||||
/**
|
/**
|
||||||
* Technology stack list
|
* Technology stack list
|
||||||
*/
|
*/
|
||||||
stack: string[]
|
stack: string[];
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar metadata display for a project: year, role, and stack.
|
* 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">
|
<div className="brutal-border-top pt-6">
|
||||||
<p className="text-xs uppercase tracking-wider opacity-60">STACK</p>
|
<p className="text-xs uppercase tracking-wider opacity-60">STACK</p>
|
||||||
{stack.map((tech) => (
|
{stack.map((tech) => (
|
||||||
<p key={tech} className="text-sm">{tech}</p>
|
<p key={tech} className="text-sm">
|
||||||
|
{tech}
|
||||||
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { DetailedProjectCard } from './DetailedProjectCard/DetailedProjectCard';
|
||||||
|
export { ProjectCard } from './ProjectCard/ProjectCard';
|
||||||
|
export { ProjectMetadata } from './ProjectMetadata/ProjectMetadata';
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ListResponse } from './types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Native fetch wrapper for PocketBase API requests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Prefer the server-only var (not exposed to the browser bundle),
|
||||||
|
* fall back to the public var for client-side usage, then to the
|
||||||
|
* local dev default. */
|
||||||
|
const PB_URL = process.env.PB_URL ?? process.env.NEXT_PUBLIC_PB_URL ?? 'http://127.0.0.1:8090';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for PocketBase collection fetching.
|
||||||
|
*/
|
||||||
|
export type PBFetchOptions = {
|
||||||
|
/**
|
||||||
|
* Sorting criteria (e.g., "-created,order")
|
||||||
|
*/
|
||||||
|
sort?: string;
|
||||||
|
/**
|
||||||
|
* Filter query string
|
||||||
|
*/
|
||||||
|
filter?: string;
|
||||||
|
/**
|
||||||
|
* Fields to expand (e.g., "stack")
|
||||||
|
*/
|
||||||
|
expand?: string;
|
||||||
|
/**
|
||||||
|
* Cache tags for on-demand revalidation via `revalidateTag`.
|
||||||
|
* Typically set to the collection name.
|
||||||
|
*/
|
||||||
|
tags?: string[];
|
||||||
|
/**
|
||||||
|
* ISR revalidation interval in seconds.
|
||||||
|
* @default 3600
|
||||||
|
*/
|
||||||
|
revalidate?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a list of records from a PocketBase collection.
|
||||||
|
*/
|
||||||
|
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
|
||||||
|
const { sort, filter, expand, tags, revalidate } = options;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (sort) {
|
||||||
|
params.set('sort', sort);
|
||||||
|
}
|
||||||
|
if (filter) {
|
||||||
|
params.set('filter', filter);
|
||||||
|
}
|
||||||
|
if (expand) {
|
||||||
|
params.set('expand', expand);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
next: {
|
||||||
|
tags: tags ?? [],
|
||||||
|
revalidate: revalidate ?? 3600,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`PocketBase ${res.status} ${res.statusText} on collection "${collection}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the first record matching an optional filter from a PocketBase collection.
|
||||||
|
*/
|
||||||
|
export async function getFirstRecord<T>(collection: string, options: PBFetchOptions = {}): Promise<T | null> {
|
||||||
|
const data = await getCollection<T>(collection, options);
|
||||||
|
return data.items[0] ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './client';
|
||||||
|
export * from './types';
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Common properties for all PocketBase records.
|
||||||
|
*/
|
||||||
|
export type BaseRecord = {
|
||||||
|
/**
|
||||||
|
* Unique record ID
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* ID of the collection this record belongs to
|
||||||
|
*/
|
||||||
|
collectionId: string;
|
||||||
|
/**
|
||||||
|
* Name of the collection this record belongs to
|
||||||
|
*/
|
||||||
|
collectionName: string;
|
||||||
|
/**
|
||||||
|
* Record creation timestamp (ISO 8601)
|
||||||
|
*/
|
||||||
|
created: string;
|
||||||
|
/**
|
||||||
|
* Record last update timestamp (ISO 8601)
|
||||||
|
*/
|
||||||
|
updated: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for simple text blocks (Intro, Bio).
|
||||||
|
* Each collection is named after its section — no slug field.
|
||||||
|
*/
|
||||||
|
export type PageContentRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* HTML or Markdown content string
|
||||||
|
*/
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for technology skills.
|
||||||
|
*/
|
||||||
|
export type SkillRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* Name of the technology or tool
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Grouping category (e.g., 'Frontend', 'Backend')
|
||||||
|
*/
|
||||||
|
category: 'Frontend' | 'Backend' | 'Tools' | 'Design' | string;
|
||||||
|
/**
|
||||||
|
* Sorting weight within the category
|
||||||
|
*/
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for work experience history.
|
||||||
|
*/
|
||||||
|
export type ExperienceRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* Name of the organization
|
||||||
|
*/
|
||||||
|
company: string;
|
||||||
|
/**
|
||||||
|
* Professional title held
|
||||||
|
*/
|
||||||
|
role: string;
|
||||||
|
/**
|
||||||
|
* Start date of the tenure
|
||||||
|
*/
|
||||||
|
start_date: string;
|
||||||
|
/**
|
||||||
|
* End date of the tenure, or null if currently employed
|
||||||
|
*/
|
||||||
|
end_date: string | null;
|
||||||
|
/**
|
||||||
|
* Rich text description of responsibilities and achievements
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* Technologies used during this role
|
||||||
|
*/
|
||||||
|
stack: string[];
|
||||||
|
/**
|
||||||
|
* Sorting weight for chronological display
|
||||||
|
*/
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for portfolio projects.
|
||||||
|
*/
|
||||||
|
export type ProjectRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* Full title of the project
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Completion or duration year (e.g., "2024")
|
||||||
|
*/
|
||||||
|
year: string;
|
||||||
|
/**
|
||||||
|
* Role performed on the project
|
||||||
|
*/
|
||||||
|
role: string;
|
||||||
|
/**
|
||||||
|
* Project description as HTML from the PocketBase rich-text editor
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* List of specific feature or achievement points
|
||||||
|
*/
|
||||||
|
details: string[];
|
||||||
|
/**
|
||||||
|
* List of SkillRecord IDs used in the project
|
||||||
|
*/
|
||||||
|
stack: string[];
|
||||||
|
/**
|
||||||
|
* Primary thumbnail or hero image filename
|
||||||
|
*/
|
||||||
|
image: string;
|
||||||
|
/**
|
||||||
|
* Project's url
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* Sorting weight for the project list
|
||||||
|
*/
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for individual social profile links.
|
||||||
|
*/
|
||||||
|
export type SocialRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* Display name shown as the link text
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* Full URL for the social profile
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for the primary contact record.
|
||||||
|
* Single-record collection — only the first record is consumed.
|
||||||
|
*/
|
||||||
|
export type ContactsRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* Primary contact email address
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
/**
|
||||||
|
* Raw relation IDs — use expand?.socials for resolved records
|
||||||
|
*/
|
||||||
|
socials: string[];
|
||||||
|
/**
|
||||||
|
* Expanded relation data, present when fetched with expand=socials
|
||||||
|
*/
|
||||||
|
expand?: {
|
||||||
|
/**
|
||||||
|
* Resolved social link records
|
||||||
|
*/
|
||||||
|
socials?: SocialRecord[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for global site configuration.
|
||||||
|
* Single-record collection — only the first record is consumed.
|
||||||
|
*/
|
||||||
|
export type SiteSettingsRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* CV filename stored in PocketBase — build the full URL with buildFileUrl()
|
||||||
|
*/
|
||||||
|
cv: string;
|
||||||
|
/**
|
||||||
|
* Raw relation ID — use expand?.contacts for the resolved record
|
||||||
|
*/
|
||||||
|
contacts: string;
|
||||||
|
/**
|
||||||
|
* Expanded relation data, present when fetched with expand=contacts,contacts.socials
|
||||||
|
*/
|
||||||
|
expand?: {
|
||||||
|
/**
|
||||||
|
* Resolved contacts record
|
||||||
|
*/
|
||||||
|
contacts?: ContactsRecord;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic response for a list of PocketBase records.
|
||||||
|
*/
|
||||||
|
export type ListResponse<T> = {
|
||||||
|
/**
|
||||||
|
* Current page index
|
||||||
|
*/
|
||||||
|
page: number;
|
||||||
|
/**
|
||||||
|
* Number of items per page
|
||||||
|
*/
|
||||||
|
perPage: number;
|
||||||
|
/**
|
||||||
|
* Total number of items across all pages
|
||||||
|
*/
|
||||||
|
totalItems: number;
|
||||||
|
/**
|
||||||
|
* Total number of pages available
|
||||||
|
*/
|
||||||
|
totalPages: number;
|
||||||
|
/**
|
||||||
|
* Array of records for the current page
|
||||||
|
*/
|
||||||
|
items: T[];
|
||||||
|
};
|
||||||
+3
-2
@@ -1,2 +1,3 @@
|
|||||||
export * from './ui'
|
export * from './api';
|
||||||
export * from './lib'
|
export * from './lib';
|
||||||
|
export * from './ui';
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Static contact and social links shown in navigation.
|
||||||
|
*/
|
||||||
|
export const CONTACT_LINKS = {
|
||||||
|
/**
|
||||||
|
* Primary contact email address
|
||||||
|
*/
|
||||||
|
email: 'hello@allmy.work',
|
||||||
|
/**
|
||||||
|
* LinkedIn profile URL
|
||||||
|
*/
|
||||||
|
linkedin: 'https://linkedin.com',
|
||||||
|
/**
|
||||||
|
* Instagram profile URL
|
||||||
|
*/
|
||||||
|
instagram: 'https://instagram.com',
|
||||||
|
/**
|
||||||
|
* Are.na profile URL
|
||||||
|
*/
|
||||||
|
arena: 'https://are.na',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Fraunces, Public_Sans } from 'next/font/google';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heading font — variable axes for brutalist variation settings
|
||||||
|
*/
|
||||||
|
export const fraunces = Fraunces({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-fraunces',
|
||||||
|
axes: ['opsz', 'SOFT', 'WONK'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body font
|
||||||
|
*/
|
||||||
|
export const publicSans = Public_Sans({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-public-sans',
|
||||||
|
});
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
export { cn } from './cn'
|
export type { ClassValue } from 'clsx';
|
||||||
export type { ClassValue } from 'clsx'
|
export { CONTACT_LINKS } from './config/config';
|
||||||
|
export * from './fonts/fonts';
|
||||||
|
export { buildFileUrl } from './utils/buildFileUrl/buildFileUrl';
|
||||||
|
export { cn } from './utils/cn/cn';
|
||||||
|
export * from './utils/formatDate/formatDate';
|
||||||
|
export { groupByKey } from './utils/groupByKey/groupByKey';
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { buildFileUrl } from './buildFileUrl';
|
||||||
|
|
||||||
|
describe('buildFileUrl', () => {
|
||||||
|
describe('default base URL', () => {
|
||||||
|
it('builds correct URL with default base', () => {
|
||||||
|
expect(buildFileUrl('site_settings', 'ss1', 'cv_2024.pdf')).toBe(
|
||||||
|
'http://127.0.0.1:8090/api/files/site_settings/ss1/cv_2024.pdf',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('custom base URL', () => {
|
||||||
|
it('uses provided baseUrl when given', () => {
|
||||||
|
expect(buildFileUrl('photos', 'rec1', 'avatar.png', 'https://pb.example.com')).toBe(
|
||||||
|
'https://pb.example.com/api/files/photos/rec1/avatar.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('different collections, records, filenames', () => {
|
||||||
|
it('handles projects collection', () => {
|
||||||
|
expect(buildFileUrl('projects', 'proj42', 'screenshot.jpg', 'http://127.0.0.1:8090')).toBe(
|
||||||
|
'http://127.0.0.1:8090/api/files/projects/proj42/screenshot.jpg',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles contacts collection', () => {
|
||||||
|
expect(buildFileUrl('contacts', 'cid99', 'photo.webp', 'http://127.0.0.1:8090')).toBe(
|
||||||
|
'http://127.0.0.1:8090/api/files/contacts/cid99/photo.webp',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Builds a URL for a file stored in a PocketBase record.
|
||||||
|
*/
|
||||||
|
export function buildFileUrl(
|
||||||
|
collectionId: string,
|
||||||
|
recordId: string,
|
||||||
|
filename: string,
|
||||||
|
baseUrl: string = process.env.NEXT_PUBLIC_PB_URL ?? 'http://127.0.0.1:8090',
|
||||||
|
): string {
|
||||||
|
return `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
|
||||||
|
}
|
||||||
@@ -1,40 +1,39 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { cn } from './cn';
|
||||||
import { cn } from './cn'
|
|
||||||
|
|
||||||
describe('cn', () => {
|
describe('cn', () => {
|
||||||
describe('basic merging', () => {
|
describe('basic merging', () => {
|
||||||
it('returns single class unchanged', () => {
|
it('returns single class unchanged', () => {
|
||||||
expect(cn('foo')).toBe('foo')
|
expect(cn('foo')).toBe('foo');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('joins multiple classes', () => {
|
it('joins multiple classes', () => {
|
||||||
expect(cn('foo', 'bar')).toBe('foo bar')
|
expect(cn('foo', 'bar')).toBe('foo bar');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('conditional classes', () => {
|
describe('conditional classes', () => {
|
||||||
it('includes truthy conditional', () => {
|
it('includes truthy conditional', () => {
|
||||||
expect(cn('foo', true && 'bar')).toBe('foo bar')
|
expect(cn('foo', true && 'bar')).toBe('foo bar');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('excludes falsy conditional', () => {
|
it('excludes falsy conditional', () => {
|
||||||
expect(cn('foo', false && 'bar')).toBe('foo')
|
expect(cn('foo', false && 'bar')).toBe('foo');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('object syntax', () => {
|
describe('object syntax', () => {
|
||||||
it('includes classes with truthy object values', () => {
|
it('includes classes with truthy object values', () => {
|
||||||
expect(cn({ foo: true, bar: false })).toBe('foo')
|
expect(cn({ foo: true, bar: false })).toBe('foo');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('tailwind conflict resolution', () => {
|
describe('tailwind conflict resolution', () => {
|
||||||
it('last padding wins', () => {
|
it('last padding wins', () => {
|
||||||
expect(cn('px-2', 'px-4')).toBe('px-4')
|
expect(cn('px-2', 'px-4')).toBe('px-4');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('last text color wins', () => {
|
it('last text color wins', () => {
|
||||||
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500')
|
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { clsx, type ClassValue } from 'clsx'
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges Tailwind classes, resolving conflicts in favor of the last value.
|
* Merges Tailwind classes, resolving conflicts in favor of the last value.
|
||||||
*/
|
*/
|
||||||
export function cn(...inputs: ClassValue[]): string {
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { formatMonthYearRange } from './formatDate';
|
||||||
|
|
||||||
|
describe('formatMonthYearRange', () => {
|
||||||
|
describe('open-ended range', () => {
|
||||||
|
it('formats start date with Present when end is null', () => {
|
||||||
|
expect(formatMonthYearRange('2022-01-01T00:00:00Z', null)).toBe('Jan 2022 — Present');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses abbreviated month name', () => {
|
||||||
|
expect(formatMonthYearRange('2020-08-15T00:00:00Z', null)).toBe('Aug 2020 — Present');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('closed range', () => {
|
||||||
|
it('formats start and end with month and year', () => {
|
||||||
|
expect(formatMonthYearRange('2021-05-01T00:00:00Z', '2024-03-31T00:00:00Z')).toBe('May 2021 — Mar 2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles same year with different months', () => {
|
||||||
|
expect(formatMonthYearRange('2024-01-01T00:00:00Z', '2024-12-31T00:00:00Z')).toBe('Jan 2024 — Dec 2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles same month and year', () => {
|
||||||
|
expect(formatMonthYearRange('2024-06-01T00:00:00Z', '2024-06-30T00:00:00Z')).toBe('Jun 2024');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error cases', () => {
|
||||||
|
it('throws if start date is invalid', () => {
|
||||||
|
expect(() => formatMonthYearRange('not-a-date', null)).toThrow('Invalid start date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if end date is provided but invalid', () => {
|
||||||
|
expect(() => formatMonthYearRange('2024-01-01T00:00:00Z', 'invalid')).toThrow('Invalid end date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if start is after end', () => {
|
||||||
|
expect(() => formatMonthYearRange('2024-01-01T00:00:00Z', '2020-01-01T00:00:00Z')).toThrow(
|
||||||
|
'Start date cannot be after end date',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on empty string', () => {
|
||||||
|
expect(() => formatMonthYearRange('', null)).toThrow('Invalid start date');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const MONTH_FMT = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
|
||||||
|
|
||||||
|
function formatMonthYear(date: Date): string {
|
||||||
|
return MONTH_FMT.format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a PocketBase date string into a localized month+year range or "Present".
|
||||||
|
* @throws {Error} if any date is invalid or if the range is logically impossible.
|
||||||
|
*/
|
||||||
|
export function formatMonthYearRange(start: string, end: string | null): string {
|
||||||
|
const startDate = new Date(start);
|
||||||
|
if (Number.isNaN(startDate.getTime())) {
|
||||||
|
throw new Error('Invalid start date');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === null) {
|
||||||
|
return `${formatMonthYear(startDate)} — Present`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = new Date(end);
|
||||||
|
if (Number.isNaN(endDate.getTime())) {
|
||||||
|
throw new Error('Invalid end date');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate > endDate) {
|
||||||
|
throw new Error('Start date cannot be after end date');
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLabel = formatMonthYear(startDate);
|
||||||
|
const endLabel = formatMonthYear(endDate);
|
||||||
|
|
||||||
|
if (startLabel === endLabel) {
|
||||||
|
return startLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${startLabel} — ${endLabel}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { groupByKey } from './groupByKey';
|
||||||
|
|
||||||
|
describe('groupByKey', () => {
|
||||||
|
describe('basic grouping', () => {
|
||||||
|
it('groups items by a string key', () => {
|
||||||
|
const items = [
|
||||||
|
{ category: 'Frontend', name: 'React' },
|
||||||
|
{ category: 'Backend', name: 'Node' },
|
||||||
|
{ category: 'Frontend', name: 'Vue' },
|
||||||
|
];
|
||||||
|
expect(groupByKey(items, 'category')).toEqual({
|
||||||
|
Frontend: [
|
||||||
|
{ category: 'Frontend', name: 'React' },
|
||||||
|
{ category: 'Frontend', name: 'Vue' },
|
||||||
|
],
|
||||||
|
Backend: [{ category: 'Backend', name: 'Node' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves insertion order within each group', () => {
|
||||||
|
const items = [
|
||||||
|
{ category: 'A', order: 1 },
|
||||||
|
{ category: 'A', order: 2 },
|
||||||
|
];
|
||||||
|
expect(groupByKey(items, 'category').A).toEqual([
|
||||||
|
{ category: 'A', order: 1 },
|
||||||
|
{ category: 'A', order: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('returns empty object for empty array', () => {
|
||||||
|
expect(groupByKey<{ category: string }>([], 'category')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles all items in same group', () => {
|
||||||
|
const items = [
|
||||||
|
{ type: 'X', id: 1 },
|
||||||
|
{ type: 'X', id: 2 },
|
||||||
|
];
|
||||||
|
const result = groupByKey(items, 'type');
|
||||||
|
expect(Object.keys(result)).toHaveLength(1);
|
||||||
|
expect(result.X).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single item', () => {
|
||||||
|
const items = [{ category: 'Only', name: 'One' }];
|
||||||
|
expect(groupByKey(items, 'category')).toEqual({
|
||||||
|
Only: [{ category: 'Only', name: 'One' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Groups an array of objects by a shared key into a record of arrays.
|
||||||
|
* @param items - Array of objects to group
|
||||||
|
* @param key - Key whose value determines the group
|
||||||
|
* @returns Record mapping each unique key value to an array of matching items
|
||||||
|
*/
|
||||||
|
export function groupByKey<T>(items: T[], key: keyof T): Record<string, T[]> {
|
||||||
|
return items.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
const k = String(item[key]);
|
||||||
|
if (!acc[k]) {
|
||||||
|
acc[k] = [];
|
||||||
|
}
|
||||||
|
acc[k].push(item);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, T[]>,
|
||||||
|
);
|
||||||
|
}
|
||||||
+234
-60
@@ -2,7 +2,7 @@
|
|||||||
/* === TYPOGRAPHY SCALE (Augmented Fourth 1.414) === */
|
/* === TYPOGRAPHY SCALE (Augmented Fourth 1.414) === */
|
||||||
--font-size: 16px;
|
--font-size: 16px;
|
||||||
--text-xs: 0.707rem;
|
--text-xs: 0.707rem;
|
||||||
--text-sm: 0.840rem;
|
--text-sm: 0.84rem;
|
||||||
--text-base: 1rem;
|
--text-base: 1rem;
|
||||||
--text-lg: 1.414rem;
|
--text-lg: 1.414rem;
|
||||||
--text-xl: 2rem;
|
--text-xl: 2rem;
|
||||||
@@ -20,36 +20,38 @@
|
|||||||
--font-weight-body: 600;
|
--font-weight-body: 600;
|
||||||
--font-weight-normal: 400;
|
--font-weight-normal: 400;
|
||||||
|
|
||||||
|
/* Fluid section title: scales from 2rem at ~267px to 8rem at ~1707px */
|
||||||
|
--text-section-title: clamp(2rem, 7.5vw, 8rem);
|
||||||
|
|
||||||
/* === LINE HEIGHT === */
|
/* === LINE HEIGHT === */
|
||||||
--line-height-tight: 1.2;
|
--line-height-tight: 1.2;
|
||||||
--line-height-normal: 1.5;
|
--line-height-normal: 1.5;
|
||||||
|
--line-height-relaxed: 1.65;
|
||||||
|
|
||||||
/* === FRAUNCES VARIABLE AXES === */
|
/* === FRAUNCES VARIABLE AXES === */
|
||||||
--fraunces-wonk: 1;
|
--fraunces-wonk: 1;
|
||||||
--fraunces-soft: 0;
|
--fraunces-soft: 0;
|
||||||
|
|
||||||
/* === COLOR PALETTE === */
|
/* === COLOR PALETTE: 2-color system === */
|
||||||
--ochre-clay: #D9B48F;
|
--cream: #f4f0e8;
|
||||||
--slate-indigo: #3B4A59;
|
--blue: #041cf3;
|
||||||
--burnt-oxide: #A64B35;
|
|
||||||
--carbon-black: #121212;
|
|
||||||
|
|
||||||
/* === SEMANTIC COLORS === */
|
/* === SEMANTIC COLORS === */
|
||||||
--background: var(--ochre-clay);
|
--background: var(--cream);
|
||||||
--foreground: var(--carbon-black);
|
--foreground: var(--blue);
|
||||||
--card: var(--ochre-clay);
|
--card: var(--cream);
|
||||||
--card-foreground: var(--carbon-black);
|
--card-foreground: var(--blue);
|
||||||
--primary: var(--burnt-oxide);
|
--primary: var(--blue);
|
||||||
--primary-foreground: var(--ochre-clay);
|
--primary-foreground: var(--cream);
|
||||||
--secondary: var(--slate-indigo);
|
--secondary: var(--cream);
|
||||||
--secondary-foreground: var(--ochre-clay);
|
--secondary-foreground: var(--blue);
|
||||||
--muted: var(--slate-indigo);
|
--muted: var(--cream);
|
||||||
--muted-foreground: var(--ochre-clay);
|
--muted-foreground: rgba(4, 28, 243, 0.5);
|
||||||
--accent: var(--burnt-oxide);
|
--accent: var(--blue);
|
||||||
--accent-foreground: var(--ochre-clay);
|
--accent-foreground: var(--cream);
|
||||||
--destructive: #d4183d;
|
--destructive: var(--blue);
|
||||||
--border: var(--carbon-black);
|
--border: var(--blue);
|
||||||
--ring: var(--carbon-black);
|
--ring: var(--blue);
|
||||||
|
|
||||||
/* === SPACING (8pt Linear Scale) === */
|
/* === SPACING (8pt Linear Scale) === */
|
||||||
--space-0: 0;
|
--space-0: 0;
|
||||||
@@ -71,19 +73,35 @@
|
|||||||
--radius: 0px;
|
--radius: 0px;
|
||||||
|
|
||||||
/* === BRUTALIST SHADOWS === */
|
/* === BRUTALIST SHADOWS === */
|
||||||
--shadow-brutal: 8px 8px 0 var(--carbon-black);
|
--shadow-brutal-xs: 1px 1px 0 var(--blue);
|
||||||
--shadow-brutal-sm: 4px 4px 0 var(--carbon-black);
|
--shadow-brutal-sm: 3px 3px 0 var(--blue);
|
||||||
--shadow-brutal-lg: 12px 12px 0 var(--carbon-black);
|
--shadow-brutal: 5px 5px 0 var(--blue);
|
||||||
|
--shadow-brutal-md: 7px 7px 0 var(--blue);
|
||||||
|
--shadow-brutal-lg: 8px 8px 0 var(--blue);
|
||||||
|
--shadow-brutal-xl: 10px 10px 0 var(--blue);
|
||||||
|
--shadow-brutal-2xl: 12px 12px 0 var(--blue);
|
||||||
|
|
||||||
/* === GRID === */
|
/* === GRID === */
|
||||||
--grid-gap: var(--space-3);
|
--grid-gap: var(--space-3);
|
||||||
|
--section-content-width: 72rem;
|
||||||
|
|
||||||
|
/* === ANIMATION === */
|
||||||
|
--ease-default: ease;
|
||||||
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
--ease-decelerate: cubic-bezier(0.25, 0, 0, 1);
|
||||||
|
--ease-micro: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
--duration-fast: 100ms;
|
||||||
|
--duration-normal: 150ms;
|
||||||
|
--duration-slow: 350ms;
|
||||||
|
--duration-spring: 220ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-ochre-clay: var(--ochre-clay);
|
--font-heading: var(--font-fraunces);
|
||||||
--color-slate-indigo: var(--slate-indigo);
|
--font-body: var(--font-public-sans);
|
||||||
--color-burnt-oxide: var(--burnt-oxide);
|
|
||||||
--color-carbon-black: var(--carbon-black);
|
--color-cream: var(--cream);
|
||||||
|
--color-blue: var(--blue);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
@@ -102,6 +120,16 @@
|
|||||||
--radius-sm: var(--radius);
|
--radius-sm: var(--radius);
|
||||||
--radius-md: var(--radius);
|
--radius-md: var(--radius);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
|
--container-section: var(--section-content-width);
|
||||||
|
--text-section-title: var(--text-section-title);
|
||||||
|
|
||||||
|
--shadow-brutal-xs: var(--shadow-brutal-xs);
|
||||||
|
--shadow-brutal-sm: var(--shadow-brutal-sm);
|
||||||
|
--shadow-brutal: var(--shadow-brutal);
|
||||||
|
--shadow-brutal-md: var(--shadow-brutal-md);
|
||||||
|
--shadow-brutal-lg: var(--shadow-brutal-lg);
|
||||||
|
--shadow-brutal-xl: var(--shadow-brutal-xl);
|
||||||
|
--shadow-brutal-2xl: var(--shadow-brutal-2xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -109,6 +137,16 @@
|
|||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: var(--blue);
|
||||||
|
color: var(--cream);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: var(--border-width) solid var(--blue);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
}
|
}
|
||||||
@@ -121,45 +159,74 @@
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Paper grain texture */
|
/* Subtle blue-tinted grain on parchment */
|
||||||
body::before {
|
body::before {
|
||||||
content: '';
|
content: "";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
background-image:
|
||||||
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px),
|
repeating-linear-gradient(
|
||||||
repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px);
|
0deg,
|
||||||
opacity: 0.4;
|
transparent,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(4, 28, 243, 0.015) 2px,
|
||||||
|
rgba(4, 28, 243, 0.015) 4px
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(4, 28, 243, 0.015) 2px,
|
||||||
|
rgba(4, 28, 243, 0.015) 4px
|
||||||
|
);
|
||||||
|
opacity: 0.6;
|
||||||
display: block;
|
display: block;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-weight: var(--font-weight-heading);
|
font-weight: var(--font-weight-heading);
|
||||||
line-height: var(--line-height-tight);
|
line-height: var(--line-height-tight);
|
||||||
font-variation-settings: 'WONK' var(--fraunces-wonk), 'SOFT' var(--fraunces-soft);
|
font-variation-settings:
|
||||||
color: var(--carbon-black);
|
"WONK" var(--fraunces-wonk),
|
||||||
|
"SOFT" var(--fraunces-soft);
|
||||||
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { font-size: var(--text-4xl); }
|
h1 {
|
||||||
h2 { font-size: var(--text-3xl); }
|
font-size: var(--text-4xl);
|
||||||
h3 { font-size: var(--text-2xl); }
|
}
|
||||||
h4 { font-size: var(--text-xl); }
|
h2 {
|
||||||
h5 { font-size: var(--text-lg); }
|
font-size: var(--text-3xl);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
font-weight: var(--font-weight-body);
|
font-weight: var(--font-weight-body);
|
||||||
color: var(--carbon-black);
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--burnt-oxide);
|
color: var(--blue);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-bottom: 2px solid var(--carbon-black);
|
border-bottom: 2px solid var(--blue);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,25 +237,132 @@
|
|||||||
blockquote {
|
blockquote {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-xl);
|
||||||
border-left: var(--border-width) solid var(--carbon-black);
|
border-left: var(--border-width) solid var(--blue);
|
||||||
padding-left: var(--space-4);
|
padding-left: var(--space-4);
|
||||||
margin: var(--space-6) 0;
|
margin: var(--space-6) 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brutalist utility classes */
|
/* Button elevation transition — only transform animates; shadow snaps instantly */
|
||||||
.brutal-shadow { box-shadow: var(--shadow-brutal); }
|
.btn-transition {
|
||||||
.brutal-shadow-sm { box-shadow: var(--shadow-brutal-sm); }
|
transition: transform 0.13s var(--ease-micro);
|
||||||
.brutal-shadow-lg { box-shadow: var(--shadow-brutal-lg); }
|
}
|
||||||
.brutal-border { border: var(--border-width) solid var(--carbon-black); }
|
|
||||||
.brutal-border-top { border-top: var(--border-width) solid var(--carbon-black); }
|
/* Brutalist utility classes */
|
||||||
.brutal-border-bottom { border-bottom: var(--border-width) solid var(--carbon-black); }
|
.brutal-shadow {
|
||||||
.brutal-border-left { border-left: var(--border-width) solid var(--carbon-black); }
|
box-shadow: var(--shadow-brutal);
|
||||||
.brutal-border-right { border-right: var(--border-width) solid var(--carbon-black); }
|
}
|
||||||
|
.brutal-shadow-sm {
|
||||||
/* Animations */
|
box-shadow: var(--shadow-brutal-sm);
|
||||||
@keyframes fadeIn {
|
}
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
.brutal-shadow-lg {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
box-shadow: var(--shadow-brutal-lg);
|
||||||
|
}
|
||||||
|
.brutal-border {
|
||||||
|
border: var(--border-width) solid var(--blue);
|
||||||
|
}
|
||||||
|
.brutal-border-top {
|
||||||
|
border-top: var(--border-width) solid var(--blue);
|
||||||
|
}
|
||||||
|
.brutal-border-bottom {
|
||||||
|
border-bottom: var(--border-width) solid var(--blue);
|
||||||
|
}
|
||||||
|
.brutal-border-left {
|
||||||
|
border-left: var(--border-width) solid var(--blue);
|
||||||
|
}
|
||||||
|
.brutal-border-right {
|
||||||
|
border-right: var(--border-width) solid var(--blue);
|
||||||
|
}
|
||||||
|
/* Apply Fraunces variable axes to non-heading elements using the heading font */
|
||||||
|
.font-wonk {
|
||||||
|
font-variation-settings:
|
||||||
|
"WONK" var(--fraunces-wonk),
|
||||||
|
"SOFT" var(--fraunces-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar divider: bottom border on mobile, right border on desktop */
|
||||||
|
.brutal-border-sidebar {
|
||||||
|
border-bottom: var(--border-width) solid var(--blue);
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.brutal-border-sidebar {
|
||||||
|
border-bottom: none;
|
||||||
|
border-right: var(--border-width) solid var(--blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editorial rich-text typography */
|
||||||
|
.rich-text {
|
||||||
|
max-width: 65ch;
|
||||||
|
line-height: var(--line-height-relaxed);
|
||||||
|
font-feature-settings: "onum";
|
||||||
|
hanging-punctuation: first last;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text p + p {
|
||||||
|
margin-top: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text ul li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.65em;
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text ul li:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text ul li::before {
|
||||||
|
content: "◆";
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 0.55em;
|
||||||
|
/* line-height matches parent so diamond centers within the first line box */
|
||||||
|
line-height: calc(var(--line-height-relaxed) / 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cross-section view transition (navigation between sections) */
|
||||||
|
::view-transition-old(section-content) {
|
||||||
|
animation-name: section-fade-out;
|
||||||
|
animation-duration: var(--duration-normal);
|
||||||
|
animation-timing-function: var(--ease-default);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(section-content) {
|
||||||
|
animation-name: section-fade-in;
|
||||||
|
animation-duration: var(--duration-spring);
|
||||||
|
animation-timing-function: var(--ease-spring);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes section-fade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes section-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.animate-fadeIn { animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { Badge } from './ui/Badge'
|
export type { BadgeSize, BadgeVariant } from './ui/Badge';
|
||||||
export type { BadgeVariant } from './ui/Badge'
|
export { Badge } from './ui/Badge';
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { Badge } from './Badge'
|
import { Badge } from './Badge';
|
||||||
|
|
||||||
const meta: Meta<typeof Badge> = {
|
const meta: Meta<typeof Badge> = {
|
||||||
title: 'Shared/Badge',
|
title: 'Shared/Badge',
|
||||||
component: Badge,
|
component: Badge,
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof Badge>
|
type Story = StoryObj<typeof Badge>;
|
||||||
|
|
||||||
export const AllVariants: Story = {
|
export const AllVariants: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
@@ -19,4 +19,4 @@ export const AllVariants: Story = {
|
|||||||
<Badge variant="outline">Outline</Badge>
|
<Badge variant="outline">Outline</Badge>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,52 +1,73 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { Badge } from './Badge';
|
||||||
import { Badge } from './Badge'
|
|
||||||
|
|
||||||
describe('Badge', () => {
|
describe('Badge', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders children', () => {
|
it('renders children', () => {
|
||||||
render(<Badge>React</Badge>)
|
render(<Badge>React</Badge>);
|
||||||
expect(screen.getByText('React')).toBeInTheDocument()
|
expect(screen.getByText('React')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders as inline span', () => {
|
it('renders as inline span', () => {
|
||||||
render(<Badge>Tag</Badge>)
|
render(<Badge>Tag</Badge>);
|
||||||
expect(screen.getByText('Tag').tagName).toBe('SPAN')
|
expect(screen.getByText('Tag').tagName).toBe('SPAN');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('variants', () => {
|
describe('variants', () => {
|
||||||
it('applies default variant classes', () => {
|
it('applies default variant classes', () => {
|
||||||
render(<Badge variant="default">Tag</Badge>)
|
render(<Badge variant="default">Tag</Badge>);
|
||||||
const el = screen.getByText('Tag')
|
const el = screen.getByText('Tag');
|
||||||
expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay')
|
expect(el).toHaveClass('bg-blue', 'text-cream');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('applies primary variant classes', () => {
|
it('applies primary variant classes', () => {
|
||||||
render(<Badge variant="primary">Tag</Badge>)
|
render(<Badge variant="primary">Tag</Badge>);
|
||||||
expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide')
|
expect(screen.getByText('Tag')).toHaveClass('bg-blue');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('applies secondary variant classes', () => {
|
it('applies secondary variant classes', () => {
|
||||||
render(<Badge variant="secondary">Tag</Badge>)
|
render(<Badge variant="secondary">Tag</Badge>);
|
||||||
expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo')
|
expect(screen.getByText('Tag')).toHaveClass('bg-blue');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('applies outline variant classes', () => {
|
it('applies outline variant classes', () => {
|
||||||
render(<Badge variant="outline">Tag</Badge>)
|
render(<Badge variant="outline">Tag</Badge>);
|
||||||
expect(screen.getByText('Tag')).toHaveClass('bg-transparent')
|
expect(screen.getByText('Tag')).toHaveClass('bg-transparent');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('defaults to default variant when unspecified', () => {
|
it('defaults to default variant when unspecified', () => {
|
||||||
render(<Badge>Tag</Badge>)
|
render(<Badge>Tag</Badge>);
|
||||||
expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black')
|
expect(screen.getByText('Tag')).toHaveClass('bg-blue');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
|
describe('sizes', () => {
|
||||||
|
it('defaults to sm size', () => {
|
||||||
|
render(<Badge>Tag</Badge>);
|
||||||
|
expect(screen.getByText('Tag')).toHaveClass('px-3', 'py-1', 'text-xs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies xs size classes', () => {
|
||||||
|
render(<Badge size="xs">Tag</Badge>);
|
||||||
|
expect(screen.getByText('Tag')).toHaveClass('px-2', 'py-0.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies sm size classes', () => {
|
||||||
|
render(<Badge size="sm">Tag</Badge>);
|
||||||
|
expect(screen.getByText('Tag')).toHaveClass('px-3', 'py-1', 'text-xs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies md size classes', () => {
|
||||||
|
render(<Badge size="md">Tag</Badge>);
|
||||||
|
expect(screen.getByText('Tag')).toHaveClass('px-4', 'py-2', 'text-sm');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('className passthrough', () => {
|
describe('className passthrough', () => {
|
||||||
it('merges custom className', () => {
|
it('merges custom className', () => {
|
||||||
render(<Badge className="mt-4">Tag</Badge>)
|
render(<Badge className="mt-4">Tag</Badge>);
|
||||||
expect(screen.getByText('Tag')).toHaveClass('mt-4')
|
expect(screen.getByText('Tag')).toHaveClass('mt-4');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,38 +1,50 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react';
|
||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline'
|
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline';
|
||||||
|
export type BadgeSize = 'xs' | 'sm' | 'md';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Badge content
|
* Badge content
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Visual variant
|
* Visual variant
|
||||||
* @default 'default'
|
* @default 'default'
|
||||||
*/
|
*/
|
||||||
variant?: BadgeVariant
|
variant?: BadgeVariant;
|
||||||
|
/**
|
||||||
|
* Size preset
|
||||||
|
* @default 'sm'
|
||||||
|
*/
|
||||||
|
size?: BadgeSize;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANTS: Record<BadgeVariant, string> = {
|
const VARIANTS: Record<BadgeVariant, string> = {
|
||||||
default: 'brutal-border bg-carbon-black text-ochre-clay',
|
default: 'brutal-border bg-blue text-cream',
|
||||||
primary: 'brutal-border bg-burnt-oxide text-ochre-clay',
|
primary: 'brutal-border bg-blue text-cream',
|
||||||
secondary: 'brutal-border bg-slate-indigo text-ochre-clay',
|
secondary: 'brutal-border bg-blue text-cream',
|
||||||
outline: 'brutal-border bg-transparent text-carbon-black',
|
outline: 'brutal-border bg-transparent text-blue',
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const SIZES: Record<BadgeSize, string> = {
|
||||||
|
xs: 'px-2 py-0.5 text-[10px]',
|
||||||
|
sm: 'px-3 py-1 text-xs',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small label for categorization or status.
|
* Small label for categorization or status.
|
||||||
*/
|
*/
|
||||||
export function Badge({ children, variant = 'default', className }: Props) {
|
export function Badge({ children, variant = 'default', size = 'sm', className }: Props) {
|
||||||
return (
|
return (
|
||||||
<span className={cn('inline-block px-3 py-1 text-xs uppercase tracking-wider', VARIANTS[variant], className)}>
|
<span className={cn('inline-block uppercase tracking-wider', SIZES[size], VARIANTS[variant], className)}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { Button } from './ui/Button'
|
export type { ButtonSize, ButtonVariant } from './ui/Button';
|
||||||
export type { ButtonVariant, ButtonSize } from './ui/Button'
|
export { Button } from './ui/Button';
|
||||||
|
|||||||
@@ -1,35 +1,49 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { Button } from './Button'
|
import { Button } from './Button';
|
||||||
|
|
||||||
const meta: Meta<typeof Button> = {
|
const meta: Meta<typeof Button> = {
|
||||||
title: 'Shared/Button',
|
title: 'Shared/Button',
|
||||||
component: Button,
|
component: Button,
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof Button>
|
type Story = StoryObj<typeof Button>;
|
||||||
|
|
||||||
export const AllVariants: Story = {
|
export const AllVariants: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex gap-4 flex-wrap p-8 bg-ochre-clay">
|
<div className="flex gap-4 flex-wrap p-8 bg-ochre-clay">
|
||||||
<Button variant="primary" size="md">Primary</Button>
|
<Button variant="primary" size="md">
|
||||||
<Button variant="secondary" size="md">Secondary</Button>
|
Primary
|
||||||
<Button variant="outline" size="md">Outline</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="md">Ghost</Button>
|
<Button variant="secondary" size="md">
|
||||||
|
Secondary
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="md">
|
||||||
|
Outline
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="md">
|
||||||
|
Ghost
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Sizes: Story = {
|
export const Sizes: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex gap-4 items-center flex-wrap p-8 bg-ochre-clay">
|
<div className="flex gap-4 items-center flex-wrap p-8 bg-ochre-clay">
|
||||||
<Button variant="primary" size="sm">Small</Button>
|
<Button variant="primary" size="sm">
|
||||||
<Button variant="primary" size="md">Medium</Button>
|
Small
|
||||||
<Button variant="primary" size="lg">Large</Button>
|
</Button>
|
||||||
|
<Button variant="primary" size="md">
|
||||||
|
Medium
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="lg">
|
||||||
|
Large
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Disabled: Story = {
|
export const Disabled: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -44,4 +58,4 @@ export const Disabled: Story = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,67 +1,93 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import userEvent from '@testing-library/user-event';
|
||||||
import userEvent from '@testing-library/user-event'
|
import { Button } from './Button';
|
||||||
import { Button } from './Button'
|
|
||||||
|
|
||||||
describe('Button', () => {
|
describe('Button', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders children', () => {
|
it('renders children', () => {
|
||||||
render(<Button>Click me</Button>)
|
render(<Button>Click me</Button>);
|
||||||
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders as button element', () => {
|
it('renders as button element', () => {
|
||||||
render(<Button>Click</Button>)
|
render(<Button>Click</Button>);
|
||||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('variants', () => {
|
describe('variants', () => {
|
||||||
it('applies primary variant by default', () => {
|
it('applies primary variant by default', () => {
|
||||||
render(<Button>Go</Button>)
|
render(<Button>Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide')
|
expect(screen.getByRole('button')).toHaveClass('bg-blue');
|
||||||
})
|
});
|
||||||
it('applies secondary variant', () => {
|
it('applies secondary variant', () => {
|
||||||
render(<Button variant="secondary">Go</Button>)
|
render(<Button variant="secondary">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo')
|
expect(screen.getByRole('button')).toHaveClass('bg-blue');
|
||||||
})
|
});
|
||||||
it('applies outline variant', () => {
|
it('applies outline variant', () => {
|
||||||
render(<Button variant="outline">Go</Button>)
|
render(<Button variant="outline">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-transparent')
|
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
||||||
})
|
});
|
||||||
it('applies ghost variant', () => {
|
it('applies ghost variant', () => {
|
||||||
render(<Button variant="ghost">Go</Button>)
|
render(<Button variant="ghost">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay')
|
expect(screen.getByRole('button')).toHaveClass('bg-cream');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('sizes', () => {
|
describe('sizes', () => {
|
||||||
it('applies md size by default', () => {
|
it('applies md size by default', () => {
|
||||||
render(<Button>Go</Button>)
|
render(<Button>Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3')
|
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3');
|
||||||
})
|
});
|
||||||
it('applies sm size', () => {
|
it('applies sm size', () => {
|
||||||
render(<Button size="sm">Go</Button>)
|
render(<Button size="sm">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2')
|
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2');
|
||||||
})
|
});
|
||||||
it('applies lg size', () => {
|
it('applies lg size', () => {
|
||||||
render(<Button size="lg">Go</Button>)
|
render(<Button size="lg">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4')
|
expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('interactions', () => {
|
describe('interactions', () => {
|
||||||
it('calls onClick when clicked', async () => {
|
it('calls onClick when clicked', async () => {
|
||||||
const onClick = vi.fn()
|
const onClick = vi.fn();
|
||||||
render(<Button onClick={onClick}>Go</Button>)
|
render(<Button onClick={onClick}>Go</Button>);
|
||||||
await userEvent.click(screen.getByRole('button'))
|
await userEvent.click(screen.getByRole('button'));
|
||||||
expect(onClick).toHaveBeenCalledOnce()
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
})
|
});
|
||||||
it('is disabled when disabled prop is set', () => {
|
it('is disabled when disabled prop is set', () => {
|
||||||
render(<Button disabled>Go</Button>)
|
render(<Button disabled>Go</Button>);
|
||||||
expect(screen.getByRole('button')).toBeDisabled()
|
expect(screen.getByRole('button')).toBeDisabled();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('className passthrough', () => {
|
describe('className passthrough', () => {
|
||||||
it('merges custom className', () => {
|
it('merges custom className', () => {
|
||||||
render(<Button className="w-full">Go</Button>)
|
render(<Button className="w-full">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('w-full')
|
expect(screen.getByRole('button')).toHaveClass('w-full');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
describe('as anchor', () => {
|
||||||
|
it('renders an anchor when href is provided', () => {
|
||||||
|
render(<Button href="/cv.pdf">Download</Button>);
|
||||||
|
expect(screen.getByRole('link', { name: 'Download' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('sets href on the anchor', () => {
|
||||||
|
render(<Button href="/cv.pdf">Download</Button>);
|
||||||
|
expect(screen.getByRole('link')).toHaveAttribute('href', '/cv.pdf');
|
||||||
|
});
|
||||||
|
it('sets download attribute when provided', () => {
|
||||||
|
render(
|
||||||
|
<Button href="/cv.pdf" download>
|
||||||
|
Download
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link')).toHaveAttribute('download');
|
||||||
|
});
|
||||||
|
it('applies the same variant and size classes as button', () => {
|
||||||
|
render(
|
||||||
|
<Button href="/test" variant="primary" size="sm">
|
||||||
|
Go
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveClass('bg-blue', 'px-4', 'py-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,48 +1,85 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'
|
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||||
export type ButtonSize = 'sm' | 'md' | 'lg'
|
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
type BaseProps = {
|
||||||
/**
|
/**
|
||||||
* Visual variant
|
* Visual variant
|
||||||
* @default 'primary'
|
* @default 'primary'
|
||||||
*/
|
*/
|
||||||
variant?: ButtonVariant
|
variant?: ButtonVariant;
|
||||||
/**
|
/**
|
||||||
* Size preset
|
* Size preset
|
||||||
* @default 'md'
|
* @default 'md'
|
||||||
*/
|
*/
|
||||||
size?: ButtonSize
|
size?: ButtonSize;
|
||||||
/**
|
/**
|
||||||
* Button content
|
* Button content
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* CSS classes
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AsButton = BaseProps & ButtonHTMLAttributes<HTMLButtonElement> & { href?: never };
|
||||||
|
type AsAnchor = BaseProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
|
||||||
|
|
||||||
|
type Props = AsButton | AsAnchor;
|
||||||
|
|
||||||
|
type RestButton = Omit<AsButton, keyof BaseProps>;
|
||||||
|
type RestAnchor = Omit<AsAnchor, keyof BaseProps>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows spread props to anchor shape when href is a non-undefined string.
|
||||||
|
*/
|
||||||
|
function isAnchorProps(props: RestButton | RestAnchor): props is RestAnchor {
|
||||||
|
return typeof props.href === 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANTS: Record<ButtonVariant, string> = {
|
const VARIANTS = {
|
||||||
primary: 'bg-burnt-oxide text-ochre-clay',
|
primary:
|
||||||
secondary: 'bg-slate-indigo text-ochre-clay',
|
'brutal-border bg-blue text-cream shadow-[5px_5px_0_rgba(4,28,243,0.35)] hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_rgba(4,28,243,0.35)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-[1px_1px_0_rgba(4,28,243,0.35)]',
|
||||||
outline: 'bg-transparent text-carbon-black border-carbon-black',
|
secondary:
|
||||||
ghost: 'bg-ochre-clay text-carbon-black border-carbon-black',
|
'brutal-border bg-blue text-cream shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
|
||||||
}
|
outline:
|
||||||
|
'brutal-border bg-transparent text-blue shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
|
||||||
|
ghost:
|
||||||
|
'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
|
||||||
|
} as const satisfies Record<ButtonVariant, string>;
|
||||||
|
|
||||||
const SIZES: Record<ButtonSize, string> = {
|
const SIZES = {
|
||||||
sm: 'px-4 py-2 text-sm',
|
sm: 'px-4 py-2 text-sm',
|
||||||
md: 'px-6 py-3 text-base',
|
md: 'px-6 py-3 text-base',
|
||||||
lg: 'px-8 py-4 text-lg',
|
lg: 'px-8 py-4 text-lg',
|
||||||
}
|
} as const satisfies Record<ButtonSize, string>;
|
||||||
|
|
||||||
const BASE = 'brutal-border brutal-shadow transition-all duration-200 hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[6px_6px_0_var(--carbon-black)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-[4px_4px_0_var(--carbon-black)] uppercase tracking-wider'
|
/* box-shadow excluded from transition intentionally — snaps instantly so the
|
||||||
|
* eye follows the 130ms button movement, not the shadow change. */
|
||||||
|
const BASE = 'cursor-pointer btn-transition uppercase tracking-wider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brutalist button with variants and sizes.
|
* Brutalist button with variants and sizes.
|
||||||
|
* Renders as <a> when href is provided, <button> otherwise.
|
||||||
*/
|
*/
|
||||||
export function Button({ variant = 'primary', size = 'md', className, children, ...props }: Props) {
|
export function Button({ variant = 'primary', size = 'md', className, children, ...props }: Props) {
|
||||||
|
const cls = cn(BASE, VARIANTS[variant], SIZES[size], className);
|
||||||
|
|
||||||
|
if (isAnchorProps(props)) {
|
||||||
|
const { href, ...anchorProps } = props;
|
||||||
|
return (
|
||||||
|
<a href={href} rel="noopener noreferrer" target="_blank" className={cls} {...anchorProps}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}>
|
<button className={cls} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card'
|
export type { CardBackground } from './ui/Card';
|
||||||
export type { CardBackground } from './ui/Card'
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './ui/Card';
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
||||||
|
|
||||||
const meta: Meta<typeof Card> = {
|
const meta: Meta<typeof Card> = {
|
||||||
title: 'Shared/Card',
|
title: 'Shared/Card',
|
||||||
component: Card,
|
component: Card,
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof Card>
|
type Story = StoryObj<typeof Card>;
|
||||||
|
|
||||||
export const AllBackgrounds: Story = {
|
export const AllBackgrounds: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
@@ -36,19 +36,17 @@ export const AllBackgrounds: Story = {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const NoPadding: Story = {
|
export const NoPadding: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="p-8 bg-ochre-clay">
|
<div className="p-8 bg-ochre-clay">
|
||||||
<Card noPadding className="w-64 overflow-hidden">
|
<Card noPadding className="w-64 overflow-hidden">
|
||||||
<div className="h-40 bg-slate-indigo flex items-center justify-center text-ochre-clay">
|
<div className="h-40 bg-slate-indigo flex items-center justify-center text-ochre-clay">Image placeholder</div>
|
||||||
Image placeholder
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const FullComposition: Story = {
|
export const FullComposition: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
@@ -67,4 +65,4 @@ export const FullComposition: Story = {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,79 +1,122 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
|
||||||
|
|
||||||
describe('Card', () => {
|
describe('Card', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders children', () => {
|
it('renders children', () => {
|
||||||
render(<Card>Content</Card>)
|
render(<Card>Content</Card>);
|
||||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('has brutal-border and brutal-shadow classes', () => {
|
it('has brutal-border and brutal-shadow classes', () => {
|
||||||
const { container } = render(<Card>Content</Card>)
|
const { container } = render(<Card>Content</Card>);
|
||||||
expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow')
|
expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('background variants', () => {
|
describe('background variants', () => {
|
||||||
it('defaults to ochre background', () => {
|
it('defaults to cream background', () => {
|
||||||
const { container } = render(<Card>Content</Card>)
|
const { container } = render(<Card>Content</Card>);
|
||||||
expect(container.firstChild).toHaveClass('bg-ochre-clay')
|
expect(container.firstChild).toHaveClass('bg-cream');
|
||||||
})
|
});
|
||||||
it('applies slate background', () => {
|
it('applies blue background', () => {
|
||||||
const { container } = render(<Card background="slate">Content</Card>)
|
const { container } = render(<Card background="blue">Content</Card>);
|
||||||
expect(container.firstChild).toHaveClass('bg-slate-indigo')
|
expect(container.firstChild).toHaveClass('bg-blue');
|
||||||
})
|
});
|
||||||
it('applies white background', () => {
|
});
|
||||||
const { container } = render(<Card background="white">Content</Card>)
|
|
||||||
expect(container.firstChild).toHaveClass('bg-white')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
describe('padding', () => {
|
describe('padding', () => {
|
||||||
it('has default padding', () => {
|
it('has default padding', () => {
|
||||||
const { container } = render(<Card>Content</Card>)
|
const { container } = render(<Card>Content</Card>);
|
||||||
expect(container.firstChild).toHaveClass('p-6')
|
expect(container.firstChild).toHaveClass('p-6');
|
||||||
})
|
});
|
||||||
it('removes padding when noPadding is true', () => {
|
it('removes padding when noPadding is true', () => {
|
||||||
const { container } = render(<Card noPadding>Content</Card>)
|
const { container } = render(<Card noPadding>Content</Card>);
|
||||||
expect(container.firstChild).not.toHaveClass('p-6')
|
expect(container.firstChild).not.toHaveClass('p-6');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('className passthrough', () => {
|
describe('className passthrough', () => {
|
||||||
it('merges custom className', () => {
|
it('merges custom className', () => {
|
||||||
const { container } = render(<Card className="group">Content</Card>)
|
const { container } = render(<Card className="group">Content</Card>);
|
||||||
expect(container.firstChild).toHaveClass('group')
|
expect(container.firstChild).toHaveClass('group');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('CardHeader', () => {
|
describe('CardHeader', () => {
|
||||||
it('renders children with bottom margin', () => {
|
it('renders children with bottom margin', () => {
|
||||||
render(<CardHeader>Header</CardHeader>)
|
render(<CardHeader>Header</CardHeader>);
|
||||||
expect(screen.getByText('Header')).toHaveClass('mb-4')
|
expect(screen.getByText('Header')).toHaveClass('mb-6');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('CardTitle', () => {
|
describe('CardTitle', () => {
|
||||||
it('renders children as h3', () => {
|
it('renders children as h3', () => {
|
||||||
render(<CardTitle>Title</CardTitle>)
|
render(<CardTitle>Title</CardTitle>);
|
||||||
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title')
|
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('CardDescription', () => {
|
describe('CardDescription', () => {
|
||||||
it('renders children as paragraph with opacity', () => {
|
it('renders children as paragraph with opacity', () => {
|
||||||
render(<CardDescription>Desc</CardDescription>)
|
render(<CardDescription>Desc</CardDescription>);
|
||||||
const el = screen.getByText('Desc')
|
const el = screen.getByText('Desc');
|
||||||
expect(el.tagName).toBe('P')
|
expect(el.tagName).toBe('P');
|
||||||
expect(el).toHaveClass('opacity-80')
|
expect(el).toHaveClass('opacity-80');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('CardContent', () => {
|
describe('CardContent', () => {
|
||||||
it('renders children in a div', () => {
|
it('renders children in a div', () => {
|
||||||
render(<CardContent>Body</CardContent>)
|
render(<CardContent>Body</CardContent>);
|
||||||
expect(screen.getByText('Body')).toBeInTheDocument()
|
expect(screen.getByText('Body')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('CardFooter', () => {
|
describe('CardFooter', () => {
|
||||||
it('renders children with top border', () => {
|
it('renders children with top border', () => {
|
||||||
render(<CardFooter>Footer</CardFooter>)
|
render(<CardFooter>Footer</CardFooter>);
|
||||||
const el = screen.getByText('Footer')
|
const el = screen.getByText('Footer');
|
||||||
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6')
|
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6', 'md:mt-8', 'md:pt-8');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
describe('CardSidebar', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders sidebar content', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
expect(screen.getByText('Sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders main content', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
expect(screen.getByText('Main')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('structure', () => {
|
||||||
|
it('root wrapper is a flex container', () => {
|
||||||
|
const { container } = render(<CardSidebar sidebar={<span>S</span>}>M</CardSidebar>);
|
||||||
|
expect(container.firstChild).toHaveClass('flex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sidebar column has brutal-border-sidebar class', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
const sidebar = screen.getByText('Sidebar').parentElement;
|
||||||
|
expect(sidebar).toHaveClass('brutal-border-sidebar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sidebar column has fixed width on lg', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
const sidebar = screen.getByText('Sidebar').parentElement;
|
||||||
|
expect(sidebar).toHaveClass('lg:w-64');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('main column fills remaining space', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
expect(screen.getByText('Main')).toHaveClass('flex-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('className passthrough', () => {
|
||||||
|
it('forwards className to the root wrapper', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CardSidebar sidebar={<span>S</span>} className="custom">
|
||||||
|
M
|
||||||
|
</CardSidebar>,
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toHaveClass('custom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,88 +1,116 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react';
|
||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
export type CardBackground = 'ochre' | 'slate' | 'white'
|
export type CardBackground = 'cream' | 'blue';
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
/**
|
/**
|
||||||
* Card content
|
* Card content
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Background color preset
|
* Background color preset
|
||||||
* @default 'ochre'
|
* @default 'cream'
|
||||||
*/
|
*/
|
||||||
background?: CardBackground
|
background?: CardBackground;
|
||||||
/**
|
/**
|
||||||
* Remove default padding
|
* Remove default padding
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
noPadding?: boolean
|
noPadding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BG: Record<CardBackground, string> = {
|
const BG: Record<CardBackground, string> = {
|
||||||
ochre: 'bg-ochre-clay',
|
cream: 'bg-cream',
|
||||||
slate: 'bg-slate-indigo text-ochre-clay',
|
blue: 'bg-blue text-cream',
|
||||||
white: 'bg-white',
|
};
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brutalist card container with background and padding variants.
|
* Brutalist card container with background and padding variants.
|
||||||
*/
|
*/
|
||||||
export function Card({ children, className, background = 'ochre', noPadding = false }: CardProps) {
|
export function Card({ children, className, background = 'cream', noPadding = false }: CardProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('brutal-border brutal-shadow', BG[background], !noPadding && 'p-6 md:p-8', className)}>
|
<div className={cn('brutal-border brutal-shadow', BG[background], !noPadding && 'p-6 md:p-8', className)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SlotProps {
|
interface SlotProps {
|
||||||
/**
|
/**
|
||||||
* Slot content
|
* Slot content
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card header wrapper — adds bottom margin.
|
* Card header wrapper — adds bottom margin.
|
||||||
*/
|
*/
|
||||||
export function CardHeader({ children, className }: SlotProps) {
|
export function CardHeader({ children, className }: SlotProps) {
|
||||||
return <div className={cn('mb-4', className)}>{children}</div>
|
return <div className={cn('mb-6 md:mb-8', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card title — renders as h3.
|
* Card title — renders as h3.
|
||||||
*/
|
*/
|
||||||
export function CardTitle({ children, className }: SlotProps) {
|
export function CardTitle({ children, className }: SlotProps) {
|
||||||
return <h3 className={className}>{children}</h3>
|
return <h3 className={className}>{children}</h3>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card description — muted paragraph below the title.
|
* Card description — muted paragraph below the title.
|
||||||
*/
|
*/
|
||||||
export function CardDescription({ children, className }: SlotProps) {
|
export function CardDescription({ children, className }: SlotProps) {
|
||||||
return <p className={cn('mt-2 opacity-80', className)}>{children}</p>
|
return <p className={cn('mt-2 opacity-80', className)}>{children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card body content area.
|
* Card body content area.
|
||||||
*/
|
*/
|
||||||
export function CardContent({ children, className }: SlotProps) {
|
export function CardContent({ children, className }: SlotProps) {
|
||||||
return <div className={className}>{children}</div>
|
return <div className={className}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card footer — separated by a brutal border-top.
|
* Card footer — separated by a brutal border-top.
|
||||||
*/
|
*/
|
||||||
export function CardFooter({ children, className }: SlotProps) {
|
export function CardFooter({ children, className }: SlotProps) {
|
||||||
return <div className={cn('mt-6 pt-6 brutal-border-top', className)}>{children}</div>
|
return <div className={cn('mt-6 md:mt-8 pt-6 md:pt-8 brutal-border-top', className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardSidebarProps {
|
||||||
|
/**
|
||||||
|
* Left sidebar content — metadata such as period, company, stack
|
||||||
|
*/
|
||||||
|
sidebar: ReactNode;
|
||||||
|
/**
|
||||||
|
* Main content — primary info such as role title and description
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Additional CSS classes for the root wrapper
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-column card layout: narrow sidebar on the left, main content on the right.
|
||||||
|
* On mobile the columns stack vertically with a bottom border separator;
|
||||||
|
* on md+ they sit side-by-side with a right border separator.
|
||||||
|
*/
|
||||||
|
export function CardSidebar({ sidebar, children, className }: CardSidebarProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col lg:flex-row', className)}>
|
||||||
|
<div className="shrink-0 lg:w-64 brutal-border-sidebar pb-6 lg:pb-0 lg:pr-8 mb-6 lg:mb-0">{sidebar}</div>
|
||||||
|
<div className="flex-1 min-w-0 lg:pl-8">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { Input, Textarea } from './ui/Input'
|
export { Input, Textarea } from './ui/Input';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { Input, Textarea } from './Input'
|
import { Input, Textarea } from './Input';
|
||||||
|
|
||||||
const meta: Meta<typeof Input> = {
|
const meta: Meta<typeof Input> = {
|
||||||
title: 'Shared/Input',
|
title: 'Shared/Input',
|
||||||
@@ -11,35 +11,35 @@ const meta: Meta<typeof Input> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof Input>
|
type Story = StoryObj<typeof Input>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {},
|
args: {},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const WithLabel: Story = {
|
export const WithLabel: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: 'Email address',
|
label: 'Email address',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const WithError: Story = {
|
export const WithError: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
error: 'This field is required',
|
error: 'This field is required',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const WithPlaceholder: Story = {
|
export const WithPlaceholder: Story = {
|
||||||
args: {
|
args: {
|
||||||
placeholder: 'Enter your email',
|
placeholder: 'Enter your email',
|
||||||
type: 'email',
|
type: 'email',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const TextareaStory: Story = {
|
export const TextareaStory: Story = {
|
||||||
name: 'Textarea',
|
name: 'Textarea',
|
||||||
@@ -48,7 +48,7 @@ export const TextareaStory: Story = {
|
|||||||
<Textarea label="Message" rows={4} />
|
<Textarea label="Message" rows={4} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const TextareaWithError: Story = {
|
export const TextareaWithError: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
@@ -56,4 +56,4 @@ export const TextareaWithError: Story = {
|
|||||||
<Textarea label="Message" error="Too short" rows={4} />
|
<Textarea label="Message" error="Too short" rows={4} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,110 +1,109 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { Input, Textarea } from './Input';
|
||||||
import { Input, Textarea } from './Input'
|
|
||||||
|
|
||||||
describe('Input', () => {
|
describe('Input', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders an input element', () => {
|
it('renders an input element', () => {
|
||||||
render(<Input />)
|
render(<Input />);
|
||||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders label when provided', () => {
|
it('renders label when provided', () => {
|
||||||
render(<Input label="Email" />)
|
render(<Input label="Email" />);
|
||||||
expect(screen.getByText('Email')).toBeInTheDocument()
|
expect(screen.getByText('Email')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('does not render label when omitted', () => {
|
it('does not render label when omitted', () => {
|
||||||
const { container } = render(<Input />)
|
const { container } = render(<Input />);
|
||||||
expect(container.querySelector('label')).toBeNull()
|
expect(container.querySelector('label')).toBeNull();
|
||||||
})
|
});
|
||||||
it('renders error message when provided', () => {
|
it('renders error message when provided', () => {
|
||||||
render(<Input error="Required" />)
|
render(<Input error="Required" />);
|
||||||
expect(screen.getByText('Required')).toBeInTheDocument()
|
expect(screen.getByText('Required')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('does not render error when omitted', () => {
|
it('does not render error when omitted', () => {
|
||||||
render(<Input />)
|
render(<Input />);
|
||||||
expect(screen.queryByText('Required')).toBeNull()
|
expect(screen.queryByText('Required')).toBeNull();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('accessibility', () => {
|
describe('accessibility', () => {
|
||||||
it('label is associated with input via htmlFor/id', () => {
|
it('label is associated with input via htmlFor/id', () => {
|
||||||
render(<Input label="Email" />)
|
render(<Input label="Email" />);
|
||||||
expect(screen.getByLabelText('Email')).toBeInTheDocument()
|
expect(screen.getByLabelText('Email')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('error span is referenced by aria-describedby', () => {
|
it('error span is referenced by aria-describedby', () => {
|
||||||
render(<Input error="Required" />)
|
render(<Input error="Required" />);
|
||||||
const input = screen.getByRole('textbox')
|
const input = screen.getByRole('textbox');
|
||||||
const errorId = input.getAttribute('aria-describedby')
|
const errorId = input.getAttribute('aria-describedby');
|
||||||
expect(errorId).toBeTruthy()
|
expect(errorId).toBeTruthy();
|
||||||
expect(document.getElementById(errorId!)).toHaveTextContent('Required')
|
expect(document.getElementById(errorId as string)).toHaveTextContent('Required');
|
||||||
})
|
});
|
||||||
it('no aria-describedby when no error', () => {
|
it('no aria-describedby when no error', () => {
|
||||||
render(<Input />)
|
render(<Input />);
|
||||||
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
|
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
|
||||||
})
|
});
|
||||||
it('uses provided id prop', () => {
|
it('uses provided id prop', () => {
|
||||||
render(<Input id="my-input" label="Email" />)
|
render(<Input id="my-input" label="Email" />);
|
||||||
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input')
|
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('styling', () => {
|
describe('styling', () => {
|
||||||
it('has brutal-border class', () => {
|
it('has brutal-border class', () => {
|
||||||
render(<Input />)
|
render(<Input />);
|
||||||
expect(screen.getByRole('textbox')).toHaveClass('brutal-border')
|
expect(screen.getByRole('textbox')).toHaveClass('brutal-border');
|
||||||
})
|
});
|
||||||
it('applies custom className', () => {
|
it('applies custom className', () => {
|
||||||
render(<Input className="w-full" />)
|
render(<Input className="w-full" />);
|
||||||
expect(screen.getByRole('textbox')).toHaveClass('w-full')
|
expect(screen.getByRole('textbox')).toHaveClass('w-full');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('forwarded props', () => {
|
describe('forwarded props', () => {
|
||||||
it('passes placeholder to input', () => {
|
it('passes placeholder to input', () => {
|
||||||
render(<Input placeholder="Enter email" />)
|
render(<Input placeholder="Enter email" />);
|
||||||
expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument()
|
expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('passes type to input', () => {
|
it('passes type to input', () => {
|
||||||
render(<Input type="email" />)
|
render(<Input type="email" />);
|
||||||
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email')
|
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('Textarea', () => {
|
describe('Textarea', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders a textarea element', () => {
|
it('renders a textarea element', () => {
|
||||||
render(<Textarea />)
|
render(<Textarea />);
|
||||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders label when provided', () => {
|
it('renders label when provided', () => {
|
||||||
render(<Textarea label="Message" />)
|
render(<Textarea label="Message" />);
|
||||||
expect(screen.getByText('Message')).toBeInTheDocument()
|
expect(screen.getByText('Message')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders error when provided', () => {
|
it('renders error when provided', () => {
|
||||||
render(<Textarea error="Too short" />)
|
render(<Textarea error="Too short" />);
|
||||||
expect(screen.getByText('Too short')).toBeInTheDocument()
|
expect(screen.getByText('Too short')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('defaults to 4 rows', () => {
|
it('defaults to 4 rows', () => {
|
||||||
render(<Textarea />)
|
render(<Textarea />);
|
||||||
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4')
|
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4');
|
||||||
})
|
});
|
||||||
it('accepts custom rows', () => {
|
it('accepts custom rows', () => {
|
||||||
render(<Textarea rows={8} />)
|
render(<Textarea rows={8} />);
|
||||||
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8')
|
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
describe('accessibility', () => {
|
describe('accessibility', () => {
|
||||||
it('label is associated with textarea via htmlFor/id', () => {
|
it('label is associated with textarea via htmlFor/id', () => {
|
||||||
render(<Textarea label="Message" />)
|
render(<Textarea label="Message" />);
|
||||||
expect(screen.getByLabelText('Message')).toBeInTheDocument()
|
expect(screen.getByLabelText('Message')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('error span is referenced by aria-describedby', () => {
|
it('error span is referenced by aria-describedby', () => {
|
||||||
render(<Textarea error="Too short" />)
|
render(<Textarea error="Too short" />);
|
||||||
const textarea = screen.getByRole('textbox')
|
const textarea = screen.getByRole('textbox');
|
||||||
const errorId = textarea.getAttribute('aria-describedby')
|
const errorId = textarea.getAttribute('aria-describedby');
|
||||||
expect(errorId).toBeTruthy()
|
expect(errorId).toBeTruthy();
|
||||||
expect(document.getElementById(errorId!)).toHaveTextContent('Too short')
|
expect(document.getElementById(errorId as string)).toHaveTextContent('Too short');
|
||||||
})
|
});
|
||||||
it('no aria-describedby when no error', () => {
|
it('no aria-describedby when no error', () => {
|
||||||
render(<Textarea />)
|
render(<Textarea />);
|
||||||
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
|
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,68 +1,81 @@
|
|||||||
import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
|
import { type InputHTMLAttributes, type TextareaHTMLAttributes, useId } from 'react';
|
||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
/**
|
/**
|
||||||
* Visible label rendered above the input
|
* Visible label rendered above the input
|
||||||
*/
|
*/
|
||||||
label?: string
|
label?: string;
|
||||||
/**
|
/**
|
||||||
* Validation error shown below the input
|
* Validation error shown below the input
|
||||||
*/
|
*/
|
||||||
error?: string
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INPUT_BASE = 'brutal-border bg-white px-4 py-3 text-carbon-black focus:outline-none focus:ring-2 focus:ring-burnt-oxide focus:ring-offset-2 focus:ring-offset-ochre-clay transition-all'
|
const INPUT_BASE =
|
||||||
|
'brutal-border bg-cream px-4 py-3 text-blue focus:outline-none focus:ring-2 focus:ring-blue focus:ring-offset-2 focus:ring-offset-cream transition-all';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Text input with optional label and error state.
|
* Text input with optional label and error state.
|
||||||
*/
|
*/
|
||||||
export function Input({ label, error, className, id, ...props }: InputProps) {
|
export function Input({ label, error, className, id, ...props }: InputProps) {
|
||||||
const generatedId = useId()
|
const generatedId = useId();
|
||||||
const inputId = id ?? generatedId
|
const inputId = id ?? generatedId;
|
||||||
const errorId = `${inputId}-error`
|
const errorId = `${inputId}-error`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{label && <label htmlFor={inputId} className="text-carbon-black">{label}</label>}
|
{label && (
|
||||||
|
<label htmlFor={inputId} className="text-blue">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
className={cn(INPUT_BASE, className)}
|
className={cn(INPUT_BASE, className)}
|
||||||
aria-describedby={error ? errorId : undefined}
|
aria-describedby={error ? errorId : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && <span id={errorId} className="text-sm text-burnt-oxide">{error}</span>}
|
{error && (
|
||||||
|
<span id={errorId} className="text-sm text-blue">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
/**
|
/**
|
||||||
* Visible label rendered above the textarea
|
* Visible label rendered above the textarea
|
||||||
*/
|
*/
|
||||||
label?: string
|
label?: string;
|
||||||
/**
|
/**
|
||||||
* Validation error shown below the textarea
|
* Validation error shown below the textarea
|
||||||
*/
|
*/
|
||||||
error?: string
|
error?: string;
|
||||||
/**
|
/**
|
||||||
* Number of visible rows
|
* Number of visible rows
|
||||||
* @default 4
|
* @default 4
|
||||||
*/
|
*/
|
||||||
rows?: number
|
rows?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multiline textarea with optional label and error state.
|
* Multiline textarea with optional label and error state.
|
||||||
*/
|
*/
|
||||||
export function Textarea({ label, error, rows = 4, className, id, ...props }: TextareaProps) {
|
export function Textarea({ label, error, rows = 4, className, id, ...props }: TextareaProps) {
|
||||||
const generatedId = useId()
|
const generatedId = useId();
|
||||||
const textareaId = id ?? generatedId
|
const textareaId = id ?? generatedId;
|
||||||
const errorId = `${textareaId}-error`
|
const errorId = `${textareaId}-error`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{label && <label htmlFor={textareaId} className="text-carbon-black">{label}</label>}
|
{label && (
|
||||||
|
<label htmlFor={textareaId} className="text-blue">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
id={textareaId}
|
id={textareaId}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -70,7 +83,11 @@ export function Textarea({ label, error, rows = 4, className, id, ...props }: Te
|
|||||||
aria-describedby={error ? errorId : undefined}
|
aria-describedby={error ? errorId : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && <span id={errorId} className="text-sm text-burnt-oxide">{error}</span>}
|
{error && (
|
||||||
|
<span id={errorId} className="text-sm text-blue">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Link } from './ui/Link/Link';
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
|
import { Link } from './Link';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Link> = {
|
||||||
|
title: 'Shared/Link',
|
||||||
|
component: Link,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Link>;
|
||||||
|
|
||||||
|
export const Internal: Story = {
|
||||||
|
args: {
|
||||||
|
href: '/about',
|
||||||
|
children: 'Internal page',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div className="p-8 bg-cream">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const External: Story = {
|
||||||
|
args: {
|
||||||
|
href: 'https://example.com',
|
||||||
|
external: true,
|
||||||
|
children: 'External site',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div className="p-8 bg-cream">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
vi.mock('next/link', () => ({
|
||||||
|
default: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => (
|
||||||
|
<a href={href} className={className}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import type React from 'react';
|
||||||
|
import { Link } from './Link';
|
||||||
|
|
||||||
|
const BASE = 'underline underline-offset-2 hover:opacity-60 transition-opacity';
|
||||||
|
|
||||||
|
describe('internal link', () => {
|
||||||
|
it('renders an anchor element', () => {
|
||||||
|
render(<Link href="/about">About</Link>);
|
||||||
|
expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct href', () => {
|
||||||
|
render(<Link href="/about">About</Link>);
|
||||||
|
expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not have target attribute', () => {
|
||||||
|
render(<Link href="/about">About</Link>);
|
||||||
|
expect(screen.getByRole('link', { name: 'About' })).not.toHaveAttribute('target');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies base classes', () => {
|
||||||
|
render(<Link href="/about">About</Link>);
|
||||||
|
const link = screen.getByRole('link', { name: 'About' });
|
||||||
|
for (const cls of BASE.split(' ')) {
|
||||||
|
expect(link).toHaveClass(cls);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('external link', () => {
|
||||||
|
it('has target="_blank"', () => {
|
||||||
|
render(
|
||||||
|
<Link href="https://example.com" external>
|
||||||
|
External
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('target', '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has rel="noopener noreferrer"', () => {
|
||||||
|
render(
|
||||||
|
<Link href="https://example.com" external>
|
||||||
|
External
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct href', () => {
|
||||||
|
render(
|
||||||
|
<Link href="https://example.com" external>
|
||||||
|
External
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('href', 'https://example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('className passthrough', () => {
|
||||||
|
it('merges custom className with base classes', () => {
|
||||||
|
render(
|
||||||
|
<Link href="/about" className="text-red-500">
|
||||||
|
Styled
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
const link = screen.getByRole('link', { name: 'Styled' });
|
||||||
|
expect(link).toHaveClass('text-red-500');
|
||||||
|
for (const cls of BASE.split(' ')) {
|
||||||
|
expect(link).toHaveClass(cls);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import NextLink from 'next/link';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for Link.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Destination URL. Use a path (e.g. /about) for internal routes, or a full URL for external.
|
||||||
|
*/
|
||||||
|
href: string;
|
||||||
|
/**
|
||||||
|
* Link content
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* CSS classes
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* When true, renders a plain <a> with target="_blank" rel="noopener noreferrer".
|
||||||
|
* Use for links that open outside the app.
|
||||||
|
*/
|
||||||
|
external?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = 'underline underline-offset-2 hover:opacity-60 transition-opacity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline text link.
|
||||||
|
* Renders as Next.js Link for internal routes, plain <a> for external links.
|
||||||
|
*/
|
||||||
|
export function Link({ href, children, className, external }: Props) {
|
||||||
|
if (external) {
|
||||||
|
return (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" className={cn(BASE, className)}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NextLink href={href} className={cn(BASE, className)}>
|
||||||
|
{children}
|
||||||
|
</NextLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { RichText } from './ui/RichText';
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { RichText } from './RichText';
|
||||||
|
|
||||||
|
describe('RichText', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders a paragraph from <p> tag', () => {
|
||||||
|
render(<RichText html="<p>Hello world</p>" />);
|
||||||
|
expect(screen.getByText('Hello world').tagName).toBe('P');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders bold text from <strong> tag', () => {
|
||||||
|
render(<RichText html="<strong>Bold</strong>" />);
|
||||||
|
expect(screen.getByText('Bold').tagName).toBe('STRONG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a link from <a> tag', () => {
|
||||||
|
render(<RichText html='<a href="https://example.com">Link</a>' />);
|
||||||
|
const link = screen.getByRole('link', { name: 'Link' });
|
||||||
|
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nested tags', () => {
|
||||||
|
render(<RichText html="<p>Text with <em>emphasis</em></p>" />);
|
||||||
|
expect(screen.getByText('emphasis').tagName).toBe('EM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing for empty string', () => {
|
||||||
|
const { container } = render(<RichText html="" />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple sibling elements', () => {
|
||||||
|
render(<RichText html="<p>First</p><p>Second</p>" />);
|
||||||
|
expect(screen.getByText('First')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Second')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('className passthrough', () => {
|
||||||
|
it('applies className to the wrapper', () => {
|
||||||
|
const { container } = render(<RichText html="<p>text</p>" className="prose" />);
|
||||||
|
expect(container.firstChild).toHaveClass('prose');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import parse from 'html-react-parser';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* HTML string from PocketBase rich-text editor
|
||||||
|
*/
|
||||||
|
html: string;
|
||||||
|
/**
|
||||||
|
* Additional CSS classes merged onto the wrapper div
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a PocketBase rich-text HTML string as React elements.
|
||||||
|
* Always applies editorial magazine typography via the rich-text CSS class.
|
||||||
|
*/
|
||||||
|
export function RichText({ html, className }: Props) {
|
||||||
|
if (!html) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn('rich-text', className)}>{parse(html)}</div>;
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export { Section, Container } from './ui/Section'
|
export type { ContainerSize, SectionBackground } from './ui/Section';
|
||||||
export type { SectionBackground, ContainerSize } from './ui/Section'
|
export { Container, Section } from './ui/Section';
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { Section, Container } from './Section'
|
import { Container, Section } from './Section';
|
||||||
|
|
||||||
const meta: Meta<typeof Section> = {
|
const meta: Meta<typeof Section> = {
|
||||||
title: 'Shared/Section',
|
title: 'Shared/Section',
|
||||||
component: Section,
|
component: Section,
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof Section>
|
type Story = StoryObj<typeof Section>;
|
||||||
|
|
||||||
export const AllBackgrounds: Story = {
|
export const AllBackgrounds: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
@@ -16,32 +16,44 @@ export const AllBackgrounds: Story = {
|
|||||||
<Section background="ochre" className="py-12">
|
<Section background="ochre" className="py-12">
|
||||||
<Container>
|
<Container>
|
||||||
<h2>Ochre Section</h2>
|
<h2>Ochre Section</h2>
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||||
|
dolore magna aliqua.
|
||||||
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
<Section background="slate" className="py-12">
|
<Section background="slate" className="py-12">
|
||||||
<Container>
|
<Container>
|
||||||
<h2>Slate Section</h2>
|
<h2>Slate Section</h2>
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||||
|
dolore magna aliqua.
|
||||||
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
<Section background="white" className="py-12">
|
<Section background="white" className="py-12">
|
||||||
<Container>
|
<Container>
|
||||||
<h2>White Section</h2>
|
<h2>White Section</h2>
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||||
|
dolore magna aliqua.
|
||||||
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Bordered: Story = {
|
export const Bordered: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<Section background="ochre" bordered className="py-12">
|
<Section background="ochre" bordered className="py-12">
|
||||||
<Container>
|
<Container>
|
||||||
<h2>Bordered Section</h2>
|
<h2>Bordered Section</h2>
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore
|
||||||
|
magna aliqua.
|
||||||
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,95 +1,98 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { Container, Section } from './Section';
|
||||||
import { Section, Container } from './Section'
|
|
||||||
|
|
||||||
describe('Section', () => {
|
describe('Section', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders a section element', () => {
|
it('renders a section element', () => {
|
||||||
const { container } = render(<Section>content</Section>)
|
const { container } = render(<Section>content</Section>);
|
||||||
expect(container.querySelector('section')).toBeInTheDocument()
|
expect(container.querySelector('section')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders children', () => {
|
it('renders children', () => {
|
||||||
render(<Section><span>hello</span></Section>)
|
render(
|
||||||
expect(screen.getByText('hello')).toBeInTheDocument()
|
<Section>
|
||||||
})
|
<span>hello</span>
|
||||||
})
|
</Section>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('hello')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('background variants', () => {
|
describe('background variants', () => {
|
||||||
it('defaults to ochre background', () => {
|
it('defaults to cream background', () => {
|
||||||
const { container } = render(<Section>x</Section>)
|
const { container } = render(<Section>x</Section>);
|
||||||
expect(container.querySelector('section')).toHaveClass('bg-ochre-clay', 'text-carbon-black')
|
expect(container.querySelector('section')).toHaveClass('bg-cream', 'text-blue');
|
||||||
})
|
});
|
||||||
it('applies slate background', () => {
|
it('applies blue background', () => {
|
||||||
const { container } = render(<Section background="slate">x</Section>)
|
const { container } = render(<Section background="blue">x</Section>);
|
||||||
expect(container.querySelector('section')).toHaveClass('bg-slate-indigo', 'text-ochre-clay')
|
expect(container.querySelector('section')).toHaveClass('bg-blue', 'text-cream');
|
||||||
})
|
});
|
||||||
it('applies white background', () => {
|
});
|
||||||
const { container } = render(<Section background="white">x</Section>)
|
|
||||||
expect(container.querySelector('section')).toHaveClass('bg-white', 'text-carbon-black')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('bordered', () => {
|
describe('bordered', () => {
|
||||||
it('no border classes by default', () => {
|
it('no border classes by default', () => {
|
||||||
const { container } = render(<Section>x</Section>)
|
const { container } = render(<Section>x</Section>);
|
||||||
const el = container.querySelector('section')!
|
const el = container.querySelector('section') as HTMLElement;
|
||||||
expect(el).not.toHaveClass('brutal-border-top')
|
expect(el).not.toHaveClass('brutal-border-top');
|
||||||
expect(el).not.toHaveClass('brutal-border-bottom')
|
expect(el).not.toHaveClass('brutal-border-bottom');
|
||||||
})
|
});
|
||||||
it('adds top and bottom borders when bordered=true', () => {
|
it('adds top and bottom borders when bordered=true', () => {
|
||||||
const { container } = render(<Section bordered>x</Section>)
|
const { container } = render(<Section bordered>x</Section>);
|
||||||
const el = container.querySelector('section')!
|
const el = container.querySelector('section') as HTMLElement;
|
||||||
expect(el).toHaveClass('brutal-border-top')
|
expect(el).toHaveClass('brutal-border-top');
|
||||||
expect(el).toHaveClass('brutal-border-bottom')
|
expect(el).toHaveClass('brutal-border-bottom');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('className', () => {
|
describe('className', () => {
|
||||||
it('applies custom className', () => {
|
it('applies custom className', () => {
|
||||||
const { container } = render(<Section className="py-16">x</Section>)
|
const { container } = render(<Section className="py-16">x</Section>);
|
||||||
expect(container.querySelector('section')).toHaveClass('py-16')
|
expect(container.querySelector('section')).toHaveClass('py-16');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Container', () => {
|
describe('Container', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders a div with children', () => {
|
it('renders a div with children', () => {
|
||||||
render(<Container><span>inner</span></Container>)
|
render(
|
||||||
expect(screen.getByText('inner')).toBeInTheDocument()
|
<Container>
|
||||||
})
|
<span>inner</span>
|
||||||
})
|
</Container>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('inner')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('size variants', () => {
|
describe('size variants', () => {
|
||||||
it('defaults to max-w-7xl', () => {
|
it('defaults to max-w-7xl', () => {
|
||||||
const { container } = render(<Container>x</Container>)
|
const { container } = render(<Container>x</Container>);
|
||||||
expect(container.firstChild).toHaveClass('max-w-7xl')
|
expect(container.firstChild).toHaveClass('max-w-7xl');
|
||||||
})
|
});
|
||||||
it('wide applies max-w-[1920px]', () => {
|
it('wide applies max-w-[1920px]', () => {
|
||||||
const { container } = render(<Container size="wide">x</Container>)
|
const { container } = render(<Container size="wide">x</Container>);
|
||||||
expect(container.firstChild).toHaveClass('max-w-[1920px]')
|
expect(container.firstChild).toHaveClass('max-w-[1920px]');
|
||||||
})
|
});
|
||||||
it('ultra-wide applies max-w-[2560px]', () => {
|
it('ultra-wide applies max-w-[2560px]', () => {
|
||||||
const { container } = render(<Container size="ultra-wide">x</Container>)
|
const { container } = render(<Container size="ultra-wide">x</Container>);
|
||||||
expect(container.firstChild).toHaveClass('max-w-[2560px]')
|
expect(container.firstChild).toHaveClass('max-w-[2560px]');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('layout', () => {
|
describe('layout', () => {
|
||||||
it('centers content horizontally', () => {
|
it('centers content horizontally', () => {
|
||||||
const { container } = render(<Container>x</Container>)
|
const { container } = render(<Container>x</Container>);
|
||||||
expect(container.firstChild).toHaveClass('mx-auto')
|
expect(container.firstChild).toHaveClass('mx-auto');
|
||||||
})
|
});
|
||||||
it('applies horizontal padding', () => {
|
it('applies horizontal padding', () => {
|
||||||
const { container } = render(<Container>x</Container>)
|
const { container } = render(<Container>x</Container>);
|
||||||
expect(container.firstChild).toHaveClass('px-6')
|
expect(container.firstChild).toHaveClass('px-6');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('className', () => {
|
describe('className', () => {
|
||||||
it('applies custom className', () => {
|
it('applies custom className', () => {
|
||||||
const { container } = render(<Container className="my-custom">x</Container>)
|
const { container } = render(<Container className="my-custom">x</Container>);
|
||||||
expect(container.firstChild).toHaveClass('my-custom')
|
expect(container.firstChild).toHaveClass('my-custom');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,82 +1,71 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react';
|
||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
export type SectionBackground = 'ochre' | 'slate' | 'white'
|
export type SectionBackground = 'cream' | 'blue';
|
||||||
export type ContainerSize = 'default' | 'wide' | 'ultra-wide'
|
export type ContainerSize = 'default' | 'wide' | 'ultra-wide';
|
||||||
|
|
||||||
interface SectionProps {
|
interface SectionProps {
|
||||||
/**
|
/**
|
||||||
* Section content
|
* Section content
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Background color variant
|
* Background color variant
|
||||||
* @default 'ochre'
|
* @default 'cream'
|
||||||
*/
|
*/
|
||||||
background?: SectionBackground
|
background?: SectionBackground;
|
||||||
/**
|
/**
|
||||||
* Adds top and bottom brutal borders
|
* Adds top and bottom brutal borders
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
bordered?: boolean
|
bordered?: boolean;
|
||||||
/**
|
/**
|
||||||
* CSS classes
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BACKGROUNDS: Record<SectionBackground, string> = {
|
const BACKGROUNDS: Record<SectionBackground, string> = {
|
||||||
ochre: 'bg-ochre-clay text-carbon-black',
|
cream: 'bg-cream text-blue',
|
||||||
slate: 'bg-slate-indigo text-ochre-clay',
|
blue: 'bg-blue text-cream',
|
||||||
white: 'bg-white text-carbon-black',
|
};
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-width page section with background and optional borders.
|
* Full-width page section with background and optional borders.
|
||||||
*/
|
*/
|
||||||
export function Section({ children, background = 'ochre', bordered = false, className }: SectionProps) {
|
export function Section({ children, background = 'cream', bordered = false, className }: SectionProps) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section className={cn(BACKGROUNDS[background], bordered && 'brutal-border-top brutal-border-bottom', className)}>
|
||||||
className={cn(
|
|
||||||
BACKGROUNDS[background],
|
|
||||||
bordered && 'brutal-border-top brutal-border-bottom',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContainerProps {
|
interface ContainerProps {
|
||||||
/**
|
/**
|
||||||
* Container content
|
* Container content
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Max-width constraint
|
* Max-width constraint
|
||||||
* @default 'default'
|
* @default 'default'
|
||||||
*/
|
*/
|
||||||
size?: ContainerSize
|
size?: ContainerSize;
|
||||||
/**
|
/**
|
||||||
* CSS classes
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZES: Record<ContainerSize, string> = {
|
const SIZES: Record<ContainerSize, string> = {
|
||||||
'default': 'max-w-7xl',
|
default: 'max-w-7xl',
|
||||||
'wide': 'max-w-[1920px]',
|
wide: 'max-w-[1920px]',
|
||||||
'ultra-wide': 'max-w-[2560px]',
|
'ultra-wide': 'max-w-[2560px]',
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centered content container with responsive horizontal padding.
|
* Centered content container with responsive horizontal padding.
|
||||||
*/
|
*/
|
||||||
export function Container({ children, size = 'default', className }: ContainerProps) {
|
export function Container({ children, size = 'default', className }: ContainerProps) {
|
||||||
return (
|
return <div className={cn(SIZES[size], 'mx-auto px-6 md:px-12 lg:px-16', className)}>{children}</div>;
|
||||||
<div className={cn(SIZES[size], 'mx-auto px-6 md:px-12 lg:px-16', className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { SectionAccordion } from './ui/SectionAccordion'
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1 +1 @@
|
|||||||
export { TechStackBrick, TechStackGrid } from './ui/TechStack'
|
export { TechStackBrick, TechStackGrid } from './ui/TechStack';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
import { TechStackGrid, TechStackBrick } from './TechStack'
|
import { TechStackBrick, TechStackGrid } from './TechStack';
|
||||||
|
|
||||||
const meta: Meta<typeof TechStackGrid> = {
|
const meta: Meta<typeof TechStackGrid> = {
|
||||||
title: 'Shared/TechStack',
|
title: 'Shared/TechStack',
|
||||||
@@ -11,11 +11,11 @@ const meta: Meta<typeof TechStackGrid> = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default meta
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof TechStackGrid>
|
type Story = StoryObj<typeof TechStackGrid>;
|
||||||
|
|
||||||
export const Grid: Story = {
|
export const Grid: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -34,7 +34,7 @@ export const Grid: Story = {
|
|||||||
'Rust',
|
'Rust',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const SingleBrick: Story = {
|
export const SingleBrick: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
@@ -42,4 +42,4 @@ export const SingleBrick: Story = {
|
|||||||
<TechStackBrick name="TypeScript" />
|
<TechStackBrick name="TypeScript" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,62 +1,61 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { render, screen } from '@testing-library/react';
|
||||||
import { render, screen } from '@testing-library/react'
|
import { TechStackBrick, TechStackGrid } from './TechStack';
|
||||||
import { TechStackBrick, TechStackGrid } from './TechStack'
|
|
||||||
|
|
||||||
describe('TechStackBrick', () => {
|
describe('TechStackBrick', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders the technology name', () => {
|
it('renders the technology name', () => {
|
||||||
render(<TechStackBrick name="TypeScript" />)
|
render(<TechStackBrick name="TypeScript" />);
|
||||||
expect(screen.getByText('TypeScript')).toBeInTheDocument()
|
expect(screen.getByText('TypeScript')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('styling', () => {
|
describe('styling', () => {
|
||||||
it('has brutal-border class', () => {
|
it('has brutal-border class', () => {
|
||||||
const { container } = render(<TechStackBrick name="React" />)
|
const { container } = render(<TechStackBrick name="React" />);
|
||||||
expect(container.firstChild).toHaveClass('brutal-border')
|
expect(container.firstChild).toHaveClass('brutal-border');
|
||||||
})
|
});
|
||||||
it('has brutal-shadow class', () => {
|
it('has brutal-shadow class', () => {
|
||||||
const { container } = render(<TechStackBrick name="React" />)
|
const { container } = render(<TechStackBrick name="React" />);
|
||||||
expect(container.firstChild).toHaveClass('brutal-shadow')
|
expect(container.firstChild).toHaveClass('brutal-shadow');
|
||||||
})
|
});
|
||||||
it('name span has uppercase and tracking-wide', () => {
|
it('name span has uppercase and tracking-wide', () => {
|
||||||
render(<TechStackBrick name="Go" />)
|
render(<TechStackBrick name="Go" />);
|
||||||
const span = screen.getByText('Go')
|
const span = screen.getByText('Go');
|
||||||
expect(span).toHaveClass('uppercase', 'tracking-wide')
|
expect(span).toHaveClass('uppercase', 'tracking-wide');
|
||||||
})
|
});
|
||||||
it('applies custom className', () => {
|
it('applies custom className', () => {
|
||||||
const { container } = render(<TechStackBrick name="Go" className="w-full" />)
|
const { container } = render(<TechStackBrick name="Go" className="w-full" />);
|
||||||
expect(container.firstChild).toHaveClass('w-full')
|
expect(container.firstChild).toHaveClass('w-full');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('TechStackGrid', () => {
|
describe('TechStackGrid', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders all skill names', () => {
|
it('renders all skill names', () => {
|
||||||
render(<TechStackGrid skills={['React', 'TypeScript', 'Go']} />)
|
render(<TechStackGrid skills={['React', 'TypeScript', 'Go']} />);
|
||||||
expect(screen.getByText('React')).toBeInTheDocument()
|
expect(screen.getByText('React')).toBeInTheDocument();
|
||||||
expect(screen.getByText('TypeScript')).toBeInTheDocument()
|
expect(screen.getByText('TypeScript')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Go')).toBeInTheDocument()
|
expect(screen.getByText('Go')).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
it('renders correct number of bricks', () => {
|
it('renders correct number of bricks', () => {
|
||||||
const { container } = render(<TechStackGrid skills={['A', 'B', 'C']} />)
|
const { container } = render(<TechStackGrid skills={['A', 'B', 'C']} />);
|
||||||
expect(container.firstChild!.childNodes).toHaveLength(3)
|
expect(container.firstChild?.childNodes).toHaveLength(3);
|
||||||
})
|
});
|
||||||
it('renders empty grid with no skills', () => {
|
it('renders empty grid with no skills', () => {
|
||||||
const { container } = render(<TechStackGrid skills={[]} />)
|
const { container } = render(<TechStackGrid skills={[]} />);
|
||||||
expect(container.firstChild!.childNodes).toHaveLength(0)
|
expect(container.firstChild?.childNodes).toHaveLength(0);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('layout', () => {
|
describe('layout', () => {
|
||||||
it('has grid class', () => {
|
it('has grid class', () => {
|
||||||
const { container } = render(<TechStackGrid skills={['A']} />)
|
const { container } = render(<TechStackGrid skills={['A']} />);
|
||||||
expect(container.firstChild).toHaveClass('grid')
|
expect(container.firstChild).toHaveClass('grid');
|
||||||
})
|
});
|
||||||
it('applies custom className', () => {
|
it('applies custom className', () => {
|
||||||
const { container } = render(<TechStackGrid skills={[]} className="my-custom" />)
|
const { container } = render(<TechStackGrid skills={[]} className="my-custom" />);
|
||||||
expect(container.firstChild).toHaveClass('my-custom')
|
expect(container.firstChild).toHaveClass('my-custom');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { cn } from '$shared/lib'
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
interface TechStackBrickProps {
|
interface TechStackBrickProps {
|
||||||
/**
|
/**
|
||||||
* Technology name displayed in the brick
|
* Technology name displayed in the brick
|
||||||
*/
|
*/
|
||||||
name: string
|
name: string;
|
||||||
/**
|
/**
|
||||||
* CSS classes
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,25 +18,25 @@ export function TechStackBrick({ name, className }: TechStackBrickProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'brutal-border brutal-shadow bg-white px-4 py-3 text-center',
|
'brutal-border brutal-shadow bg-cream px-4 py-3 text-center',
|
||||||
'transition-all duration-200 hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px]',
|
'transition-all duration-200 hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-sm uppercase tracking-wide">{name}</span>
|
<span className="text-sm uppercase tracking-wide">{name}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TechStackGridProps {
|
interface TechStackGridProps {
|
||||||
/**
|
/**
|
||||||
* List of technology names to render as bricks
|
* List of technology names to render as bricks
|
||||||
*/
|
*/
|
||||||
skills: string[]
|
skills: string[];
|
||||||
/**
|
/**
|
||||||
* CSS classes
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,14 +45,11 @@ interface TechStackGridProps {
|
|||||||
export function TechStackGrid({ skills, className }: TechStackGridProps) {
|
export function TechStackGrid({ skills, className }: TechStackGridProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn('grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4', className)}
|
||||||
'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{skills.map((skill, index) => (
|
{skills.map((skill) => (
|
||||||
<TechStackBrick key={index} name={skill} />
|
<TechStackBrick key={skill} name={skill} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ViewTransitionWrapper } from './ui/ViewTransitionWrapper';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ViewTransitionWrapper } from './ViewTransitionWrapper';
|
||||||
|
|
||||||
|
describe('ViewTransitionWrapper', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(
|
||||||
|
<ViewTransitionWrapper name="test">
|
||||||
|
<p>Hello</p>
|
||||||
|
</ViewTransitionWrapper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple children', () => {
|
||||||
|
render(
|
||||||
|
<ViewTransitionWrapper name="test">
|
||||||
|
<p>First</p>
|
||||||
|
<p>Second</p>
|
||||||
|
</ViewTransitionWrapper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('First')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Second')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add an extra wrapper DOM element', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ViewTransitionWrapper name="test">
|
||||||
|
<p>Content</p>
|
||||||
|
</ViewTransitionWrapper>,
|
||||||
|
);
|
||||||
|
expect(container.firstChild?.nodeName).toBe('P');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Fragment, type ReactNode, ViewTransition as VT } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VT is undefined in stable react-dom (test env / non-experimental builds).
|
||||||
|
* Fall back to Fragment so children render and the name prop is silently ignored.
|
||||||
|
*/
|
||||||
|
const Transition = (VT ?? Fragment) as typeof VT;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* Maps to the view-transition-name CSS property
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Content to animate
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps children in React's ViewTransition when available,
|
||||||
|
* falling back to a Fragment in environments where ViewTransition is undefined.
|
||||||
|
*/
|
||||||
|
export function ViewTransitionWrapper({ name, children }: Props) {
|
||||||
|
return <Transition name={name}>{children}</Transition>;
|
||||||
|
}
|
||||||
+13
-16
@@ -1,17 +1,14 @@
|
|||||||
export { Badge } from './Badge'
|
export type { BadgeSize, BadgeVariant } from './Badge';
|
||||||
export type { BadgeVariant } from './Badge'
|
export { Badge } from './Badge';
|
||||||
|
export type { ButtonSize, ButtonVariant } from './Button';
|
||||||
|
export { Button } from './Button';
|
||||||
|
export type { CardBackground } from './Card';
|
||||||
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
|
||||||
|
|
||||||
export { Button } from './Button'
|
export { Input, Textarea } from './Input';
|
||||||
export type { ButtonVariant, ButtonSize } from './Button'
|
export { Link } from './Link';
|
||||||
|
export { RichText } from './RichText';
|
||||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
export type { ContainerSize, SectionBackground } from './Section';
|
||||||
export type { CardBackground } from './Card'
|
export { Container, Section } from './Section';
|
||||||
|
export { TechStackBrick, TechStackGrid } from './TechStack';
|
||||||
export { Input, Textarea } from './Input'
|
export { ViewTransitionWrapper } from './ViewTransitionWrapper';
|
||||||
|
|
||||||
export { Section, Container } from './Section'
|
|
||||||
export type { SectionBackground, ContainerSize } from './Section'
|
|
||||||
|
|
||||||
export { SectionAccordion } from './SectionAccordion'
|
|
||||||
|
|
||||||
export { TechStackBrick, TechStackGrid } from './TechStack'
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
const mockFont = () => ({
|
||||||
|
variable: '--font-mock',
|
||||||
|
className: 'mock-font',
|
||||||
|
style: { fontFamily: 'mock-font' },
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('next/font/google', () => ({
|
||||||
|
Fraunces: mockFont,
|
||||||
|
Public_Sans: mockFont,
|
||||||
|
}));
|
||||||
+2
-1
@@ -1 +1,2 @@
|
|||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom';
|
||||||
|
import './__mocks__/next-font-google';
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import type { PageContentRecord } from '$shared/api';
|
||||||
|
import { getFirstRecord } from '$shared/api';
|
||||||
|
import { RichText } from '$shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bio section component.
|
||||||
|
* Displays personal biography content from PocketBase.
|
||||||
|
*/
|
||||||
|
export default async function BioSection() {
|
||||||
|
const data = await getFirstRecord<PageContentRecord>('bio', { tags: ['bio'] });
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RichText html={data.content} />;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user