Compare commits
31 Commits
76f5b269f8
...
5c00f8e8a0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c00f8e8a0 | |||
| cb3bdce24a | |||
| 42ca683c65 | |||
| fea6682024 | |||
| 540df57f8d | |||
| b88263a65a | |||
| 06e39b58c6 | |||
| ac9ee0eb4e | |||
| 2ae5ae3210 | |||
| f159c6e861 | |||
| b33b9f328c | |||
| c9631f9905 | |||
| ba7395cb32 | |||
| 7e542597d0 | |||
| 0552a2a8e5 | |||
| d955aeb628 | |||
| b40ff4f588 | |||
| 531de6899e | |||
| 10034ec561 | |||
| 458ee0e449 | |||
| 979e2071d1 | |||
| 37098be3c8 | |||
| 48a08ec3fb | |||
| 1550989fd9 | |||
| 782c619a91 | |||
| 543020f85c | |||
| e00c1460e1 | |||
| f874a943ff | |||
| ff62cba5b1 | |||
| f4986d6657 | |||
| e3959c0e45 |
@@ -10,6 +10,7 @@ import { SectionsAccordion } from '$widgets/SectionsAccordion';
|
|||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
||||||
sort: 'order',
|
sort: 'order',
|
||||||
|
tags: ['sections'],
|
||||||
});
|
});
|
||||||
return [{}, ...sections.map((s) => ({ slug: [s.slug] }))];
|
return [{}, ...sections.map((s) => ({ slug: [s.slug] }))];
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,7 @@ export default async function SectionPage({ params }: Props) {
|
|||||||
|
|
||||||
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
||||||
sort: 'order',
|
sort: 'order',
|
||||||
|
tags: ['sections'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sections.length === 0) {
|
if (sections.length === 0) {
|
||||||
|
|||||||
@@ -5,6 +5,74 @@ export const dynamic = 'force-static';
|
|||||||
const base = { created: '', updated: '' };
|
const base = { created: '', updated: '' };
|
||||||
|
|
||||||
const FIXTURES: Record<string, unknown[]> = {
|
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: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
+5
-1
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { fraunces, publicSans } from '$shared/lib';
|
import { fraunces, publicSans } from '$shared/lib';
|
||||||
|
import { Footer } from '$widgets/Footer';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -13,7 +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}`}>{children}</body>
|
<body className={`${fraunces.variable} ${publicSans.variable} flex flex-col min-h-screen`}>
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+14
-6
@@ -1,13 +1,21 @@
|
|||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
/* output: 'export' is opt-in via STATIC_EXPORT=true.
|
/* PocketBase origin — used to allowlist remote images.
|
||||||
* Set this in CI/deploy — not locally — so the mock API route works
|
* PB_HOSTNAME and PB_PORT are server-only env vars; safe to read here. */
|
||||||
* during development and local builds. */
|
const pbHostname = process.env.PB_HOSTNAME ?? '127.0.0.1';
|
||||||
const isExport = process.env.STATIC_EXPORT === 'true';
|
const pbPort = parseInt(process.env.PB_PORT ?? '8090', 10);
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
...(isExport ? { output: 'export' } : {}),
|
output: 'standalone',
|
||||||
images: { unoptimized: true },
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: pbHostname,
|
||||||
|
port: String(pbPort),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
viewTransition: true,
|
viewTransition: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,18 +33,15 @@ interface SectionAccordionProps {
|
|||||||
* Accordion-style section that collapses to a navigation link when inactive.
|
* Accordion-style section that collapses to a navigation link when inactive.
|
||||||
*/
|
*/
|
||||||
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
|
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
|
||||||
|
const heading = `${number}. ${title}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id={id} className="scroll-mt-8">
|
<section id={id} className="scroll-mt-8">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<ViewTransitionWrapper name="section-content">
|
<ViewTransitionWrapper name="section-content">
|
||||||
<div className="mb-16">
|
<div className="mb-16">
|
||||||
<h1
|
<h1 className="font-heading font-black text-section-title leading-[1.2] mb-0">{heading}</h1>
|
||||||
className="font-heading font-black text-5xl leading-[1.2] mb-0"
|
|
||||||
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
|
|
||||||
>
|
|
||||||
{number}. {title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</ViewTransitionWrapper>
|
</ViewTransitionWrapper>
|
||||||
<ViewTransitionWrapper name="section-body">
|
<ViewTransitionWrapper name="section-body">
|
||||||
@@ -52,13 +49,14 @@ export function SectionAccordion({ number, title, id, isActive, href, children }
|
|||||||
</ViewTransitionWrapper>
|
</ViewTransitionWrapper>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Link href={href} className="block w-full text-left mb-3 py-3 group border-b-0 hover:border-b-0">
|
<Link
|
||||||
<h2
|
href={href}
|
||||||
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-60 transition-opacity duration-200"
|
aria-label={heading}
|
||||||
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
|
className="block w-full text-left mb-3 py-3 group border-b-0 hover:border-b-0"
|
||||||
>
|
>
|
||||||
{number}. {title}
|
<span className="block font-heading font-wonk font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-60 transition-opacity duration-200">
|
||||||
</h2>
|
{heading}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const DEFAULT_PROPS = {
|
|||||||
company: 'Acme Corp',
|
company: 'Acme Corp',
|
||||||
period: '2021 – 2024',
|
period: '2021 – 2024',
|
||||||
description: 'Built scalable frontend systems.',
|
description: 'Built scalable frontend systems.',
|
||||||
|
stack: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('ExperienceCard', () => {
|
describe('ExperienceCard', () => {
|
||||||
@@ -31,23 +32,43 @@ describe('ExperienceCard', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('structure', () => {
|
describe('layout', () => {
|
||||||
it('title is rendered as an h4', () => {
|
it('period badge is inside the sidebar column', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
|
||||||
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('period badge has brutal-border, bg-blue, text-cream, text-sm', () => {
|
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const badge = screen.getByText('2021 – 2024');
|
const badge = screen.getByText('2021 – 2024');
|
||||||
expect(badge).toHaveClass('brutal-border', 'bg-blue', 'text-cream', 'text-sm');
|
expect(badge.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('company paragraph has opacity-80', () => {
|
it('company name is inside the sidebar column', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const company = screen.getByText('Acme Corp');
|
const company = screen.getByText('Acme Corp');
|
||||||
expect(company.tagName).toBe('P');
|
expect(company.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
expect(company).toHaveClass('opacity-80');
|
});
|
||||||
|
|
||||||
|
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', () => {
|
it('description renders via RichText with rich-text class', () => {
|
||||||
@@ -56,12 +77,28 @@ describe('ExperienceCard', () => {
|
|||||||
expect(desc.closest('.rich-text')).toBeInTheDocument();
|
expect(desc.closest('.rich-text')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('card has brutal-border class (from Card component)', () => {
|
it('card has brutal-border class', () => {
|
||||||
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />);
|
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
expect(container.firstChild).toHaveClass('brutal-border');
|
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', () => {
|
describe('className passthrough', () => {
|
||||||
it('forwards className to the card', () => {
|
it('forwards className to the card', () => {
|
||||||
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />);
|
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Card, RichText } from '$shared/ui';
|
import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@@ -10,13 +10,17 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
company: string;
|
company: string;
|
||||||
/**
|
/**
|
||||||
* Employment period (e.g. "2021 – 2024")
|
* Employment period (e.g. "Jan 2021 – Dec 2024")
|
||||||
*/
|
*/
|
||||||
period: string;
|
period: string;
|
||||||
/**
|
/**
|
||||||
* Description of responsibilities and achievements
|
* Description of responsibilities and achievements
|
||||||
*/
|
*/
|
||||||
description: string;
|
description: string;
|
||||||
|
/**
|
||||||
|
* Technologies used during this role
|
||||||
|
*/
|
||||||
|
stack: string[];
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes forwarded to the card
|
* Additional CSS classes forwarded to the card
|
||||||
*/
|
*/
|
||||||
@@ -24,19 +28,35 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Work experience card with title, company, period, and description.
|
* 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, className }: Props) {
|
export function ExperienceCard({ title, company, period, description, stack, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between mb-4 gap-4">
|
<CardSidebar
|
||||||
<div className="flex-1 max-w-[700px]">
|
sidebar={
|
||||||
<h4>{title}</h4>
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-base opacity-80">{company}</p>
|
<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>
|
||||||
<span className="brutal-border px-4 py-2 bg-blue text-cream text-sm self-start">{period}</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CardTitle className="font-heading">{title}</CardTitle>
|
||||||
<RichText html={description} />
|
<RichText html={description} />
|
||||||
|
</div>
|
||||||
|
</CardSidebar>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Card } from '$shared/ui';
|
import { Card, RichText } from '$shared/ui';
|
||||||
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
|
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -20,7 +20,7 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
stack: string[];
|
stack: string[];
|
||||||
/**
|
/**
|
||||||
* Project description paragraph
|
* Project description as HTML from the PocketBase rich-text editor
|
||||||
*/
|
*/
|
||||||
description: string;
|
description: string;
|
||||||
/**
|
/**
|
||||||
@@ -51,7 +51,7 @@ export function DetailedProjectCard({ title, year, role, stack, description, det
|
|||||||
<div className="lg:col-span-10 order-1 lg:order-2">
|
<div className="lg:col-span-10 order-1 lg:order-2">
|
||||||
<Card>
|
<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-blue overflow-hidden relative">
|
<div className="brutal-border aspect-video bg-blue overflow-hidden relative">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const DEFAULT_PROPS = {
|
|||||||
year: '2024',
|
year: '2024',
|
||||||
description: 'A cool project description',
|
description: 'A cool project description',
|
||||||
tags: ['React', 'Node'],
|
tags: ['React', 'Node'],
|
||||||
|
url: 'https://example.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('ProjectCard', () => {
|
describe('ProjectCard', () => {
|
||||||
@@ -15,7 +16,7 @@ describe('ProjectCard', () => {
|
|||||||
expect(screen.getByText('My Project')).toBeInTheDocument();
|
expect(screen.getByText('My Project')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the year badge', () => {
|
it('renders the year', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('2024')).toBeInTheDocument();
|
expect(screen.getByText('2024')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -33,27 +34,77 @@ describe('ProjectCard', () => {
|
|||||||
|
|
||||||
it('renders the View Project button', () => {
|
it('renders the View Project button', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument();
|
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', () => {
|
describe('structure', () => {
|
||||||
it('card has hover transition classes', () => {
|
it('card has hover transition classes', () => {
|
||||||
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const card = container.firstChild as HTMLElement;
|
expect(container.firstChild).toHaveClass('group', 'transition-shadow', 'duration-300');
|
||||||
expect(card).toHaveClass('group', 'transition-shadow', 'duration-300');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('year badge has correct classes', () => {
|
it('title renders as h3', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const yearBadge = screen.getByText('2024');
|
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('My Project');
|
||||||
expect(yearBadge).toHaveClass('brutal-border', 'bg-blue', 'text-cream', 'text-sm');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tags have correct classes', () => {
|
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} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const tag = screen.getByText('React');
|
const tag = screen.getByText('React');
|
||||||
expect(tag).toHaveClass('brutal-border', 'bg-cream', 'text-blue', 'text-sm', 'uppercase', 'tracking-wide');
|
expect(tag).toHaveClass('brutal-border', 'bg-transparent', 'px-2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +119,7 @@ describe('ProjectCard', () => {
|
|||||||
expect(screen.getByRole('img')).toBeInTheDocument();
|
expect(screen.getByRole('img')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('image wrapper has aspect-video and overflow-hidden when imageUrl is provided', () => {
|
it('image wrapper has aspect-video and overflow-hidden', () => {
|
||||||
const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
|
||||||
const imgWrapper = container.querySelector('img')?.parentElement;
|
const imgWrapper = container.querySelector('img')?.parentElement;
|
||||||
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
|
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '$shared/ui';
|
import { Badge, Button, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +19,10 @@ type Props = {
|
|||||||
* Technology or category tags
|
* Technology or category tags
|
||||||
*/
|
*/
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
/**
|
||||||
|
* Project's URL
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
/**
|
/**
|
||||||
* Optional preview image URL
|
* Optional preview image URL
|
||||||
*/
|
*/
|
||||||
@@ -26,38 +30,42 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact project card for grid/list display.
|
* 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, imageUrl }: Props) {
|
export function ProjectCard({ title, year, description, tags, url, imageUrl }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
|
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
|
||||||
<CardHeader>
|
<CardSidebar
|
||||||
<div className="flex flex-row justify-between items-start mb-3">
|
sidebar={
|
||||||
<CardTitle className="flex-1">{title}</CardTitle>
|
<div className="flex flex-col gap-4">
|
||||||
<span className="brutal-border px-3 py-1 bg-blue text-cream text-sm">{year}</span>
|
<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>
|
</div>
|
||||||
<CardDescription>{description}</CardDescription>
|
)}
|
||||||
</CardHeader>
|
<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 && (
|
{imageUrl && (
|
||||||
<div className="brutal-border my-6 aspect-video bg-blue overflow-hidden relative">
|
<div className="brutal-border aspect-video bg-blue overflow-hidden relative">
|
||||||
<Image src={imageUrl} alt={title} fill className="object-cover" />
|
<Image src={imageUrl} alt={title} fill className="object-cover" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<RichText html={description} />
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
</div>
|
||||||
{tags.map((tag) => (
|
</CardSidebar>
|
||||||
<span key={tag} className="brutal-border px-3 py-1 bg-cream text-blue text-sm uppercase tracking-wide">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter>
|
|
||||||
<Button variant="primary" className="w-full">
|
|
||||||
View Project
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-11
@@ -4,13 +4,10 @@ import type { ListResponse } from './types';
|
|||||||
* Native fetch wrapper for PocketBase API requests.
|
* Native fetch wrapper for PocketBase API requests.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PB_URL =
|
/* Prefer the server-only var (not exposed to the browser bundle),
|
||||||
process.env.NEXT_PUBLIC_PB_URL ||
|
* fall back to the public var for client-side usage, then to the
|
||||||
(process.env.NODE_ENV === 'production'
|
* local dev default. */
|
||||||
? (() => {
|
const PB_URL = process.env.PB_URL ?? process.env.NEXT_PUBLIC_PB_URL ?? 'http://127.0.0.1:8090';
|
||||||
throw new Error('NEXT_PUBLIC_PB_URL is not set');
|
|
||||||
})()
|
|
||||||
: 'http://127.0.0.1:8090');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for PocketBase collection fetching.
|
* Options for PocketBase collection fetching.
|
||||||
@@ -28,13 +25,23 @@ export type PBFetchOptions = {
|
|||||||
* Fields to expand (e.g., "stack")
|
* Fields to expand (e.g., "stack")
|
||||||
*/
|
*/
|
||||||
expand?: string;
|
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.
|
* Fetch a list of records from a PocketBase collection.
|
||||||
*/
|
*/
|
||||||
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
|
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
|
||||||
const { sort, filter, expand } = options;
|
const { sort, filter, expand, tags, revalidate } = options;
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (sort) {
|
if (sort) {
|
||||||
@@ -49,9 +56,12 @@ export async function getCollection<T>(collection: string, options: PBFetchOptio
|
|||||||
|
|
||||||
const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
|
const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
|
||||||
|
|
||||||
/* force-cache deduplicates identical fetches during the static build phase;
|
const res = await fetch(url, {
|
||||||
* it has no runtime effect in `output: 'export'` mode. */
|
next: {
|
||||||
const res = await fetch(url, { cache: 'force-cache' });
|
tags: tags ?? [],
|
||||||
|
revalidate: revalidate ?? 3600,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`PocketBase ${res.status} ${res.statusText} on collection "${collection}"`);
|
throw new Error(`PocketBase ${res.status} ${res.statusText} on collection "${collection}"`);
|
||||||
|
|||||||
+71
-1
@@ -77,6 +77,10 @@ export type ExperienceRecord = BaseRecord & {
|
|||||||
* Rich text description of responsibilities and achievements
|
* Rich text description of responsibilities and achievements
|
||||||
*/
|
*/
|
||||||
description: string;
|
description: string;
|
||||||
|
/**
|
||||||
|
* Technologies used during this role
|
||||||
|
*/
|
||||||
|
stack: string[];
|
||||||
/**
|
/**
|
||||||
* Sorting weight for chronological display
|
* Sorting weight for chronological display
|
||||||
*/
|
*/
|
||||||
@@ -100,7 +104,7 @@ export type ProjectRecord = BaseRecord & {
|
|||||||
*/
|
*/
|
||||||
role: string;
|
role: string;
|
||||||
/**
|
/**
|
||||||
* Short summary of the project
|
* Project description as HTML from the PocketBase rich-text editor
|
||||||
*/
|
*/
|
||||||
description: string;
|
description: string;
|
||||||
/**
|
/**
|
||||||
@@ -115,12 +119,78 @@ export type ProjectRecord = BaseRecord & {
|
|||||||
* Primary thumbnail or hero image filename
|
* Primary thumbnail or hero image filename
|
||||||
*/
|
*/
|
||||||
image: string;
|
image: string;
|
||||||
|
/**
|
||||||
|
* Project's url
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
/**
|
/**
|
||||||
* Sorting weight for the project list
|
* Sorting weight for the project list
|
||||||
*/
|
*/
|
||||||
order: number;
|
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.
|
* Generic response for a list of PocketBase records.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type { ClassValue } from 'clsx';
|
export type { ClassValue } from 'clsx';
|
||||||
export { CONTACT_LINKS } from './config/config';
|
export { CONTACT_LINKS } from './config/config';
|
||||||
export * from './fonts/fonts';
|
export * from './fonts/fonts';
|
||||||
|
export { buildFileUrl } from './utils/buildFileUrl/buildFileUrl';
|
||||||
export { cn } from './utils/cn/cn';
|
export { cn } from './utils/cn/cn';
|
||||||
export * from './utils/formatDate/formatDate';
|
export * from './utils/formatDate/formatDate';
|
||||||
export { groupByKey } from './utils/groupByKey/groupByKey';
|
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,47 +1,47 @@
|
|||||||
import { formatYearRange } from './formatDate';
|
import { formatMonthYearRange } from './formatDate';
|
||||||
|
|
||||||
describe('formatYearRange', () => {
|
describe('formatMonthYearRange', () => {
|
||||||
describe('Success Paths', () => {
|
describe('open-ended range', () => {
|
||||||
it('formats a date range within the same year', () => {
|
it('formats start date with Present when end is null', () => {
|
||||||
const start = '2024-01-01 12:00:00.000Z';
|
expect(formatMonthYearRange('2022-01-01T00:00:00Z', null)).toBe('Jan 2022 — Present');
|
||||||
const end = '2024-12-31 12:00:00.000Z';
|
|
||||||
expect(formatYearRange(start, end)).toBe('2024');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats a range between different years', () => {
|
it('uses abbreviated month name', () => {
|
||||||
const start = '2021-05-15 12:00:00.000Z';
|
expect(formatMonthYearRange('2020-08-15T00:00:00Z', null)).toBe('Aug 2020 — Present');
|
||||||
const end = '2024-03-20 12:00:00.000Z';
|
|
||||||
expect(formatYearRange(start, end)).toBe('2021 — 2024');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats a range with null end date as "Present"', () => {
|
|
||||||
const start = '2022-08-01 12:00:00.000Z';
|
|
||||||
const end = null;
|
|
||||||
expect(formatYearRange(start, end)).toBe('2022 — Present');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Error & Edge Cases', () => {
|
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', () => {
|
it('throws if start date is invalid', () => {
|
||||||
const start = 'not-a-date';
|
expect(() => formatMonthYearRange('not-a-date', null)).toThrow('Invalid start date');
|
||||||
const end = '2024-01-01';
|
|
||||||
expect(() => formatYearRange(start, end)).toThrow('Invalid start date');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if end date is provided but invalid', () => {
|
it('throws if end date is provided but invalid', () => {
|
||||||
const start = '2024-01-01';
|
expect(() => formatMonthYearRange('2024-01-01T00:00:00Z', 'invalid')).toThrow('Invalid end date');
|
||||||
const end = 'invalid';
|
|
||||||
expect(() => formatYearRange(start, end)).toThrow('Invalid end date');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if start year is after end year', () => {
|
it('throws if start is after end', () => {
|
||||||
const start = '2024-01-01';
|
expect(() => formatMonthYearRange('2024-01-01T00:00:00Z', '2020-01-01T00:00:00Z')).toThrow(
|
||||||
const end = '2020-01-01';
|
'Start date cannot be after end date',
|
||||||
expect(() => formatYearRange(start, end)).toThrow('Start year cannot be after end year');
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty strings by throwing', () => {
|
it('throws on empty string', () => {
|
||||||
expect(() => formatYearRange('', null)).toThrow('Invalid start date');
|
expect(() => formatMonthYearRange('', null)).toThrow('Invalid start date');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +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 year string or "Present".
|
* 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.
|
* @throws {Error} if any date is invalid or if the range is logically impossible.
|
||||||
*/
|
*/
|
||||||
export function formatYearRange(start: string, end: string | null): string {
|
export function formatMonthYearRange(start: string, end: string | null): string {
|
||||||
const startDate = new Date(start);
|
const startDate = new Date(start);
|
||||||
if (Number.isNaN(startDate.getTime())) {
|
if (Number.isNaN(startDate.getTime())) {
|
||||||
throw new Error('Invalid start date');
|
throw new Error('Invalid start date');
|
||||||
}
|
}
|
||||||
const startYear = startDate.getFullYear();
|
|
||||||
|
|
||||||
if (end === null) {
|
if (end === null) {
|
||||||
return `${startYear} — Present`;
|
return `${formatMonthYear(startDate)} — Present`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const endDate = new Date(end);
|
const endDate = new Date(end);
|
||||||
if (Number.isNaN(endDate.getTime())) {
|
if (Number.isNaN(endDate.getTime())) {
|
||||||
throw new Error('Invalid end date');
|
throw new Error('Invalid end date');
|
||||||
}
|
}
|
||||||
const endYear = endDate.getFullYear();
|
|
||||||
|
|
||||||
if (startYear > endYear) {
|
if (startDate > endDate) {
|
||||||
throw new Error('Start year cannot be after end year');
|
throw new Error('Start date cannot be after end date');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startYear === endYear) {
|
const startLabel = formatMonthYear(startDate);
|
||||||
return `${startYear}`;
|
const endLabel = formatMonthYear(endDate);
|
||||||
|
|
||||||
|
if (startLabel === endLabel) {
|
||||||
|
return startLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${startYear} — ${endYear}`;
|
return `${startLabel} — ${endLabel}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,13 @@
|
|||||||
--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;
|
||||||
@@ -79,7 +83,7 @@
|
|||||||
|
|
||||||
/* === GRID === */
|
/* === GRID === */
|
||||||
--grid-gap: var(--space-3);
|
--grid-gap: var(--space-3);
|
||||||
--section-content-width: 56rem;
|
--section-content-width: 72rem;
|
||||||
|
|
||||||
/* === ANIMATION === */
|
/* === ANIMATION === */
|
||||||
--ease-default: ease;
|
--ease-default: ease;
|
||||||
@@ -117,6 +121,7 @@
|
|||||||
--radius-md: var(--radius);
|
--radius-md: var(--radius);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--container-section: var(--section-content-width);
|
--container-section: var(--section-content-width);
|
||||||
|
--text-section-title: var(--text-section-title);
|
||||||
|
|
||||||
--shadow-brutal-xs: var(--shadow-brutal-xs);
|
--shadow-brutal-xs: var(--shadow-brutal-xs);
|
||||||
--shadow-brutal-sm: var(--shadow-brutal-sm);
|
--shadow-brutal-sm: var(--shadow-brutal-sm);
|
||||||
@@ -132,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);
|
||||||
}
|
}
|
||||||
@@ -258,21 +273,63 @@
|
|||||||
.brutal-border-right {
|
.brutal-border-right {
|
||||||
border-right: var(--border-width) solid var(--blue);
|
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 */
|
/* Editorial rich-text typography */
|
||||||
.rich-text {
|
.rich-text {
|
||||||
max-width: 65ch;
|
max-width: 65ch;
|
||||||
line-height: 1.65;
|
line-height: var(--line-height-relaxed);
|
||||||
font-feature-settings: "onum";
|
font-feature-settings: "onum";
|
||||||
hanging-punctuation: first last;
|
hanging-punctuation: first last;
|
||||||
text-wrap: pretty;
|
text-wrap: pretty;
|
||||||
hyphens: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-text p + p {
|
.rich-text p + p {
|
||||||
margin-top: 1.2em;
|
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) */
|
/* Cross-section view transition (navigation between sections) */
|
||||||
::view-transition-old(section-content) {
|
::view-transition-old(section-content) {
|
||||||
animation-name: section-fade-out;
|
animation-name: section-fade-out;
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export type { BadgeVariant } from './ui/Badge';
|
export type { BadgeSize, BadgeVariant } from './ui/Badge';
|
||||||
export { Badge } from './ui/Badge';
|
export { Badge } from './ui/Badge';
|
||||||
|
|||||||
@@ -42,6 +42,28 @@ describe('Badge', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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>);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 {
|
||||||
/**
|
/**
|
||||||
@@ -13,6 +14,11 @@ interface Props {
|
|||||||
* @default 'default'
|
* @default 'default'
|
||||||
*/
|
*/
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant;
|
||||||
|
/**
|
||||||
|
* Size preset
|
||||||
|
* @default 'sm'
|
||||||
|
*/
|
||||||
|
size?: BadgeSize;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
@@ -26,12 +32,18 @@ const VARIANTS: Record<BadgeVariant, string> = {
|
|||||||
outline: 'brutal-border bg-transparent text-blue',
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,4 +63,31 @@ describe('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,10 +1,10 @@
|
|||||||
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'
|
||||||
@@ -19,9 +19,28 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
* 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:
|
primary:
|
||||||
'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)]',
|
'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)]',
|
||||||
secondary:
|
secondary:
|
||||||
@@ -29,25 +48,37 @@ const VARIANTS: Record<ButtonVariant, string> = {
|
|||||||
outline:
|
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',
|
'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:
|
ghost:
|
||||||
'border-[3px] border-solid border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
|
'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>;
|
||||||
|
|
||||||
/* box-shadow excluded from transition intentionally — snaps instantly so the
|
/* box-shadow excluded from transition intentionally — snaps instantly so the
|
||||||
* eye follows the 130ms button movement, not the shadow change. */
|
* eye follows the 130ms button movement, not the shadow change. */
|
||||||
const BASE = 'btn-transition uppercase tracking-wider';
|
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 (
|
return (
|
||||||
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}>
|
<a href={href} rel="noopener noreferrer" target="_blank" className={cls} {...anchorProps}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={cls} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export type { CardBackground } from './ui/Card';
|
export type { CardBackground } from './ui/Card';
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/Card';
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './ui/Card';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
|
||||||
|
|
||||||
describe('Card', () => {
|
describe('Card', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
@@ -42,7 +42,7 @@ describe('Card', () => {
|
|||||||
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', () => {
|
||||||
@@ -69,6 +69,54 @@ 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ interface SlotProps {
|
|||||||
* 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,5 +83,34 @@ export function CardContent({ children, className }: SlotProps) {
|
|||||||
* 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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
export type { BadgeVariant } from './Badge';
|
export type { BadgeSize, BadgeVariant } from './Badge';
|
||||||
export { Badge } from './Badge';
|
export { Badge } from './Badge';
|
||||||
export type { ButtonSize, ButtonVariant } from './Button';
|
export type { ButtonSize, ButtonVariant } from './Button';
|
||||||
export { Button } from './Button';
|
export { Button } from './Button';
|
||||||
export type { CardBackground } from './Card';
|
export type { CardBackground } from './Card';
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
|
||||||
|
|
||||||
export { Input, Textarea } from './Input';
|
export { Input, Textarea } from './Input';
|
||||||
|
export { Link } from './Link';
|
||||||
export { RichText } from './RichText';
|
export { RichText } from './RichText';
|
||||||
export type { ContainerSize, SectionBackground } from './Section';
|
export type { ContainerSize, SectionBackground } from './Section';
|
||||||
export { Container, Section } from './Section';
|
export { Container, Section } from './Section';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { RichText } from '$shared/ui';
|
|||||||
* Displays personal biography content from PocketBase.
|
* Displays personal biography content from PocketBase.
|
||||||
*/
|
*/
|
||||||
export default async function BioSection() {
|
export default async function BioSection() {
|
||||||
const data = await getFirstRecord<PageContentRecord>('bio');
|
const data = await getFirstRecord<PageContentRecord>('bio', { tags: ['bio'] });
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const mockItems = [
|
|||||||
start_date: '2022-01-01T00:00:00Z',
|
start_date: '2022-01-01T00:00:00Z',
|
||||||
end_date: null,
|
end_date: null,
|
||||||
description: 'Built critical systems.',
|
description: 'Built critical systems.',
|
||||||
|
stack: ['React', 'TypeScript'],
|
||||||
order: 1,
|
order: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -31,6 +32,7 @@ const mockItems = [
|
|||||||
start_date: '2020-01-01T00:00:00Z',
|
start_date: '2020-01-01T00:00:00Z',
|
||||||
end_date: '2021-12-31T00:00:00Z',
|
end_date: '2021-12-31T00:00:00Z',
|
||||||
description: 'Learned the ropes.',
|
description: 'Learned the ropes.',
|
||||||
|
stack: [],
|
||||||
order: 2,
|
order: 2,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -63,12 +65,12 @@ describe('ExperienceSection', () => {
|
|||||||
|
|
||||||
it('formats open-ended period as "Present"', async () => {
|
it('formats open-ended period as "Present"', async () => {
|
||||||
render(await ExperienceSection());
|
render(await ExperienceSection());
|
||||||
expect(screen.getByText('2022 — Present')).toBeInTheDocument();
|
expect(screen.getByText('Jan 2022 — Present')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats closed period with year range', async () => {
|
it('formats closed period with month and year range', async () => {
|
||||||
render(await ExperienceSection());
|
render(await ExperienceSection());
|
||||||
expect(screen.getByText('2020 — 2021')).toBeInTheDocument();
|
expect(screen.getByText('Jan 2020 — Dec 2021')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders description text', async () => {
|
it('renders description text', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ExperienceCard } from '$entities/experience';
|
import { ExperienceCard } from '$entities/experience';
|
||||||
import type { ExperienceRecord } from '$shared/api';
|
import type { ExperienceRecord } from '$shared/api';
|
||||||
import { getCollection } from '$shared/api';
|
import { getCollection } from '$shared/api';
|
||||||
import { formatYearRange } from '$shared/lib';
|
import { formatMonthYearRange } from '$shared/lib';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Experience section component.
|
* Experience section component.
|
||||||
@@ -10,6 +10,7 @@ import { formatYearRange } from '$shared/lib';
|
|||||||
export default async function ExperienceSection() {
|
export default async function ExperienceSection() {
|
||||||
const { items } = await getCollection<ExperienceRecord>('experience', {
|
const { items } = await getCollection<ExperienceRecord>('experience', {
|
||||||
sort: 'order',
|
sort: 'order',
|
||||||
|
tags: ['experience'],
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,8 +20,9 @@ export default async function ExperienceSection() {
|
|||||||
key={exp.id}
|
key={exp.id}
|
||||||
title={exp.role}
|
title={exp.role}
|
||||||
company={exp.company}
|
company={exp.company}
|
||||||
period={formatYearRange(exp.start_date, exp.end_date)}
|
period={formatMonthYearRange(exp.start_date, exp.end_date)}
|
||||||
description={exp.description}
|
description={exp.description}
|
||||||
|
stack={exp.stack}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Footer } from './ui/Footer/Footer';
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
vi.mock('$shared/api', () => ({
|
||||||
|
getFirstRecord: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { getFirstRecord } from '$shared/api';
|
||||||
|
import { Footer } from './Footer';
|
||||||
|
|
||||||
|
const mockSettings = {
|
||||||
|
id: 'ss1',
|
||||||
|
collectionId: 'site_settings',
|
||||||
|
collectionName: 'site_settings',
|
||||||
|
created: '',
|
||||||
|
updated: '',
|
||||||
|
cv: 'cv_2024.pdf',
|
||||||
|
contacts: 'c1',
|
||||||
|
expand: {
|
||||||
|
contacts: {
|
||||||
|
id: 'c1',
|
||||||
|
collectionId: 'contacts',
|
||||||
|
collectionName: 'contacts',
|
||||||
|
created: '',
|
||||||
|
updated: '',
|
||||||
|
email: 'hello@allmy.work',
|
||||||
|
socials: ['s1'],
|
||||||
|
expand: {
|
||||||
|
socials: [
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
collectionId: 'contact',
|
||||||
|
collectionName: 'contact',
|
||||||
|
created: '',
|
||||||
|
updated: '',
|
||||||
|
label: 'GitHub',
|
||||||
|
url: 'https://github.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Footer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getFirstRecord).mockResolvedValue(mockSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('structure', () => {
|
||||||
|
it('renders a footer element', async () => {
|
||||||
|
const { container } = render(await Footer());
|
||||||
|
expect(container.querySelector('footer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has brutal-border-top separator', async () => {
|
||||||
|
const { container } = render(await Footer());
|
||||||
|
expect(container.querySelector('footer')).toHaveClass('brutal-border-top');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('email link', () => {
|
||||||
|
it('renders the contact email as a mailto link', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
const link = screen.getByRole('link', { name: /hello@allmy\.work/i });
|
||||||
|
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render email link when contacts.email is missing', async () => {
|
||||||
|
vi.mocked(getFirstRecord).mockResolvedValue({
|
||||||
|
...mockSettings,
|
||||||
|
expand: {
|
||||||
|
contacts: {
|
||||||
|
...mockSettings.expand.contacts,
|
||||||
|
email: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.queryByRole('link', { name: /hello@allmy\.work/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('social links', () => {
|
||||||
|
it('renders GitHub social link with correct href', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
const link = screen.getByRole('link', { name: 'GitHub' });
|
||||||
|
expect(link).toHaveAttribute('href', 'https://github.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('social links have target="_blank"', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
const link = screen.getByRole('link', { name: 'GitHub' });
|
||||||
|
expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render social links when expand.socials is empty', async () => {
|
||||||
|
vi.mocked(getFirstRecord).mockResolvedValue({
|
||||||
|
...mockSettings,
|
||||||
|
expand: {
|
||||||
|
contacts: {
|
||||||
|
...mockSettings.expand.contacts,
|
||||||
|
expand: { socials: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.queryByRole('link', { name: 'GitHub' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CV download', () => {
|
||||||
|
it('renders a CV download link when cv is available', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.getByRole('link', { name: /download cv/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CV link points to the PocketBase file URL', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.getByRole('link', { name: /download cv/i })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'http://127.0.0.1:8090/api/files/site_settings/ss1/cv_2024.pdf',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CV link has download attribute', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.getByRole('link', { name: /download cv/i })).toHaveAttribute('download');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CV link has button styling', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
const link = screen.getByRole('link', { name: /download cv/i });
|
||||||
|
expect(link).toHaveClass('brutal-border', 'uppercase');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render CV link when no cv field', async () => {
|
||||||
|
vi.mocked(getFirstRecord).mockResolvedValue({ ...mockSettings, cv: '' });
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.queryByRole('link', { name: /download cv/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render CV link when settings record is missing', async () => {
|
||||||
|
vi.mocked(getFirstRecord).mockResolvedValue(null);
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.queryByRole('link', { name: /download cv/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { SiteSettingsRecord } from '$shared/api';
|
||||||
|
import { getFirstRecord } from '$shared/api';
|
||||||
|
import { buildFileUrl } from '$shared/lib';
|
||||||
|
import { Button, Link } from '$shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site-wide footer with contact email, social links, and CV download.
|
||||||
|
* All contact data is fetched from the site_settings CMS collection with nested expand.
|
||||||
|
*/
|
||||||
|
export async function Footer() {
|
||||||
|
const settings = await getFirstRecord<SiteSettingsRecord>('site_settings', {
|
||||||
|
expand: 'contacts,contacts.socials',
|
||||||
|
tags: ['site_settings'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cvUrl = settings?.cv ? buildFileUrl(settings.collectionId, settings.id, settings.cv) : null;
|
||||||
|
const contacts = settings?.expand?.contacts;
|
||||||
|
const socials = contacts?.expand?.socials ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="brutal-border-top px-8 py-6 lg:px-16 lg:py-8">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
{contacts?.email && (
|
||||||
|
<Link href={`mailto:${contacts.email}`} className="text-sm opacity-60 hover:opacity-100 no-underline">
|
||||||
|
{contacts.email}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{socials.map((social) => (
|
||||||
|
<Link
|
||||||
|
key={social.id}
|
||||||
|
href={social.url}
|
||||||
|
external
|
||||||
|
className="text-sm opacity-60 hover:opacity-100 no-underline"
|
||||||
|
>
|
||||||
|
{social.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{cvUrl && (
|
||||||
|
<Button href={cvUrl} download size="sm" className="self-start sm:self-auto">
|
||||||
|
Download CV
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { RichText } from '$shared/ui';
|
|||||||
* Displays primary introduction content from PocketBase.
|
* Displays primary introduction content from PocketBase.
|
||||||
*/
|
*/
|
||||||
export default async function IntroSection() {
|
export default async function IntroSection() {
|
||||||
const data = await getFirstRecord<PageContentRecord>('intro');
|
const data = await getFirstRecord<PageContentRecord>('intro', { tags: ['intro'] });
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import { ProjectCard } from '$entities/project';
|
import { ProjectCard } from '$entities/project';
|
||||||
import type { ProjectRecord } from '$shared/api';
|
import type { ProjectRecord } from '$shared/api';
|
||||||
import { getCollection } from '$shared/api';
|
import { getCollection } from '$shared/api';
|
||||||
|
import { buildFileUrl } from '$shared/lib';
|
||||||
/** Base URL for PocketBase file storage */
|
|
||||||
const PB_URL = process.env.NEXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a PocketBase file URL for a project image.
|
|
||||||
*/
|
|
||||||
function buildImageUrl(project: ProjectRecord): string | undefined {
|
|
||||||
if (!project.image) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return `${PB_URL}/api/files/${project.collectionId}/${project.id}/${project.image}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Projects section component.
|
* Projects section component.
|
||||||
@@ -22,10 +10,11 @@ function buildImageUrl(project: ProjectRecord): string | undefined {
|
|||||||
export default async function ProjectsSection() {
|
export default async function ProjectsSection() {
|
||||||
const { items } = await getCollection<ProjectRecord>('projects', {
|
const { items } = await getCollection<ProjectRecord>('projects', {
|
||||||
sort: 'order',
|
sort: 'order',
|
||||||
|
tags: ['projects'],
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-section">
|
<div className="space-y-6 max-w-section">
|
||||||
{items.map((project) => (
|
{items.map((project) => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
@@ -33,7 +22,8 @@ export default async function ProjectsSection() {
|
|||||||
year={project.year}
|
year={project.year}
|
||||||
description={project.description}
|
description={project.description}
|
||||||
tags={project.stack}
|
tags={project.stack}
|
||||||
imageUrl={buildImageUrl(project)}
|
url={project.url}
|
||||||
|
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Badge } from '$shared/ui';
|
|||||||
export default async function SkillsSection() {
|
export default async function SkillsSection() {
|
||||||
const data = await getCollection<SkillRecord>('skills', {
|
const data = await getCollection<SkillRecord>('skills', {
|
||||||
sort: 'category,order',
|
sort: 'category,order',
|
||||||
|
tags: ['skills'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data.items.length) {
|
if (!data.items.length) {
|
||||||
@@ -23,7 +24,7 @@ export default async function SkillsSection() {
|
|||||||
<div className="space-y-12 max-w-section">
|
<div className="space-y-12 max-w-section">
|
||||||
{Object.entries(categories).map(([category, items]) => (
|
{Object.entries(categories).map(([category, items]) => (
|
||||||
<div key={category} className="space-y-4">
|
<div key={category} className="space-y-4">
|
||||||
<h3 className="text-xl font-bold uppercase tracking-widest opacity-50">{category}</h3>
|
<h3 className="text-xl font-bold uppercase tracking-widest opacity-60">{category}</h3>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{items.map((skill) => (
|
{items.map((skill) => (
|
||||||
<Badge key={skill.id}>{skill.name}</Badge>
|
<Badge key={skill.id}>{skill.name}</Badge>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './Footer';
|
||||||
export * from './Navigation';
|
export * from './Navigation';
|
||||||
|
|||||||
Reference in New Issue
Block a user