chore: add dockerfile
This commit is contained in:
@@ -54,6 +54,7 @@ next-env.d.ts
|
|||||||
!/.vscode
|
!/.vscode
|
||||||
!/.gitattributes
|
!/.gitattributes
|
||||||
!/.gitignore
|
!/.gitignore
|
||||||
|
!/.dockerignore
|
||||||
!/biome.json
|
!/biome.json
|
||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,882 +0,0 @@
|
|||||||
# 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.
|
|
||||||
Reference in New Issue
Block a user