Compare commits
14 Commits
1a413e3d04
...
0090718869
| Author | SHA1 | Date | |
|---|---|---|---|
| 0090718869 | |||
| 301e7a2555 | |||
| 0a99a37bca | |||
| e8bf8b502e | |||
| 30f8e4be95 | |||
| fed9c97ddb | |||
| af165ec356 | |||
| 1dfa9a62a2 | |||
| b4bda4b8f7 | |||
| f9cdb06632 | |||
| 9fa2156ee8 | |||
| ced77f6f07 | |||
| f163b750b2 | |||
| 41d1a37352 |
@@ -0,0 +1,50 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
const base = { created: '', updated: '' };
|
const base = { created: '', updated: '' };
|
||||||
|
|
||||||
@@ -241,6 +241,10 @@ const FIXTURES: Record<string, unknown[]> = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return Object.keys(FIXTURES).map((collection) => ({ collection }));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock API route handler for PocketBase collection records.
|
* Mock API route handler for PocketBase collection records.
|
||||||
* Returns fixture data shaped as a PocketBase list response.
|
* Returns fixture data shaped as a PocketBase list response.
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { SectionRecord } from '$entities/Section';
|
|
||||||
import { getCollection } from '$shared/api';
|
|
||||||
import type { NavItem } from '$widgets/Navigation';
|
|
||||||
import { MobileNav, SidebarNav } from '$widgets/Navigation';
|
|
||||||
import { SectionFactory } from '$widgets/SectionFactory';
|
|
||||||
import { SectionsAccordion } from '$widgets/SectionsAccordion';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Portfolio home page.
|
|
||||||
*
|
|
||||||
* Fetches all sections at build time (SSG). Renders a fixed sidebar with
|
|
||||||
* section navigation and a scrollable main column with accordion sections.
|
|
||||||
*/
|
|
||||||
export default async function Home() {
|
|
||||||
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
|
||||||
sort: 'order',
|
|
||||||
});
|
|
||||||
|
|
||||||
const navItems: NavItem[] = sections.map((s) => ({
|
|
||||||
id: s.slug,
|
|
||||||
label: s.title,
|
|
||||||
number: s.number,
|
|
||||||
}));
|
|
||||||
|
|
||||||
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}>
|
|
||||||
{sections.map((s) => (
|
|
||||||
<SectionFactory key={s.slug} slug={s.slug} />
|
|
||||||
))}
|
|
||||||
</SectionsAccordion>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"includes": ["src/**/*", "app/**/*"]
|
"includes": ["src/**/*", "app/**/*", "*.ts", "*.tsx", "*.js", "*.jsx"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -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.
|
||||||
+9
-4
@@ -1,8 +1,13 @@
|
|||||||
import type { NextConfig } from 'next'
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
/* output: 'export' is opt-in via STATIC_EXPORT=true.
|
||||||
|
* Set this in CI/deploy — not locally — so the mock API route works
|
||||||
|
* during development and local builds. */
|
||||||
|
const isExport = process.env.STATIC_EXPORT === 'true';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'export',
|
...(isExport ? { output: 'export' } : {}),
|
||||||
images: { unoptimized: true },
|
images: { unoptimized: true },
|
||||||
}
|
};
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"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": "biome lint --write .",
|
"lint": "biome lint --write .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ export type SectionRecord = BaseRecord & {
|
|||||||
* Display name of the section
|
* Display name of the section
|
||||||
*/
|
*/
|
||||||
title: string;
|
title: string;
|
||||||
/**
|
|
||||||
* Visual numbering prefix (e.g., "01")
|
|
||||||
*/
|
|
||||||
number: string;
|
|
||||||
/**
|
/**
|
||||||
* Sorting weight for section order
|
* Sorting weight for section order
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const Active: Story = {
|
|||||||
title: 'Biography',
|
title: 'Biography',
|
||||||
id: 'bio',
|
id: 'bio',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
onClick: () => {},
|
href: '/bio',
|
||||||
children: <p>This is the expanded section content. It is visible because isActive is true.</p>,
|
children: <p>This is the expanded section content. It is visible because isActive is true.</p>,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -34,7 +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: <p>This content is hidden in collapsed state.</p>,
|
children: <p>This content is hidden in collapsed state.</p>,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { SectionAccordion } from './SectionAccordion';
|
import { SectionAccordion } from './SectionAccordion';
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
@@ -7,7 +6,7 @@ const defaultProps = {
|
|||||||
title: 'About',
|
title: 'About',
|
||||||
id: 'about',
|
id: 'about',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
onClick: vi.fn(),
|
href: '/about',
|
||||||
children: <p>Content here</p>,
|
children: <p>Content here</p>,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,19 +16,25 @@ describe('SectionAccordion', () => {
|
|||||||
const { container } = render(<SectionAccordion {...defaultProps} />);
|
const { container } = render(<SectionAccordion {...defaultProps} />);
|
||||||
expect(container.querySelector('section#about')).toBeInTheDocument();
|
expect(container.querySelector('section#about')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it('renders a button with number and title', () => {
|
|
||||||
|
it('renders a link with number and title', () => {
|
||||||
render(<SectionAccordion {...defaultProps} />);
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
expect(screen.getByRole('button', { name: /01.*About/i })).toBeInTheDocument();
|
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', () => {
|
it('does not render children', () => {
|
||||||
render(<SectionAccordion {...defaultProps} />);
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
|
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it('calls onClick when button is clicked', async () => {
|
|
||||||
const onClick = vi.fn();
|
it('does not render a button', () => {
|
||||||
render(<SectionAccordion {...defaultProps} onClick={onClick} />);
|
render(<SectionAccordion {...defaultProps} />);
|
||||||
await userEvent.click(screen.getByRole('button'));
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
expect(onClick).toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,14 +45,17 @@ describe('SectionAccordion', () => {
|
|||||||
render(<SectionAccordion {...activeProps} />);
|
render(<SectionAccordion {...activeProps} />);
|
||||||
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders children', () => {
|
it('renders children', () => {
|
||||||
render(<SectionAccordion {...activeProps} />);
|
render(<SectionAccordion {...activeProps} />);
|
||||||
expect(screen.getByText('Content here')).toBeInTheDocument();
|
expect(screen.getByText('Content here')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it('does not render a button', () => {
|
|
||||||
|
it('does not render a link', () => {
|
||||||
render(<SectionAccordion {...activeProps} />);
|
render(<SectionAccordion {...activeProps} />);
|
||||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('content wrapper has animate-fadeIn class', () => {
|
it('content wrapper has animate-fadeIn class', () => {
|
||||||
const { container } = render(<SectionAccordion {...activeProps} />);
|
const { container } = render(<SectionAccordion {...activeProps} />);
|
||||||
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
|
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface SectionAccordionProps {
|
interface SectionAccordionProps {
|
||||||
@@ -18,9 +19,9 @@ interface SectionAccordionProps {
|
|||||||
*/
|
*/
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
/**
|
/**
|
||||||
* Called when the collapsed header is clicked
|
* Navigation URL for the collapsed heading link
|
||||||
*/
|
*/
|
||||||
onClick: () => void;
|
href: string;
|
||||||
/**
|
/**
|
||||||
* Section content, shown when active
|
* Section content, shown when active
|
||||||
*/
|
*/
|
||||||
@@ -28,9 +29,9 @@ interface SectionAccordionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accordion-style section that collapses to a heading button when inactive.
|
* Accordion-style section that collapses to a navigation link when inactive.
|
||||||
*/
|
*/
|
||||||
export function SectionAccordion({ number, title, id, isActive, onClick, children }: SectionAccordionProps) {
|
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
|
||||||
return (
|
return (
|
||||||
<section id={id} className="scroll-mt-8">
|
<section id={id} className="scroll-mt-8">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
@@ -46,10 +47,9 @@ export function SectionAccordion({ number, title, id, isActive, onClick, childre
|
|||||||
<div className="animate-fadeIn">{children}</div>
|
<div className="animate-fadeIn">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href={href}
|
||||||
onClick={onClick}
|
className="block w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group border-b-0 hover:border-b-0"
|
||||||
className="w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
|
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity"
|
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity"
|
||||||
@@ -57,7 +57,7 @@ export function SectionAccordion({ number, title, id, isActive, onClick, childre
|
|||||||
>
|
>
|
||||||
{number}. {title}
|
{number}. {title}
|
||||||
</h2>
|
</h2>
|
||||||
</button>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ describe('ExperienceCard', () => {
|
|||||||
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer');
|
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('period badge has brutal-border, bg-carbon-black, text-ochre-clay, text-sm', () => {
|
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-carbon-black', 'text-ochre-clay', 'text-sm');
|
expect(badge).toHaveClass('brutal-border', 'bg-blue', 'text-cream', 'text-sm');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('company paragraph has opacity-80', () => {
|
it('company paragraph has opacity-80', () => {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function ExperienceCard({ title, company, period, description, className
|
|||||||
<h4>{title}</h4>
|
<h4>{title}</h4>
|
||||||
<p className="text-base opacity-80">{company}</p>
|
<p className="text-base opacity-80">{company}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay text-sm self-start">{period}</span>
|
<span className="brutal-border px-4 py-2 bg-blue text-cream text-sm self-start">{period}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base max-w-[700px]">{description}</p>
|
<p className="text-base max-w-[700px]">{description}</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ export function DetailedProjectCard({ title, year, role, stack, description, det
|
|||||||
</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>
|
<p className="text-lg mb-6">{description}</p>
|
||||||
|
|
||||||
{imageUrl && (
|
{imageUrl && (
|
||||||
<div className="brutal-border aspect-video bg-slate-indigo 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -47,20 +47,13 @@ describe('ProjectCard', () => {
|
|||||||
it('year badge has correct classes', () => {
|
it('year badge has correct classes', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const yearBadge = screen.getByText('2024');
|
const yearBadge = screen.getByText('2024');
|
||||||
expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm');
|
expect(yearBadge).toHaveClass('brutal-border', 'bg-blue', 'text-cream', 'text-sm');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tags have correct classes', () => {
|
it('tags have correct classes', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const tag = screen.getByText('React');
|
const tag = screen.getByText('React');
|
||||||
expect(tag).toHaveClass(
|
expect(tag).toHaveClass('brutal-border', 'bg-cream', 'text-blue', 'text-sm', 'uppercase', 'tracking-wide');
|
||||||
'brutal-border',
|
|
||||||
'bg-white',
|
|
||||||
'text-carbon-black',
|
|
||||||
'text-sm',
|
|
||||||
'uppercase',
|
|
||||||
'tracking-wide',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,29 +33,26 @@ export function ProjectCard({ title, year, description, tags, imageUrl }: Props)
|
|||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'group hover:translate-x-[2px] hover:translate-y-[2px]',
|
'group hover:translate-x-[2px] hover:translate-y-[2px]',
|
||||||
'hover:shadow-[10px_10px_0_var(--carbon-black)] transition-all duration-300',
|
'hover:shadow-[10px_10px_0_var(--blue)] transition-all duration-300',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-row justify-between items-start mb-3">
|
<div className="flex flex-row justify-between items-start mb-3">
|
||||||
<CardTitle className="flex-1">{title}</CardTitle>
|
<CardTitle className="flex-1">{title}</CardTitle>
|
||||||
<span className="brutal-border px-3 py-1 bg-carbon-black text-ochre-clay text-sm">{year}</span>
|
<span className="brutal-border px-3 py-1 bg-blue text-cream text-sm">{year}</span>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{imageUrl && (
|
{imageUrl && (
|
||||||
<div className="brutal-border my-6 aspect-video bg-slate-indigo overflow-hidden relative">
|
<div className="brutal-border my-6 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<span
|
<span key={tag} className="brutal-border px-3 py-1 bg-cream text-blue text-sm uppercase tracking-wide">
|
||||||
key={tag}
|
|
||||||
className="brutal-border px-3 py-1 bg-white text-carbon-black text-sm uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -26,12 +26,9 @@ export type BaseRecord = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PocketBase collection for simple text blocks (Intro, Bio).
|
* PocketBase collection for simple text blocks (Intro, Bio).
|
||||||
|
* Each collection is named after its section — no slug field.
|
||||||
*/
|
*/
|
||||||
export type PageContentRecord = BaseRecord & {
|
export type PageContentRecord = BaseRecord & {
|
||||||
/**
|
|
||||||
* Slug corresponding to the parent section
|
|
||||||
*/
|
|
||||||
slug: string;
|
|
||||||
/**
|
/**
|
||||||
* HTML or Markdown content string
|
* HTML or Markdown content string
|
||||||
*/
|
*/
|
||||||
|
|||||||
+49
-41
@@ -28,28 +28,26 @@
|
|||||||
--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,9 +69,9 @@
|
|||||||
--radius: 0px;
|
--radius: 0px;
|
||||||
|
|
||||||
/* === BRUTALIST SHADOWS === */
|
/* === BRUTALIST SHADOWS === */
|
||||||
--shadow-brutal: 8px 8px 0 var(--carbon-black);
|
--shadow-brutal: 8px 8px 0 var(--blue);
|
||||||
--shadow-brutal-sm: 4px 4px 0 var(--carbon-black);
|
--shadow-brutal-sm: 4px 4px 0 var(--blue);
|
||||||
--shadow-brutal-lg: 12px 12px 0 var(--carbon-black);
|
--shadow-brutal-lg: 12px 12px 0 var(--blue);
|
||||||
|
|
||||||
/* === GRID === */
|
/* === GRID === */
|
||||||
--grid-gap: var(--space-3);
|
--grid-gap: var(--space-3);
|
||||||
@@ -83,10 +81,8 @@
|
|||||||
--font-heading: var(--font-fraunces);
|
--font-heading: var(--font-fraunces);
|
||||||
--font-body: var(--font-public-sans);
|
--font-body: var(--font-public-sans);
|
||||||
|
|
||||||
--color-ochre-clay: var(--ochre-clay);
|
--color-cream: var(--cream);
|
||||||
--color-slate-indigo: var(--slate-indigo);
|
--color-blue: var(--blue);
|
||||||
--color-burnt-oxide: var(--burnt-oxide);
|
|
||||||
--color-carbon-black: var(--carbon-black);
|
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
@@ -124,15 +120,27 @@
|
|||||||
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;
|
||||||
@@ -150,7 +158,7 @@
|
|||||||
font-variation-settings:
|
font-variation-settings:
|
||||||
"WONK" var(--fraunces-wonk),
|
"WONK" var(--fraunces-wonk),
|
||||||
"SOFT" var(--fraunces-soft);
|
"SOFT" var(--fraunces-soft);
|
||||||
color: var(--carbon-black);
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -173,13 +181,13 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +198,7 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -207,19 +215,19 @@
|
|||||||
box-shadow: var(--shadow-brutal-lg);
|
box-shadow: var(--shadow-brutal-lg);
|
||||||
}
|
}
|
||||||
.brutal-border {
|
.brutal-border {
|
||||||
border: var(--border-width) solid var(--carbon-black);
|
border: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
.brutal-border-top {
|
.brutal-border-top {
|
||||||
border-top: var(--border-width) solid var(--carbon-black);
|
border-top: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
.brutal-border-bottom {
|
.brutal-border-bottom {
|
||||||
border-bottom: var(--border-width) solid var(--carbon-black);
|
border-bottom: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
.brutal-border-left {
|
.brutal-border-left {
|
||||||
border-left: var(--border-width) solid var(--carbon-black);
|
border-left: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
.brutal-border-right {
|
.brutal-border-right {
|
||||||
border-right: var(--border-width) solid var(--carbon-black);
|
border-right: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
|
|||||||
@@ -18,17 +18,17 @@ describe('Badge', () => {
|
|||||||
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', () => {
|
||||||
@@ -38,7 +38,7 @@ describe('Badge', () => {
|
|||||||
|
|
||||||
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ describe('Button', () => {
|
|||||||
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>);
|
||||||
@@ -28,7 +28,7 @@ describe('Button', () => {
|
|||||||
});
|
});
|
||||||
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', () => {
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VARIANTS: Record<ButtonVariant, string> = {
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
primary: 'bg-burnt-oxide text-ochre-clay',
|
primary: 'bg-blue text-cream outline-[3px] outline-cream',
|
||||||
secondary: 'bg-slate-indigo text-ochre-clay',
|
secondary: 'bg-blue text-cream outline-[3px] outline-cream',
|
||||||
outline: 'bg-transparent text-carbon-black border-carbon-black',
|
outline: 'bg-transparent text-blue border-blue',
|
||||||
ghost: 'bg-ochre-clay text-carbon-black border-carbon-black',
|
ghost: 'bg-cream text-blue border-blue',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIZES: Record<ButtonSize, string> = {
|
const SIZES: Record<ButtonSize, string> = {
|
||||||
@@ -35,7 +35,7 @@ const SIZES: Record<ButtonSize, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const BASE =
|
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';
|
'brutal-border brutal-shadow transition-all duration-200 hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[6px_6px_0_var(--blue)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-[4px_4px_0_var(--blue)] uppercase tracking-wider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brutalist button with variants and sizes.
|
* Brutalist button with variants and sizes.
|
||||||
|
|||||||
@@ -13,17 +13,13 @@ describe('Card', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
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', () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 {
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +14,7 @@ interface CardProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Background color preset
|
* Background color preset
|
||||||
* @default 'ochre'
|
* @default 'cream'
|
||||||
*/
|
*/
|
||||||
background?: CardBackground;
|
background?: CardBackground;
|
||||||
/**
|
/**
|
||||||
@@ -25,15 +25,14 @@ interface CardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INPUT_BASE =
|
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';
|
'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.
|
||||||
@@ -26,7 +26,7 @@ export function Input({ label, error, className, id, ...props }: InputProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{label && (
|
{label && (
|
||||||
<label htmlFor={inputId} className="text-carbon-black">
|
<label htmlFor={inputId} className="text-blue">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
@@ -37,7 +37,7 @@ export function Input({ label, error, className, id, ...props }: InputProps) {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<span id={errorId} className="text-sm text-burnt-oxide">
|
<span id={errorId} className="text-sm text-blue">
|
||||||
{error}
|
{error}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -72,7 +72,7 @@ export function Textarea({ label, error, rows = 4, className, id, ...props }: Te
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{label && (
|
{label && (
|
||||||
<label htmlFor={textareaId} className="text-carbon-black">
|
<label htmlFor={textareaId} className="text-blue">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
@@ -84,7 +84,7 @@ export function Textarea({ label, error, rows = 4, className, id, ...props }: Te
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<span id={errorId} className="text-sm text-burnt-oxide">
|
<span id={errorId} className="text-sm text-blue">
|
||||||
{error}
|
{error}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,29 @@
|
|||||||
|
import parse from 'html-react-parser';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* HTML string from PocketBase rich-text editor
|
||||||
|
*/
|
||||||
|
html: string;
|
||||||
|
/**
|
||||||
|
* CSS classes applied to the wrapper div
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a PocketBase rich-text HTML string as React elements.
|
||||||
|
*/
|
||||||
|
export function RichText({ html, className }: Props) {
|
||||||
|
if (!html) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parse(html);
|
||||||
|
|
||||||
|
if (className) {
|
||||||
|
return <div className={className}>{parsed}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{parsed}</>;
|
||||||
|
}
|
||||||
@@ -18,17 +18,13 @@ describe('Section', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 {
|
||||||
@@ -11,7 +11,7 @@ interface SectionProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Background color variant
|
* Background color variant
|
||||||
* @default 'ochre'
|
* @default 'cream'
|
||||||
*/
|
*/
|
||||||
background?: SectionBackground;
|
background?: SectionBackground;
|
||||||
/**
|
/**
|
||||||
@@ -26,15 +26,14 @@ interface SectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 className={cn(BACKGROUNDS[background], bordered && 'brutal-border-top brutal-border-bottom', className)}>
|
<section className={cn(BACKGROUNDS[background], bordered && 'brutal-border-top brutal-border-bottom', className)}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ 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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type { CardBackground } from './Card';
|
|||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
||||||
|
|
||||||
export { Input, Textarea } from './Input';
|
export { Input, Textarea } from './Input';
|
||||||
|
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';
|
||||||
|
|
||||||
export { TechStackBrick, TechStackGrid } from './TechStack';
|
export { TechStackBrick, TechStackGrid } from './TechStack';
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import type { PageContentRecord } from '$shared/api';
|
import type { PageContentRecord } from '$shared/api';
|
||||||
import { getFirstRecord } from '$shared/api';
|
import { getFirstRecord } from '$shared/api';
|
||||||
|
import { RichText } from '$shared/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bio section component.
|
* Bio section component.
|
||||||
* 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');
|
||||||
filter: 'slug = "bio"',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <RichText html={data.content} className="prose prose-lg max-w-none" />;
|
||||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
|
||||||
<p>{data.content}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import type { PageContentRecord } from '$shared/api';
|
import type { PageContentRecord } from '$shared/api';
|
||||||
import { getFirstRecord } from '$shared/api';
|
import { getFirstRecord } from '$shared/api';
|
||||||
|
import { RichText } from '$shared/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intro section component.
|
* Intro section component.
|
||||||
* 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');
|
||||||
filter: 'slug = "intro"',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <RichText html={data.content} className="prose prose-lg max-w-none" />;
|
||||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
|
||||||
<p>{data.content}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import type { NavItem } from '../model/types';
|
import type { NavItem } from '../model/types';
|
||||||
import { MobileNav } from './MobileNav';
|
import { MobileNav } from './MobileNav';
|
||||||
|
|
||||||
const ITEMS: NavItem[] = [{ id: 'about', label: 'About', number: '01' }];
|
vi.mock('next/navigation', () => ({ usePathname: vi.fn(() => '/') }));
|
||||||
|
|
||||||
|
const ITEMS: NavItem[] = [
|
||||||
|
{ id: 'intro', label: 'Intro', number: '01' },
|
||||||
|
{ id: 'bio', label: 'Bio', number: '02' },
|
||||||
|
];
|
||||||
|
|
||||||
describe('MobileNav', () => {
|
describe('MobileNav', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
@@ -19,27 +24,39 @@ describe('MobileNav', () => {
|
|||||||
|
|
||||||
it('menu items are hidden initially', () => {
|
it('menu items are hidden initially', () => {
|
||||||
render(<MobileNav items={ITEMS} />);
|
render(<MobileNav items={ITEMS} />);
|
||||||
expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('link', { name: /intro/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('navigation items', () => {
|
||||||
|
it('shows items as links with correct hrefs when open', async () => {
|
||||||
|
render(<MobileNav items={ITEMS} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
||||||
|
expect(screen.getByRole('link', { name: /01.*Intro/i })).toHaveAttribute('href', '/intro');
|
||||||
|
expect(screen.getByRole('link', { name: /02.*Bio/i })).toHaveAttribute('href', '/bio');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('interactions', () => {
|
describe('interactions', () => {
|
||||||
it('click toggle shows item buttons and changes label to "Close"', async () => {
|
it('click toggle shows links and changes label to "Close"', async () => {
|
||||||
render(<MobileNav items={ITEMS} />);
|
render(<MobileNav items={ITEMS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
||||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||||
expect(screen.getByText('About')).toBeInTheDocument();
|
expect(screen.getByText('Intro')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('click item button closes the menu', async () => {
|
it('closes menu when pathname changes', async () => {
|
||||||
render(<MobileNav items={ITEMS} />);
|
const { usePathname } = await import('next/navigation');
|
||||||
|
vi.mocked(usePathname).mockReturnValue('/');
|
||||||
|
const { rerender } = render(<MobileNav items={ITEMS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
||||||
// item button label contains number + label text; find by accessible name fragment
|
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||||
const itemBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes('About'));
|
|
||||||
expect(itemBtn).toBeDefined();
|
vi.mocked(usePathname).mockReturnValue('/bio');
|
||||||
await userEvent.click(itemBtn as HTMLElement);
|
rerender(<MobileNav items={ITEMS} />);
|
||||||
expect(screen.queryByText('Close')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import type { NavItem } from '../model/types';
|
import type { NavItem } from '../model/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for MobileNav.
|
||||||
|
*/
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Navigation items to render
|
* Navigation items to render
|
||||||
@@ -13,30 +18,25 @@ interface Props {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile navigation overlay, hidden on lg+ screens.
|
* Mobile navigation overlay, hidden on lg+ screens.
|
||||||
|
* Closes automatically when the URL pathname changes after navigation.
|
||||||
*/
|
*/
|
||||||
export function MobileNav({ items }: Props) {
|
export function MobileNav({ items }: Props) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
/**
|
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger, not a value used inside the callback
|
||||||
* Scrolls to the section by id with a 100px offset, then closes the menu.
|
useEffect(() => {
|
||||||
*/
|
|
||||||
function scrollToSection(id: string) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) {
|
|
||||||
const top = el.getBoundingClientRect().top + window.scrollY - 100;
|
|
||||||
window.scrollTo({ top, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lg:hidden fixed top-0 left-0 right-0 bg-ochre-clay brutal-border-bottom z-50">
|
<div className="lg:hidden fixed top-0 left-0 right-0 bg-cream brutal-border-bottom z-50">
|
||||||
<div className="px-6 py-4 flex items-center justify-between">
|
<div className="px-6 py-4 flex items-center justify-between">
|
||||||
<h4>allmy.work</h4>
|
<h4>allmy.work</h4>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen((prev) => !prev)}
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
|
className="brutal-border px-4 py-2 bg-blue text-cream"
|
||||||
>
|
>
|
||||||
{isOpen ? 'Close' : 'Menu'}
|
{isOpen ? 'Close' : 'Menu'}
|
||||||
</button>
|
</button>
|
||||||
@@ -44,12 +44,7 @@ export function MobileNav({ items }: Props) {
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
|
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<Link key={item.id} href={`/${item.id}`} className="block w-full brutal-border bg-cream px-4 py-3">
|
||||||
type="button"
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => scrollToSection(item.id)}
|
|
||||||
className="w-full text-left brutal-border bg-ochre-clay px-4 py-3"
|
|
||||||
>
|
|
||||||
<div className={cn('flex items-baseline gap-3')}>
|
<div className={cn('flex items-baseline gap-3')}>
|
||||||
<span className="text-sm opacity-60 font-body">{item.number}</span>
|
<span className="text-sm opacity-60 font-body">{item.number}</span>
|
||||||
<span
|
<span
|
||||||
@@ -59,7 +54,7 @@ export function MobileNav({ items }: Props) {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,21 +2,23 @@ import { render, screen } from '@testing-library/react';
|
|||||||
import type { NavItem } from '../model/types';
|
import type { NavItem } from '../model/types';
|
||||||
import { SidebarNav } from './SidebarNav';
|
import { SidebarNav } from './SidebarNav';
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
usePathname: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
const ITEMS: NavItem[] = [
|
const ITEMS: NavItem[] = [
|
||||||
{ id: 'bio', label: 'Bio', number: '01' },
|
{ id: 'bio', label: 'Bio', number: '01' },
|
||||||
{ id: 'work', label: 'Work', number: '02' },
|
{ id: 'work', label: 'Work', number: '02' },
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
global.IntersectionObserver = class {
|
|
||||||
observe = vi.fn();
|
|
||||||
disconnect = vi.fn();
|
|
||||||
unobserve = vi.fn();
|
|
||||||
} as unknown as typeof IntersectionObserver;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SidebarNav', () => {
|
describe('SidebarNav', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(usePathname).mockReturnValue('/bio');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders a nav element', () => {
|
it('renders a nav element', () => {
|
||||||
render(<SidebarNav items={ITEMS} />);
|
render(<SidebarNav items={ITEMS} />);
|
||||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||||
@@ -50,10 +52,33 @@ describe('SidebarNav', () => {
|
|||||||
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a button for each item', () => {
|
it('renders a link for each nav item', () => {
|
||||||
render(<SidebarNav items={ITEMS} />);
|
render(<SidebarNav items={ITEMS} />);
|
||||||
const buttons = screen.getAllByRole('button');
|
expect(screen.getByRole('link', { name: /Bio/i })).toBeInTheDocument();
|
||||||
expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length);
|
expect(screen.getByRole('link', { name: /Work/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('active state', () => {
|
||||||
|
it('marks matching pathname item as active (no opacity-40)', () => {
|
||||||
|
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 (opacity-40)', () => {
|
||||||
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { CONTACT_LINKS, cn } from '$shared/lib';
|
import { CONTACT_LINKS, cn } from '$shared/lib';
|
||||||
import type { NavItem } from '../model/types';
|
import type { NavItem } from '../model/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for SidebarNav.
|
||||||
|
*/
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Navigation items to render
|
* Navigation items to render
|
||||||
@@ -13,45 +17,27 @@ interface Props {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed sidebar navigation, visible on lg+ screens.
|
* Fixed sidebar navigation, visible on lg+ screens.
|
||||||
|
* Active section determined by current URL pathname.
|
||||||
*/
|
*/
|
||||||
export function SidebarNav({ items }: Props) {
|
export function SidebarNav({ items }: Props) {
|
||||||
const [activeSection, setActiveSection] = useState('bio');
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setActiveSection(entry.target.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 },
|
|
||||||
);
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
const el = document.getElementById(item.id);
|
|
||||||
if (el) {
|
|
||||||
observer.observe(el);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrolls to the section by id with a 40px offset.
|
* An item is active when its slug matches the current pathname,
|
||||||
|
* or when the pathname is root and it is the first item.
|
||||||
*/
|
*/
|
||||||
function scrollToSection(id: string) {
|
function isActive(item: NavItem): boolean {
|
||||||
const el = document.getElementById(id);
|
if (pathname === `/${item.id}`) {
|
||||||
if (el) {
|
return true;
|
||||||
const top = el.getBoundingClientRect().top + window.scrollY - 40;
|
|
||||||
window.scrollTo({ top, behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
|
if (pathname === '/' && items[0]?.id === item.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<nav className="fixed top-0 left-0 h-screen w-full lg:w-1/3 bg-cream brutal-border-right hidden lg:block overflow-y-auto z-50">
|
||||||
<div className="px-8 py-12 space-y-2">
|
<div className="px-8 py-12 space-y-2">
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h2>Index</h2>
|
<h2>Index</h2>
|
||||||
@@ -60,27 +46,23 @@ export function SidebarNav({ items }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.map((item) => {
|
{items.map((item) => (
|
||||||
const isActive = activeSection === item.id;
|
<Link
|
||||||
return (
|
key={item.id}
|
||||||
<button
|
href={`/${item.id}`}
|
||||||
type="button"
|
className={cn(
|
||||||
key={item.id}
|
'block w-full text-left brutal-border bg-cream px-6 py-4 transition-all duration-300',
|
||||||
onClick={() => scrollToSection(item.id)}
|
isActive(item)
|
||||||
className={cn(
|
? 'shadow-[12px_12px_0_var(--blue)] opacity-100 translate-x-0'
|
||||||
'w-full text-left brutal-border bg-ochre-clay px-6 py-4 transition-all duration-300',
|
: 'opacity-40 shadow-none hover:opacity-60',
|
||||||
isActive
|
)}
|
||||||
? '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 className="flex items-baseline gap-4">
|
</div>
|
||||||
<span className="text-sm opacity-60">{item.number}</span>
|
</Link>
|
||||||
<span className="font-heading text-xl font-black">{item.label}</span>
|
))}
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="mt-12 pt-12 brutal-border-top">
|
<div className="mt-12 pt-12 brutal-border-top">
|
||||||
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
|
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe('UtilityBar', () => {
|
|||||||
it('Download CV button has primary variant class', () => {
|
it('Download CV button has primary variant class', () => {
|
||||||
render(<UtilityBar />);
|
render(<UtilityBar />);
|
||||||
const btn = screen.getByRole('button', { name: /download cv/i });
|
const btn = screen.getByRole('button', { name: /download cv/i });
|
||||||
expect(btn).toHaveClass('bg-burnt-oxide');
|
expect(btn).toHaveClass('bg-blue');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ export function UtilityBar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-ochre-clay brutal-border-top z-40">
|
<div className="fixed bottom-0 left-0 right-0 bg-cream brutal-border-top z-40">
|
||||||
<div className="max-w-[2560px] mx-auto px-6 md:px-12 lg:px-16 py-4 flex items-center justify-between">
|
<div className="max-w-[2560px] mx-auto px-6 md:px-12 lg:px-16 py-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm uppercase tracking-wider">Contact</span>
|
<span className="text-sm uppercase tracking-wider">Contact</span>
|
||||||
<a href={`mailto:${CONTACT_LINKS.email}`} className="text-base hover:text-burnt-oxide transition-colors">
|
<a href={`mailto:${CONTACT_LINKS.email}`} className="text-base hover:opacity-60 transition-opacity">
|
||||||
{CONTACT_LINKS.email}
|
{CONTACT_LINKS.email}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import type { SectionRecord } from '$entities/Section';
|
import type { SectionRecord } from '$entities/Section';
|
||||||
import { SectionsAccordion } from './SectionsAccordion';
|
import { SectionsAccordion } from './SectionsAccordion';
|
||||||
|
|
||||||
const baseRecord = { collectionId: 'c1', collectionName: 'sections', created: '', updated: '' };
|
const baseRecord = { collectionId: 'c1', collectionName: 'sections', created: '', updated: '' };
|
||||||
|
|
||||||
const sections: SectionRecord[] = [
|
const sections: SectionRecord[] = [
|
||||||
{ ...baseRecord, id: '1', slug: 'intro', title: 'Intro', number: '01', order: 1 },
|
{ ...baseRecord, id: '1', slug: 'intro', title: 'Intro', order: 1 },
|
||||||
{ ...baseRecord, id: '2', slug: 'bio', title: 'Bio', number: '02', order: 2 },
|
{ ...baseRecord, id: '2', slug: 'bio', title: 'Bio', order: 2 },
|
||||||
{ ...baseRecord, id: '3', slug: 'skills', title: 'Skills', number: '03', order: 3 },
|
{ ...baseRecord, id: '3', slug: 'skills', title: 'Skills', order: 3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('SectionsAccordion', () => {
|
describe('SectionsAccordion', () => {
|
||||||
describe('initial state', () => {
|
describe('active section rendering', () => {
|
||||||
it('renders the first section as active (h1)', () => {
|
it('renders the active section as an h1', () => {
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections}>
|
<SectionsAccordion sections={sections} activeSlug="intro">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
@@ -24,56 +23,49 @@ describe('SectionsAccordion', () => {
|
|||||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders remaining sections as collapsed buttons', () => {
|
it('renders inactive sections as links', () => {
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections}>
|
<SectionsAccordion sections={sections} activeSlug="intro">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
</SectionsAccordion>,
|
</SectionsAccordion>,
|
||||||
);
|
);
|
||||||
const buttons = screen.getAllByRole('button');
|
const links = screen.getAllByRole('link');
|
||||||
expect(buttons).toHaveLength(2);
|
expect(links).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('interaction', () => {
|
it('inactive section links point to correct hrefs', () => {
|
||||||
it('activates clicked section', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections}>
|
<SectionsAccordion sections={sections} activeSlug="intro">
|
||||||
|
<div>Intro content</div>
|
||||||
|
<div>Bio content</div>
|
||||||
|
<div>Skills content</div>
|
||||||
|
</SectionsAccordion>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: /02.*Bio/i })).toHaveAttribute('href', '/bio');
|
||||||
|
expect(screen.getByRole('link', { name: /03.*Skills/i })).toHaveAttribute('href', '/skills');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the correct active section for a given activeSlug', () => {
|
||||||
|
render(
|
||||||
|
<SectionsAccordion sections={sections} activeSlug="bio">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
</SectionsAccordion>,
|
</SectionsAccordion>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
|
|
||||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deactivates previously active section after click', async () => {
|
it('only one section is active at a time', () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections}>
|
<SectionsAccordion sections={sections} activeSlug="skills">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
</SectionsAccordion>,
|
</SectionsAccordion>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
|
|
||||||
expect(screen.queryByRole('heading', { level: 1, name: /01\. Intro/i })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only one section is active at a time', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<SectionsAccordion sections={sections}>
|
|
||||||
<div>Intro content</div>
|
|
||||||
<div>Bio content</div>
|
|
||||||
<div>Skills content</div>
|
|
||||||
</SectionsAccordion>,
|
|
||||||
);
|
|
||||||
await user.click(screen.getByRole('button', { name: /03\. Skills/i }));
|
|
||||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -81,7 +73,7 @@ describe('SectionsAccordion', () => {
|
|||||||
describe('content slots', () => {
|
describe('content slots', () => {
|
||||||
it('shows active section content', () => {
|
it('shows active section content', () => {
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections}>
|
<SectionsAccordion sections={sections} activeSlug="intro">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
@@ -90,17 +82,16 @@ describe('SectionsAccordion', () => {
|
|||||||
expect(screen.getByText('Intro content')).toBeInTheDocument();
|
expect(screen.getByText('Intro content')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows correct content after switching sections', async () => {
|
it('does not show inactive section content', () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
render(
|
||||||
<SectionsAccordion sections={sections}>
|
<SectionsAccordion sections={sections} activeSlug="intro">
|
||||||
<div>Intro content</div>
|
<div>Intro content</div>
|
||||||
<div>Bio content</div>
|
<div>Bio content</div>
|
||||||
<div>Skills content</div>
|
<div>Skills content</div>
|
||||||
</SectionsAccordion>,
|
</SectionsAccordion>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
|
expect(screen.queryByText('Bio content')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('Bio content')).toBeInTheDocument();
|
expect(screen.queryByText('Skills content')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Children, useState } from 'react';
|
import { Children } from 'react';
|
||||||
import type { SectionRecord } from '$entities/Section';
|
import type { SectionRecord } from '$entities/Section';
|
||||||
import { SectionAccordion } from '$entities/Section';
|
import { SectionAccordion } from '$entities/Section';
|
||||||
|
|
||||||
@@ -10,6 +8,11 @@ type Props = {
|
|||||||
* Ordered section metadata — drives navigation labels and IDs
|
* Ordered section metadata — drives navigation labels and IDs
|
||||||
*/
|
*/
|
||||||
sections: SectionRecord[];
|
sections: SectionRecord[];
|
||||||
|
/**
|
||||||
|
* Slug of the currently active section.
|
||||||
|
* Must match one of the slugs in the sections array.
|
||||||
|
*/
|
||||||
|
activeSlug: string;
|
||||||
/**
|
/**
|
||||||
* Pre-rendered RSC content slots, one per section, matched by index
|
* Pre-rendered RSC content slots, one per section, matched by index
|
||||||
*/
|
*/
|
||||||
@@ -17,11 +20,11 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages accordion open/close state across all portfolio sections.
|
* Renders all portfolio sections as an accordion list.
|
||||||
* Receives RSC content as opaque children slots, matched positionally to sections.
|
* Active section is determined by the URL (activeSlug prop); inactive sections
|
||||||
|
* render as navigation links so the browser handles routing.
|
||||||
*/
|
*/
|
||||||
export function SectionsAccordion({ sections, children }: Props) {
|
export function SectionsAccordion({ sections, activeSlug, children }: Props) {
|
||||||
const [activeSlug, setActiveSlug] = useState(sections[0]?.slug ?? '');
|
|
||||||
const slots = Children.toArray(children);
|
const slots = Children.toArray(children);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,10 +33,10 @@ export function SectionsAccordion({ sections, children }: Props) {
|
|||||||
<SectionAccordion
|
<SectionAccordion
|
||||||
key={section.slug}
|
key={section.slug}
|
||||||
id={section.slug}
|
id={section.slug}
|
||||||
number={section.number}
|
number={String(section.order).padStart(2, '0')}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
isActive={activeSlug === section.slug}
|
isActive={activeSlug === section.slug}
|
||||||
onClick={() => setActiveSlug(section.slug)}
|
href={`/${section.slug}`}
|
||||||
>
|
>
|
||||||
{slots[i]}
|
{slots[i]}
|
||||||
</SectionAccordion>
|
</SectionAccordion>
|
||||||
|
|||||||
+10
-10
@@ -1,6 +1,6 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
import react from '@vitejs/plugin-react';
|
||||||
import react from '@vitejs/plugin-react'
|
import path from 'path';
|
||||||
import path from 'path'
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
@@ -12,13 +12,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'$shared': path.resolve(__dirname, './src/shared'),
|
$shared: path.resolve(__dirname, './src/shared'),
|
||||||
'$entities': path.resolve(__dirname, './src/entities'),
|
$entities: path.resolve(__dirname, './src/entities'),
|
||||||
'$widgets': path.resolve(__dirname, './src/widgets'),
|
$widgets: path.resolve(__dirname, './src/widgets'),
|
||||||
'$features': path.resolve(__dirname, './src/features'),
|
$features: path.resolve(__dirname, './src/features'),
|
||||||
'$app': path.resolve(__dirname, './src/app'),
|
$app: path.resolve(__dirname, './src/app'),
|
||||||
'$routes': path.resolve(__dirname, './src/routes'),
|
$routes: path.resolve(__dirname, './src/routes'),
|
||||||
'@': path.resolve(__dirname, '.'),
|
'@': path.resolve(__dirname, '.'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -3239,6 +3239,44 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"dom-serializer@npm:^3.0.0":
|
||||||
|
version: 3.1.1
|
||||||
|
resolution: "dom-serializer@npm:3.1.1"
|
||||||
|
dependencies:
|
||||||
|
domelementtype: "npm:^3.0.0"
|
||||||
|
domhandler: "npm:^6.0.0"
|
||||||
|
entities: "npm:^8.0.0"
|
||||||
|
checksum: 10c0/dc700204f0ef4a4c5a344bd8773703d5476dcca1a4af8b2d3fd9bcbbace833439b6ea3d3c48c4b387fa0b2456dd839caca354eed7f7c7f17bc47da8e217742ca
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"domelementtype@npm:^3.0.0":
|
||||||
|
version: 3.0.0
|
||||||
|
resolution: "domelementtype@npm:3.0.0"
|
||||||
|
checksum: 10c0/26e8ef992769c4f9bce941eb0cff7ce2ba3f1b3bf77710bb4b029055030625892e83da326cc36b1e444cf3bfdea7d1954791ee2227746387465da9929d16d954
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"domhandler@npm:6.0.1, domhandler@npm:^6.0.0":
|
||||||
|
version: 6.0.1
|
||||||
|
resolution: "domhandler@npm:6.0.1"
|
||||||
|
dependencies:
|
||||||
|
domelementtype: "npm:^3.0.0"
|
||||||
|
checksum: 10c0/8655204dd9612b55813d5880e3e87e134d6dfb2de4bd80f342b3c97f41b167576a8c66c0449c2423999953aedfcda290f7be253a6f9bf71e815afa85f939d44e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"domutils@npm:^4.0.2":
|
||||||
|
version: 4.0.2
|
||||||
|
resolution: "domutils@npm:4.0.2"
|
||||||
|
dependencies:
|
||||||
|
dom-serializer: "npm:^3.0.0"
|
||||||
|
domelementtype: "npm:^3.0.0"
|
||||||
|
domhandler: "npm:^6.0.0"
|
||||||
|
checksum: 10c0/59827827ecf15ed1f43f4cb8db374484b6089bf40e32cb41c8e381525aeb5ef5d029e4f9d5f74a418bf3217b87a6cbabdf5b4ebed0a018bc533bd6349c46a739
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1":
|
"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1":
|
||||||
version: 1.0.1
|
version: 1.0.1
|
||||||
resolution: "dunder-proto@npm:1.0.1"
|
resolution: "dunder-proto@npm:1.0.1"
|
||||||
@@ -3288,6 +3326,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"entities@npm:^8.0.0":
|
||||||
|
version: 8.0.0
|
||||||
|
resolution: "entities@npm:8.0.0"
|
||||||
|
checksum: 10c0/938e631664c19451823344a351aeeafd74fae2d5fa51e4d5b6ff635afaefd4bacf0f609989888c04c42733f46ffdac15211608267ebb02488005891a4793e94d
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"env-paths@npm:^2.2.0":
|
"env-paths@npm:^2.2.0":
|
||||||
version: 2.2.1
|
version: 2.2.1
|
||||||
resolution: "env-paths@npm:2.2.1"
|
resolution: "env-paths@npm:2.2.1"
|
||||||
@@ -4291,6 +4336,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"html-dom-parser@npm:7.1.0":
|
||||||
|
version: 7.1.0
|
||||||
|
resolution: "html-dom-parser@npm:7.1.0"
|
||||||
|
dependencies:
|
||||||
|
domhandler: "npm:6.0.1"
|
||||||
|
htmlparser2: "npm:12.0.0"
|
||||||
|
checksum: 10c0/e73b0c2e8bbe809ff877bf2483f6547f4797ee55c1c6d0f486d54ce7310e799c36986328f11dde1ce99608939a06efdf1d02c45a0abd0ec40b405b230c3dffdf
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"html-encoding-sniffer@npm:^6.0.0":
|
"html-encoding-sniffer@npm:^6.0.0":
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
resolution: "html-encoding-sniffer@npm:6.0.0"
|
resolution: "html-encoding-sniffer@npm:6.0.0"
|
||||||
@@ -4307,6 +4362,36 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"html-react-parser@npm:^6.1.0":
|
||||||
|
version: 6.1.0
|
||||||
|
resolution: "html-react-parser@npm:6.1.0"
|
||||||
|
dependencies:
|
||||||
|
domhandler: "npm:6.0.1"
|
||||||
|
html-dom-parser: "npm:7.1.0"
|
||||||
|
react-property: "npm:2.0.2"
|
||||||
|
style-to-js: "npm:1.1.21"
|
||||||
|
peerDependencies:
|
||||||
|
"@types/react": 0.14 || 15 || 16 || 17 || 18 || 19
|
||||||
|
react: 0.14 || 15 || 16 || 17 || 18 || 19
|
||||||
|
peerDependenciesMeta:
|
||||||
|
"@types/react":
|
||||||
|
optional: true
|
||||||
|
checksum: 10c0/1626454d3e3edf01b8e626b6f4a150b9ab013b4d379e5038506c93c3ce7cfb09a78abff079512ecbd4dd6d840c0bbcd55f722ee3302a70400c760e9109891b49
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"htmlparser2@npm:12.0.0":
|
||||||
|
version: 12.0.0
|
||||||
|
resolution: "htmlparser2@npm:12.0.0"
|
||||||
|
dependencies:
|
||||||
|
domelementtype: "npm:^3.0.0"
|
||||||
|
domhandler: "npm:^6.0.0"
|
||||||
|
domutils: "npm:^4.0.2"
|
||||||
|
entities: "npm:^8.0.0"
|
||||||
|
checksum: 10c0/3fcdce24c06fc4c9c42c8142d6c139104a2c30f901ce046cb0bdeaa8678445294aaf4506569464a5c853c8b1d89609f7306ea133efd966bf703f574a394dcff9
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"http-cache-semantics@npm:^4.1.1":
|
"http-cache-semantics@npm:^4.1.1":
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
resolution: "http-cache-semantics@npm:4.2.0"
|
resolution: "http-cache-semantics@npm:4.2.0"
|
||||||
@@ -4390,6 +4475,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"inline-style-parser@npm:0.2.7":
|
||||||
|
version: 0.2.7
|
||||||
|
resolution: "inline-style-parser@npm:0.2.7"
|
||||||
|
checksum: 10c0/d884d76f84959517430ae6c22f0bda59bb3f58f539f99aac75a8d786199ec594ed648c6ab4640531f9fc244b0ed5cd8c458078e592d016ef06de793beb1debff
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"internal-slot@npm:^1.1.0":
|
"internal-slot@npm:^1.1.0":
|
||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
resolution: "internal-slot@npm:1.1.0"
|
resolution: "internal-slot@npm:1.1.0"
|
||||||
@@ -5859,6 +5951,7 @@ __metadata:
|
|||||||
eslint: "npm:^9"
|
eslint: "npm:^9"
|
||||||
eslint-config-next: "npm:16.2.4"
|
eslint-config-next: "npm:16.2.4"
|
||||||
eslint-plugin-storybook: "npm:^10.3.5"
|
eslint-plugin-storybook: "npm:^10.3.5"
|
||||||
|
html-react-parser: "npm:^6.1.0"
|
||||||
jsdom: "npm:^29.0.2"
|
jsdom: "npm:^29.0.2"
|
||||||
lefthook: "npm:^2.1.6"
|
lefthook: "npm:^2.1.6"
|
||||||
next: "npm:16.2.4"
|
next: "npm:16.2.4"
|
||||||
@@ -6016,6 +6109,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"react-property@npm:2.0.2":
|
||||||
|
version: 2.0.2
|
||||||
|
resolution: "react-property@npm:2.0.2"
|
||||||
|
checksum: 10c0/27a3dfa68d29d45fc3582552715203291d26c6f1b228fdb6775e7ca19b10753141dbe98a0aa3a4da745b39fcd7427dc2d623055e63742062231ee18692a6f0fa
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"react@npm:19.2.4":
|
"react@npm:19.2.4":
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
resolution: "react@npm:19.2.4"
|
resolution: "react@npm:19.2.4"
|
||||||
@@ -6752,6 +6852,24 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"style-to-js@npm:1.1.21":
|
||||||
|
version: 1.1.21
|
||||||
|
resolution: "style-to-js@npm:1.1.21"
|
||||||
|
dependencies:
|
||||||
|
style-to-object: "npm:1.0.14"
|
||||||
|
checksum: 10c0/94231aa80f58f442c3a5ae01a21d10701e5d62f96b4b3e52eab3499077ee52df203cc0df4a1a870707f5e99470859136ea8657b782a5f4ca7934e0ffe662a588
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"style-to-object@npm:1.0.14":
|
||||||
|
version: 1.0.14
|
||||||
|
resolution: "style-to-object@npm:1.0.14"
|
||||||
|
dependencies:
|
||||||
|
inline-style-parser: "npm:0.2.7"
|
||||||
|
checksum: 10c0/854d9e9b77afc336e6d7b09348e7939f2617b34eb0895824b066d8cd1790284cb6d8b2ba36be88025b2595d715dba14b299ae76e4628a366541106f639e13679
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"styled-jsx@npm:5.1.6":
|
"styled-jsx@npm:5.1.6":
|
||||||
version: 5.1.6
|
version: 5.1.6
|
||||||
resolution: "styled-jsx@npm:5.1.6"
|
resolution: "styled-jsx@npm:5.1.6"
|
||||||
|
|||||||
Reference in New Issue
Block a user