Compare commits
97 Commits
92e4a01641
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d439e81236 | |||
| 615f4afc2d | |||
| e5f5c7b82e | |||
| e16b88ba7e | |||
| 83ddd2724f | |||
| 7e87cbc3ae | |||
| 521aa7d05c | |||
| 532f93d896 | |||
| 9ebb515032 | |||
| cd59766f92 | |||
| ecbb76312b | |||
| 82933dedf8 | |||
| c4002ebb4f | |||
| a31cf4deec | |||
| 5b686ad87c | |||
| 43242c3bed | |||
| 7a06d42d20 | |||
| eeb7d6b4a6 | |||
| eb13328f9a | |||
| bfb0b46a37 | |||
| c7ed458c8e | |||
| 49cafe7161 | |||
| 58eae96791 | |||
| 1b0ffd41a2 | |||
| 3e520f6abb | |||
| 4d54947a91 | |||
| f121443e52 | |||
| df4526cabd | |||
| bf36a40bb5 | |||
| 886cf4b5c4 | |||
| fc588f9e66 | |||
| f08ee51332 | |||
| 9ded41db3c | |||
| 0697e9ad72 | |||
| caff3fe7e3 | |||
| 56f3f94e41 | |||
| 9deefaf3fc | |||
| a54963091c | |||
| 6b15a0e658 | |||
| 93b8adf55d | |||
| 03a90e1cf0 | |||
| 06d69a860e | |||
| 181cfdebdf | |||
| e0565d6ddc | |||
| 598d566487 | |||
| dd9cc766d5 | |||
| d1b4452867 | |||
| d62c0ad501 | |||
| cf2f1bc7f3 | |||
| 7f6e6369ff | |||
| d0f09f0dbd | |||
| 41af0b90a0 | |||
| 954b17d824 | |||
| 906ec3b805 | |||
| 4d6d78a528 | |||
| f40e9f54a3 | |||
| 7829f81d1a | |||
| cd9da6dd26 | |||
| d5ba77b4ce | |||
| 5c00f8e8a0 | |||
| cb3bdce24a | |||
| 42ca683c65 | |||
| fea6682024 | |||
| 540df57f8d | |||
| b88263a65a | |||
| 06e39b58c6 | |||
| ac9ee0eb4e | |||
| 2ae5ae3210 | |||
| f159c6e861 | |||
| b33b9f328c | |||
| c9631f9905 | |||
| ba7395cb32 | |||
| 7e542597d0 | |||
| 0552a2a8e5 | |||
| d955aeb628 | |||
| b40ff4f588 | |||
| 531de6899e | |||
| 10034ec561 | |||
| 458ee0e449 | |||
| 979e2071d1 | |||
| 37098be3c8 | |||
| 48a08ec3fb | |||
| 1550989fd9 | |||
| 782c619a91 | |||
| 543020f85c | |||
| e00c1460e1 | |||
| f874a943ff | |||
| ff62cba5b1 | |||
| f4986d6657 | |||
| e3959c0e45 | |||
| 76f5b269f8 | |||
| b8b5e65497 | |||
| e63de14515 | |||
| dfc3ed4715 | |||
| a77cd43749 | |||
| 8db4f81f70 | |||
| f1049624f7 |
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.env*.local
|
||||||
|
README.md
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
.yarn
|
||||||
|
.pnp.*
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Build and push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: docker.allmy.work
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
PB_PUBLIC_URL=${{ vars.PB_PUBLIC_URL }}
|
||||||
|
tags: |
|
||||||
|
docker.allmy.work/${{ gitea.repository }}:latest
|
||||||
|
docker.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
|
||||||
|
cache-from: type=registry,ref=docker.allmy.work/${{ gitea.repository }}:buildcache
|
||||||
|
cache-to: type=registry,ref=docker.allmy.work/${{ gitea.repository }}:buildcache,mode=max
|
||||||
@@ -54,6 +54,8 @@ next-env.d.ts
|
|||||||
!/.vscode
|
!/.vscode
|
||||||
!/.gitattributes
|
!/.gitattributes
|
||||||
!/.gitignore
|
!/.gitignore
|
||||||
|
!/.dockerignore
|
||||||
|
!/.gitea
|
||||||
!/biome.json
|
!/biome.json
|
||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
COPY package.json yarn.lock .yarnrc.yml ./
|
||||||
|
RUN yarn install --immutable
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
ARG PB_PUBLIC_URL
|
||||||
|
ENV PB_PUBLIC_URL=$PB_PUBLIC_URL
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Link } from '$shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route-level error boundary — shown when an unhandled error escapes
|
||||||
|
* the [[...slug]] segment. Mirrors the not-found layout.
|
||||||
|
*/
|
||||||
|
export default function ErrorPage() {
|
||||||
|
return (
|
||||||
|
<main className="flex-1 flex flex-col items-center justify-center gap-6">
|
||||||
|
<h1 className="font-heading text-[clamp(8rem,20vw,18rem)] leading-none">500</h1>
|
||||||
|
<Link href="/">Back to main</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,15 +8,20 @@ import { SectionsAccordion } from '$widgets/SectionsAccordion';
|
|||||||
* Optional catchall: `/` → first section, `/:slug` → that section.
|
* Optional catchall: `/` → first section, `/:slug` → that section.
|
||||||
*/
|
*/
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
|
try {
|
||||||
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
||||||
sort: 'order',
|
sort: 'order',
|
||||||
});
|
});
|
||||||
return [{}, ...sections.map((s) => ({ slug: [s.slug] }))];
|
return [{}, ...sections.map((s) => ({ slug: [s.slug] }))];
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[generateStaticParams] PocketBase unreachable at build — deferring to runtime ISR', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
params: Promise<{ slug?: string[] }>;
|
params: Promise<{ slug?: string[] }>;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Portfolio page — one route per section, sections list always visible.
|
* Portfolio page — one route per section, sections list always visible.
|
||||||
@@ -26,6 +31,7 @@ export default async function SectionPage({ params }: Props) {
|
|||||||
|
|
||||||
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
||||||
sort: 'order',
|
sort: 'order',
|
||||||
|
tags: ['sections'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sections.length === 0) {
|
if (sections.length === 0) {
|
||||||
@@ -39,7 +45,7 @@ export default async function SectionPage({ params }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="px-8 py-12 lg:py-16 lg:px-16">
|
<main className="px-4 py-6 sm:px-8 sm:py-12 lg:py-16 lg:px-16">
|
||||||
<SectionsAccordion sections={sections} activeSlug={activeSlug}>
|
<SectionsAccordion sections={sections} activeSlug={activeSlug}>
|
||||||
{sections.map((s) => (
|
{sections.map((s) => (
|
||||||
<SectionFactory key={s.slug} slug={s.slug} />
|
<SectionFactory key={s.slug} slug={s.slug} />
|
||||||
|
|||||||
@@ -5,6 +5,74 @@ export const dynamic = 'force-static';
|
|||||||
const base = { created: '', updated: '' };
|
const base = { created: '', updated: '' };
|
||||||
|
|
||||||
const FIXTURES: Record<string, unknown[]> = {
|
const FIXTURES: Record<string, unknown[]> = {
|
||||||
|
site_settings: [
|
||||||
|
{
|
||||||
|
id: 'ss1',
|
||||||
|
collectionId: 'site_settings',
|
||||||
|
collectionName: 'site_settings',
|
||||||
|
...base,
|
||||||
|
cv: '',
|
||||||
|
contacts: 'c1',
|
||||||
|
expand: {
|
||||||
|
contacts: {
|
||||||
|
id: 'c1',
|
||||||
|
collectionId: 'contacts',
|
||||||
|
collectionName: 'contacts',
|
||||||
|
...base,
|
||||||
|
email: 'hello@allmy.work',
|
||||||
|
socials: ['s1', 's2'],
|
||||||
|
expand: {
|
||||||
|
socials: [
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
collectionId: 'contact',
|
||||||
|
collectionName: 'contact',
|
||||||
|
...base,
|
||||||
|
label: 'GitHub',
|
||||||
|
url: 'https://github.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2',
|
||||||
|
collectionId: 'contact',
|
||||||
|
collectionName: 'contact',
|
||||||
|
...base,
|
||||||
|
label: 'LinkedIn',
|
||||||
|
url: 'https://linkedin.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contacts: [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
collectionId: 'contacts',
|
||||||
|
collectionName: 'contacts',
|
||||||
|
...base,
|
||||||
|
email: 'hello@allmy.work',
|
||||||
|
socials: ['s1', 's2'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contact: [
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
collectionId: 'contact',
|
||||||
|
collectionName: 'contact',
|
||||||
|
...base,
|
||||||
|
label: 'GitHub',
|
||||||
|
url: 'https://github.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2',
|
||||||
|
collectionId: 'contact',
|
||||||
|
collectionName: 'contact',
|
||||||
|
...base,
|
||||||
|
label: 'LinkedIn',
|
||||||
|
url: 'https://linkedin.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { revalidateTag } from 'next/cache';
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/revalidate
|
||||||
|
*
|
||||||
|
* Webhook endpoint for on-demand ISR. PocketBase (or any external
|
||||||
|
* caller) sends this request after mutating CMS content so the
|
||||||
|
* relevant tag is purged from the Next.js data cache.
|
||||||
|
*
|
||||||
|
* Expected body: `{ "tag": "<collection-name>" }`
|
||||||
|
* Required header: `x-revalidate-secret: <REVALIDATE_SECRET>`
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const secret = request.headers.get('x-revalidate-secret');
|
||||||
|
|
||||||
|
if (secret !== process.env.REVALIDATE_SECRET) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof body !== 'object' ||
|
||||||
|
body === null ||
|
||||||
|
!('tag' in body) ||
|
||||||
|
typeof (body as Record<string, unknown>).tag !== 'string'
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'Missing or invalid "tag" field' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = (body as { tag: string }).tag;
|
||||||
|
|
||||||
|
/* Second arg is required by the Next.js 15 type signature;
|
||||||
|
* "max" means the purge propagates indefinitely — correct for
|
||||||
|
* an on-demand webhook that has no TTL of its own. */
|
||||||
|
revalidateTag(tag, 'max');
|
||||||
|
|
||||||
|
return NextResponse.json({ revalidated: true, tag }, { status: 200 });
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,10 +1,12 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { fraunces, publicSans } from '$shared/lib';
|
import { fraunces, publicSans } from '$shared/lib';
|
||||||
|
import { Footer } from '$widgets/Footer';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Portfolio',
|
title: 'Ilia Mashkov — Portfolio',
|
||||||
description: 'Portfolio',
|
description: 'Portfolio of Ilia Mashkov, a frontend software engineer.',
|
||||||
|
icons: { icon: '/favicon.svg' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,8 +14,11 @@ export const metadata: Metadata = {
|
|||||||
*/
|
*/
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" data-scroll-behavior="smooth">
|
||||||
<body className={`${fraunces.variable} ${publicSans.variable}`}>{children}</body>
|
<body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}>
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Link } from '$shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom 404 page — shown for any unmatched route.
|
||||||
|
*/
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="flex-1 flex flex-col items-center justify-center gap-6">
|
||||||
|
<h1 className="font-heading text-[clamp(8rem,20vw,18rem)] leading-none">404</h1>
|
||||||
|
<Link href="/">Back to main</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"includes": ["src/**/*", "app/**/*", "*.ts", "*.tsx", "*.js", "*.jsx"]
|
"includes": ["src/**/*", "app/**/*", "*.ts", "*.tsx", "*.js", "*.jsx", "*.json", "*.css"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
/* output: 'export' is opt-in via STATIC_EXPORT=true.
|
/* Public PocketBase host for the image optimizer allowlist.
|
||||||
* Set this in CI/deploy — not locally — so the mock API route works
|
* Derived from PB_PUBLIC_URL (e.g. https://cms.allmy.work) at BUILD time —
|
||||||
* during development and local builds. */
|
* remotePatterns is frozen into the build, so PB_PUBLIC_URL must be present
|
||||||
const isExport = process.env.STATIC_EXPORT === 'true';
|
* during `next build` in CI (via build-arg), not just at runtime. */
|
||||||
|
const pbPublicHost = process.env.PB_PUBLIC_URL ? new URL(process.env.PB_PUBLIC_URL).hostname : '127.0.0.1';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
...(isExport ? { output: 'export' } : {}),
|
output: 'standalone',
|
||||||
images: { unoptimized: true },
|
poweredByHeader: false,
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: pbPublicHost,
|
||||||
|
pathname: '/api/files/**',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
viewTransition: true,
|
viewTransition: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "portfolio",
|
"name": "portfolio",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"packageManager": "yarn@4.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" fill="none" xmlns:v="https://vecta.io/nano"><path d="M1000 500c0 276.142-223.858 500-500 500S0 776.142 0 500 223.858 0 500 0s500 223.858 500 500z" fill="#f4f0e8"/><path d="M483.953 210.023c-12.558 3.37-26.493 11.531-155.686 92.422-49.373 30.867-48.34 29.98-55.566 40.446C263.928 355.486 170 572.083 170 579.711c0 9.934 6.365 15.611 40.083 36.543l113.023 69.361 119.56 73.619c59.866 37.075 56.081 36.011 79.305 21.464l269.398-165.863c18.923-11.53 33.545-21.819 35.61-24.835 7.053-10.289 11.526 1.419-75.177-197.084-19.611-45.058-24.428-53.573-33.546-60.846-10.493-8.16-177.018-111.581-188.715-117.08-8.946-4.257-13.935-5.322-25.805-5.854-8.085-.355-16.859 0-19.783.887zm69.156 60.491l90.487 56.411c27.352 17.03 49.028 31.576 48.168 32.286-1.72 1.774-29.245 18.981-120.592 75.215l-70.876 43.816-95.821-59.072-95.648-60.846c0-1.597 163.772-104.662 176.846-111.048 4.989-2.484 9.634-3.371 16.687-2.839 9.118.533 12.558 2.306 50.749 26.077zM386.584 450.569l96.509 59.604v115.483c0 91.535-.516 115.129-2.065 114.419-1.204-.355-22.019-12.95-46.103-27.851l-113.54-69.715-111.818-70.426c0-.709 10.321-24.835 22.88-53.395l39.222-89.761c8.774-20.755 16.687-37.785 17.375-37.785s44.556 26.786 97.54 59.427zm327.888-55.879c.688 2.128 14.278 33.882 30.449 70.602 45.588 104.308 46.62 106.969 45.071 108.388-.86.887-28.556 18.094-61.758 38.317L593.707 694.84l-75.176 45.767c-.688 0-.86-51.976-.688-115.66l.516-115.661 88.595-54.637 95.476-58.895c8.429-5.499 10.665-5.677 12.042-1.064z" fill="#041cf3"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
@@ -55,10 +55,5 @@ describe('SectionAccordion', () => {
|
|||||||
render(<SectionAccordion {...activeProps} />);
|
render(<SectionAccordion {...activeProps} />);
|
||||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('content wrapper has section-content class', () => {
|
|
||||||
const { container } = render(<SectionAccordion {...activeProps} />);
|
|
||||||
expect(container.querySelector('.section-content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,32 +33,30 @@ interface SectionAccordionProps {
|
|||||||
* Accordion-style section that collapses to a navigation link when inactive.
|
* Accordion-style section that collapses to a navigation link when inactive.
|
||||||
*/
|
*/
|
||||||
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
|
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
|
||||||
|
const heading = `${number}. ${title}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id={id} className="scroll-mt-8">
|
<section id={id} className="scroll-mt-8">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<div className="mb-12">
|
<div className="mb-6 sm:mb-12">
|
||||||
<ViewTransitionWrapper name="section-content">
|
<ViewTransitionWrapper name="section-title">
|
||||||
<div className="mb-16">
|
<div className="mb-6 sm:mb-12">
|
||||||
<h1
|
<h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</h1>
|
||||||
className="font-heading font-black text-5xl leading-[1.2] mb-0"
|
|
||||||
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
|
|
||||||
>
|
|
||||||
{number}. {title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</ViewTransitionWrapper>
|
</ViewTransitionWrapper>
|
||||||
<ViewTransitionWrapper name="section-body">
|
<ViewTransitionWrapper name="section-body">
|
||||||
<div className="section-content">{children}</div>
|
<div>{children}</div>
|
||||||
</ViewTransitionWrapper>
|
</ViewTransitionWrapper>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Link href={href} className="block w-full text-left mb-3 py-3 group border-b-0 hover:border-b-0">
|
<Link
|
||||||
<h2
|
href={href}
|
||||||
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-60 transition-opacity duration-200"
|
aria-label={heading}
|
||||||
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
|
className="block w-full text-left mb-1 py-1 sm:mb-3 sm:py-3 group border-b-0 hover:border-b-0"
|
||||||
>
|
>
|
||||||
{number}. {title}
|
<span className="block font-heading font-wonk font-black text-xl sm:text-3xl opacity-30 group-hover:opacity-60 transition-opacity duration-200">
|
||||||
</h2>
|
{heading}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const baseArgs = {
|
|||||||
period: '2021 – 2024',
|
period: '2021 – 2024',
|
||||||
description:
|
description:
|
||||||
'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.',
|
'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.',
|
||||||
|
stack: ['React', 'TypeScript', 'Next.js'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const DEFAULT_PROPS = {
|
|||||||
company: 'Acme Corp',
|
company: 'Acme Corp',
|
||||||
period: '2021 – 2024',
|
period: '2021 – 2024',
|
||||||
description: 'Built scalable frontend systems.',
|
description: 'Built scalable frontend systems.',
|
||||||
|
stack: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('ExperienceCard', () => {
|
describe('ExperienceCard', () => {
|
||||||
@@ -31,37 +32,73 @@ describe('ExperienceCard', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('structure', () => {
|
describe('layout', () => {
|
||||||
it('title is rendered as an h4', () => {
|
it('period badge is inside the sidebar column', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
|
||||||
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('period badge has brutal-border, bg-blue, text-cream, text-sm', () => {
|
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const badge = screen.getByText('2021 – 2024');
|
const badge = screen.getByText('2021 – 2024');
|
||||||
expect(badge).toHaveClass('brutal-border', 'bg-blue', 'text-cream', 'text-sm');
|
expect(badge.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('company paragraph has opacity-80', () => {
|
it('company name is inside the sidebar column', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const company = screen.getByText('Acme Corp');
|
const company = screen.getByText('Acme Corp');
|
||||||
expect(company.tagName).toBe('P');
|
expect(company.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
expect(company).toHaveClass('opacity-80');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('description paragraph has text-base and max-w-[700px]', () => {
|
it('title is outside the sidebar column', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const title = screen.getByText('Senior Developer');
|
||||||
|
expect(title.closest('.brutal-border-sidebar')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('description is outside the sidebar column', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const desc = screen.getByText('Built scalable frontend systems.');
|
const desc = screen.getByText('Built scalable frontend systems.');
|
||||||
expect(desc).toHaveClass('text-base', 'max-w-[700px]');
|
expect(desc.closest('.brutal-border-sidebar')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('card has brutal-border class (from Card component)', () => {
|
describe('structure', () => {
|
||||||
|
it('title is rendered as an h3', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Senior Developer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('period has left border accent styling', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const period = screen.getByText('2021 – 2024');
|
||||||
|
expect(period.tagName).toBe('P');
|
||||||
|
expect(period).toHaveClass('brutal-border-left', 'text-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('description renders via RichText with rich-text class', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
|
const desc = screen.getByText('Built scalable frontend systems.');
|
||||||
|
expect(desc.closest('.rich-text')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('card has brutal-border class', () => {
|
||||||
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />);
|
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
expect(container.firstChild).toHaveClass('brutal-border');
|
expect(container.firstChild).toHaveClass('brutal-border');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('stack tags', () => {
|
||||||
|
it('renders stack tags in the sidebar as xs outline badges', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} stack={['React', 'TypeScript']} />);
|
||||||
|
const react = screen.getByText('React');
|
||||||
|
const ts = screen.getByText('TypeScript');
|
||||||
|
expect(react.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
expect(ts.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
expect(react).toHaveClass('brutal-border', 'bg-transparent', 'px-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing extra when stack is empty', () => {
|
||||||
|
render(<ExperienceCard {...DEFAULT_PROPS} stack={[]} />);
|
||||||
|
expect(screen.queryByRole('list')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('className passthrough', () => {
|
describe('className passthrough', () => {
|
||||||
it('forwards className to the card', () => {
|
it('forwards className to the card', () => {
|
||||||
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />);
|
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Card } from '$shared/ui';
|
import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Job title
|
* Job title
|
||||||
*/
|
*/
|
||||||
@@ -10,33 +10,53 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
company: string;
|
company: string;
|
||||||
/**
|
/**
|
||||||
* Employment period (e.g. "2021 – 2024")
|
* Employment period (e.g. "Jan 2021 – Dec 2024")
|
||||||
*/
|
*/
|
||||||
period: string;
|
period: string;
|
||||||
/**
|
/**
|
||||||
* Description of responsibilities and achievements
|
* Description of responsibilities and achievements
|
||||||
*/
|
*/
|
||||||
description: string;
|
description: string;
|
||||||
|
/**
|
||||||
|
* Technologies used during this role
|
||||||
|
*/
|
||||||
|
stack: string[];
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes forwarded to the card
|
* Additional CSS classes forwarded to the card
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Work experience card with title, company, period, and description.
|
* Work experience card with sidebar layout.
|
||||||
|
* Sidebar: period, company, stack tags.
|
||||||
|
* Main: job title and rich-text description.
|
||||||
*/
|
*/
|
||||||
export function ExperienceCard({ title, company, period, description, className }: Props) {
|
export function ExperienceCard({ title, company, period, description, stack, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between mb-4 gap-4">
|
<CardSidebar
|
||||||
<div className="flex-1 max-w-[700px]">
|
sidebar={
|
||||||
<h4>{title}</h4>
|
<div className="flex flex-col gap-3 sm:gap-4">
|
||||||
<p className="text-base opacity-80">{company}</p>
|
<p className="text-sm font-medium brutal-border-left pl-3">{period}</p>
|
||||||
|
<p className="text-lg font-black">{company}</p>
|
||||||
|
{stack.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stack.map((tech) => (
|
||||||
|
<Badge key={tech} variant="outline" size="xs">
|
||||||
|
{tech}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="brutal-border px-4 py-2 bg-blue text-cream text-sm self-start">{period}</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base max-w-[700px]">{description}</p>
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CardTitle className="font-heading">{title}</CardTitle>
|
||||||
|
<RichText html={description} />
|
||||||
|
</div>
|
||||||
|
</CardSidebar>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Card } from '$shared/ui';
|
import { Card, RichText } from '$shared/ui';
|
||||||
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
|
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Project name
|
* Project name
|
||||||
*/
|
*/
|
||||||
@@ -20,7 +20,7 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
stack: string[];
|
stack: string[];
|
||||||
/**
|
/**
|
||||||
* Project description paragraph
|
* Project description as HTML from the PocketBase rich-text editor
|
||||||
*/
|
*/
|
||||||
description: string;
|
description: string;
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +36,7 @@ type Props = {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
reverse?: boolean;
|
reverse?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-width detailed project card with metadata sidebar.
|
* Full-width detailed project card with metadata sidebar.
|
||||||
@@ -51,7 +51,7 @@ export function DetailedProjectCard({ title, year, role, stack, description, det
|
|||||||
<div className="lg:col-span-10 order-1 lg:order-2">
|
<div className="lg:col-span-10 order-1 lg:order-2">
|
||||||
<Card>
|
<Card>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<p className="text-lg mb-6">{description}</p>
|
<RichText html={description} className="text-lg mb-6" />
|
||||||
|
|
||||||
{imageUrl && (
|
{imageUrl && (
|
||||||
<div className="brutal-border aspect-video bg-blue overflow-hidden relative">
|
<div className="brutal-border aspect-video bg-blue overflow-hidden relative">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const DEFAULT_PROPS = {
|
|||||||
year: '2024',
|
year: '2024',
|
||||||
description: 'A cool project description',
|
description: 'A cool project description',
|
||||||
tags: ['React', 'Node'],
|
tags: ['React', 'Node'],
|
||||||
|
url: 'https://example.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('ProjectCard', () => {
|
describe('ProjectCard', () => {
|
||||||
@@ -15,7 +16,7 @@ describe('ProjectCard', () => {
|
|||||||
expect(screen.getByText('My Project')).toBeInTheDocument();
|
expect(screen.getByText('My Project')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the year badge', () => {
|
it('renders the year', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByText('2024')).toBeInTheDocument();
|
expect(screen.getByText('2024')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -33,27 +34,77 @@ describe('ProjectCard', () => {
|
|||||||
|
|
||||||
it('renders the View Project button', () => {
|
it('renders the View Project button', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: /view project/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('View Project link points to the project url', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByRole('link', { name: /view project/i })).toHaveAttribute('href', 'https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('View Project link opens in a new tab', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByRole('link', { name: /view project/i })).toHaveAttribute('target', '_blank');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('layout', () => {
|
||||||
|
it('year is inside the sidebar column', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('2024').closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags are inside the sidebar column', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('React').closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Node').closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('View Project button is inside the sidebar column', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
const btn = screen.getByRole('link', { name: /view project/i });
|
||||||
|
expect(btn.closest('.brutal-border-sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title is outside the sidebar column', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('My Project').closest('.brutal-border-sidebar')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('description is outside the sidebar column', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByText('A cool project description').closest('.brutal-border-sidebar')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('structure', () => {
|
describe('structure', () => {
|
||||||
it('card has hover transition classes', () => {
|
it('card has hover transition classes', () => {
|
||||||
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const card = container.firstChild as HTMLElement;
|
expect(container.firstChild).toHaveClass('group', 'transition-shadow', 'duration-300');
|
||||||
expect(card).toHaveClass('group', 'transition-all', 'duration-300');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('year badge has correct classes', () => {
|
it('title renders as h3', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const yearBadge = screen.getByText('2024');
|
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('My Project');
|
||||||
expect(yearBadge).toHaveClass('brutal-border', 'bg-blue', 'text-cream', 'text-sm');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tags have correct classes', () => {
|
it('year has period-style left border', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
const year = screen.getByText('2024');
|
||||||
|
expect(year.tagName).toBe('P');
|
||||||
|
expect(year).toHaveClass('brutal-border-left', 'text-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('View Project button uses sm size', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
|
const btn = screen.getByRole('link', { name: /view project/i });
|
||||||
|
expect(btn).toHaveClass('px-3', 'py-1.5', 'sm:px-4', 'sm:py-2', 'text-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags are xs outline badges', () => {
|
||||||
render(<ProjectCard {...DEFAULT_PROPS} />);
|
render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const tag = screen.getByText('React');
|
const tag = screen.getByText('React');
|
||||||
expect(tag).toHaveClass('brutal-border', 'bg-cream', 'text-blue', 'text-sm', 'uppercase', 'tracking-wide');
|
expect(tag).toHaveClass('brutal-border', 'bg-transparent', 'px-2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,10 +119,16 @@ describe('ProjectCard', () => {
|
|||||||
expect(screen.getByRole('img')).toBeInTheDocument();
|
expect(screen.getByRole('img')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('image wrapper has aspect-video and overflow-hidden when imageUrl is provided', () => {
|
it('image wrapper has aspect-video and overflow-hidden', () => {
|
||||||
const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
|
||||||
const imgWrapper = container.querySelector('img')?.parentElement;
|
const imgWrapper = container.querySelector('img')?.parentElement;
|
||||||
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
|
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('image is wrapped in a lightbox button with cursor-zoom-in', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
|
||||||
|
const btn = screen.getByRole('button', { name: DEFAULT_PROPS.title });
|
||||||
|
expect(btn).toHaveClass('cursor-zoom-in');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import Image from 'next/image';
|
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '$shared/ui';
|
import { Badge, Button, Card, CardSidebar, CardTitle, ImageLightbox, RichText } from '$shared/ui';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Project name
|
* Project name
|
||||||
*/
|
*/
|
||||||
@@ -19,50 +18,57 @@ type Props = {
|
|||||||
* Technology or category tags
|
* Technology or category tags
|
||||||
*/
|
*/
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
/**
|
||||||
|
* Project's URL
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
/**
|
/**
|
||||||
* Optional preview image URL
|
* Optional preview image URL
|
||||||
*/
|
*/
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
};
|
/**
|
||||||
|
* Skip lazy-loading the preview image. Set true for above-the-fold cards
|
||||||
|
* (typically the first card in the list) to improve LCP.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
priority?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact project card for grid/list display.
|
* Project card with sidebar layout.
|
||||||
|
* Sidebar: year badge, stack tags, View Project button.
|
||||||
|
* Main: title, optional image, description.
|
||||||
*/
|
*/
|
||||||
export function ProjectCard({ title, year, description, tags, imageUrl }: Props) {
|
export function ProjectCard({ title, year, description, tags, url, imageUrl, priority = false }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
|
||||||
className={cn(
|
<CardSidebar
|
||||||
'group hover:translate-x-[2px] hover:translate-y-[2px]',
|
sidebar={
|
||||||
'hover:shadow-[10px_10px_0_var(--blue)] transition-all duration-300',
|
<div className="flex flex-col gap-4">
|
||||||
)}
|
<p className="text-sm font-medium brutal-border-left pl-3">{year}</p>
|
||||||
>
|
{tags.length > 0 && (
|
||||||
<CardHeader>
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="flex flex-row justify-between items-start mb-3">
|
|
||||||
<CardTitle className="flex-1">{title}</CardTitle>
|
|
||||||
<span className="brutal-border px-3 py-1 bg-blue text-cream text-sm">{year}</span>
|
|
||||||
</div>
|
|
||||||
<CardDescription>{description}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{imageUrl && (
|
|
||||||
<div className="brutal-border my-6 aspect-video bg-blue overflow-hidden relative">
|
|
||||||
<Image src={imageUrl} alt={title} fill className="object-cover" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<span key={tag} className="brutal-border px-3 py-1 bg-cream text-blue text-sm uppercase tracking-wide">
|
<Badge key={tag} variant="outline" size="xs">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
|
)}
|
||||||
<CardFooter>
|
<Button href={url} variant="primary" size="sm" className="self-start lg:w-full lg:self-auto text-center">
|
||||||
<Button variant="primary" className="w-full">
|
|
||||||
View Project
|
View Project
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CardTitle className="font-heading">{title}</CardTitle>
|
||||||
|
{imageUrl && (
|
||||||
|
<ImageLightbox src={imageUrl} alt={title} priority={priority} className="brutal-border bg-blue" />
|
||||||
|
)}
|
||||||
|
<RichText html={description} />
|
||||||
|
</div>
|
||||||
|
</CardSidebar>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Project year
|
* Project year
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +17,7 @@ type Props = {
|
|||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar metadata display for a project: year, role, and stack.
|
* Sidebar metadata display for a project: year, role, and stack.
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import type { ListResponse } from './types';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Native fetch wrapper for PocketBase API requests.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PB_URL =
|
|
||||||
process.env.NEXT_PUBLIC_PB_URL ||
|
|
||||||
(process.env.NODE_ENV === 'production'
|
|
||||||
? (() => {
|
|
||||||
throw new Error('NEXT_PUBLIC_PB_URL is not set');
|
|
||||||
})()
|
|
||||||
: 'http://127.0.0.1:8090');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for PocketBase collection fetching.
|
|
||||||
*/
|
|
||||||
export type PBFetchOptions = {
|
|
||||||
/**
|
|
||||||
* Sorting criteria (e.g., "-created,order")
|
|
||||||
*/
|
|
||||||
sort?: string;
|
|
||||||
/**
|
|
||||||
* Filter query string
|
|
||||||
*/
|
|
||||||
filter?: string;
|
|
||||||
/**
|
|
||||||
* Fields to expand (e.g., "stack")
|
|
||||||
*/
|
|
||||||
expand?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a list of records from a PocketBase collection.
|
|
||||||
*/
|
|
||||||
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
|
|
||||||
const { sort, filter, expand } = options;
|
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (sort) {
|
|
||||||
params.set('sort', sort);
|
|
||||||
}
|
|
||||||
if (filter) {
|
|
||||||
params.set('filter', filter);
|
|
||||||
}
|
|
||||||
if (expand) {
|
|
||||||
params.set('expand', expand);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
|
|
||||||
|
|
||||||
/* force-cache deduplicates identical fetches during the static build phase;
|
|
||||||
* it has no runtime effect in `output: 'export'` mode. */
|
|
||||||
const res = await fetch(url, { cache: 'force-cache' });
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`PocketBase ${res.status} ${res.statusText} on collection "${collection}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the first record matching an optional filter from a PocketBase collection.
|
|
||||||
*/
|
|
||||||
export async function getFirstRecord<T>(collection: string, options: PBFetchOptions = {}): Promise<T | null> {
|
|
||||||
const data = await getCollection<T>(collection, options);
|
|
||||||
return data.items[0] ?? null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PBHttpError } from '../error';
|
||||||
|
import { getCollection } from './client';
|
||||||
|
|
||||||
|
describe('getCollection', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when PocketBase is unreachable', () => {
|
||||||
|
it('returns an empty list instead of throwing', async () => {
|
||||||
|
vi.stubEnv('PB_URL', 'http://localhost:8090');
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
|
||||||
|
|
||||||
|
const result = await getCollection('projects');
|
||||||
|
|
||||||
|
expect(result.items).toEqual([]);
|
||||||
|
expect(result.totalItems).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when PocketBase returns an HTTP error', () => {
|
||||||
|
it('rethrows PBHttpError', async () => {
|
||||||
|
vi.stubEnv('PB_URL', 'http://localhost:8090');
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
statusText: 'Forbidden',
|
||||||
|
json: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(getCollection('projects')).rejects.toBeInstanceOf(PBHttpError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { PBHttpError } from '../error';
|
||||||
|
import type { ListResponse } from '../types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Native fetch wrapper for PocketBase API requests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for PocketBase collection fetching.
|
||||||
|
*/
|
||||||
|
export type PBFetchOptions = {
|
||||||
|
/**
|
||||||
|
* Sorting criteria (e.g., "-created,order")
|
||||||
|
*/
|
||||||
|
sort?: string;
|
||||||
|
/**
|
||||||
|
* Filter query string
|
||||||
|
*/
|
||||||
|
filter?: string;
|
||||||
|
/**
|
||||||
|
* Fields to expand (e.g., "stack")
|
||||||
|
*/
|
||||||
|
expand?: string;
|
||||||
|
/**
|
||||||
|
* Cache tags for on-demand revalidation via `revalidateTag`.
|
||||||
|
* Typically set to the collection name.
|
||||||
|
*/
|
||||||
|
tags?: string[];
|
||||||
|
/**
|
||||||
|
* ISR revalidation interval in seconds.
|
||||||
|
* @default 3600
|
||||||
|
*/
|
||||||
|
revalidate?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a list of records from a PocketBase collection.
|
||||||
|
*/
|
||||||
|
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
|
||||||
|
/* Required in production; falls back to localhost in development. */
|
||||||
|
const pbUrl = process.env.PB_URL ?? (process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:8090' : undefined);
|
||||||
|
|
||||||
|
if (!pbUrl) {
|
||||||
|
throw new Error('PB_URL is required in production');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sort, filter, expand, tags, revalidate } = options;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (sort) {
|
||||||
|
params.set('sort', sort);
|
||||||
|
}
|
||||||
|
if (filter) {
|
||||||
|
params.set('filter', filter);
|
||||||
|
}
|
||||||
|
if (expand) {
|
||||||
|
params.set('expand', expand);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${pbUrl}/api/collections/${collection}/records?${params.toString()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
next: {
|
||||||
|
tags: tags ?? [],
|
||||||
|
revalidate: revalidate ?? 3600,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new PBHttpError(res.status, collection, res.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PBHttpError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
console.warn(`[getCollection] "${collection}" unreachable — returning empty list`, err);
|
||||||
|
return { items: [], page: 1, perPage: 0, totalItems: 0, totalPages: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the first record matching an optional filter from a PocketBase collection.
|
||||||
|
*
|
||||||
|
* Returns null on connection failure (e.g. PocketBase unreachable during build)
|
||||||
|
* so prerendering doesn't crash. HTTP errors (4xx/5xx) are rethrown — PB is
|
||||||
|
* reachable but something is genuinely wrong, which shouldn't be silently hidden.
|
||||||
|
*/
|
||||||
|
export async function getFirstRecord<T>(collection: string, options: PBFetchOptions = {}): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const data = await getCollection<T>(collection, options);
|
||||||
|
return data.items[0] ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PBHttpError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
console.warn(`[getFirstRecord] "${collection}" unreachable — returning null`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Error thrown when PocketBase responds with a non-OK HTTP status (4xx/5xx).
|
||||||
|
*
|
||||||
|
* Distinguishes *server-responded-with-failure* from *server-unreachable*.
|
||||||
|
* A connection-level failure (ECONNREFUSED, DNS, the build-time `PB_URL`
|
||||||
|
* guard) throws a plain `Error`; only an actual HTTP response throws this.
|
||||||
|
* Callers use `instanceof PBHttpError` to decide whether to swallow the
|
||||||
|
* failure (connection — safe to ignore at build) or rethrow it (HTTP — a
|
||||||
|
* real problem that must surface).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* try {
|
||||||
|
* await getCollection('site_settings');
|
||||||
|
* } catch (err) {
|
||||||
|
* if (err instanceof PBHttpError) {
|
||||||
|
* // PB is up but returned e.g. 403 — log, alert, rethrow
|
||||||
|
* console.error(`PB returned ${err.status} for ${err.collection}`);
|
||||||
|
* } else {
|
||||||
|
* // PB unreachable — acceptable during build, render empty
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export class PBHttpError extends Error {
|
||||||
|
/**
|
||||||
|
* @param status HTTP status code returned by PocketBase (e.g. 404, 500).
|
||||||
|
* @param collection Name of the collection that was queried, for context.
|
||||||
|
* @param statusText HTTP status text from the response (e.g. "Not Found").
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly collection: string,
|
||||||
|
statusText: string,
|
||||||
|
) {
|
||||||
|
super(`PocketBase ${status} ${statusText} on collection "${collection}"`);
|
||||||
|
this.name = 'PBHttpError';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './client';
|
export * from './client/client';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ export type ExperienceRecord = BaseRecord & {
|
|||||||
* Rich text description of responsibilities and achievements
|
* Rich text description of responsibilities and achievements
|
||||||
*/
|
*/
|
||||||
description: string;
|
description: string;
|
||||||
|
/**
|
||||||
|
* Technologies used during this role
|
||||||
|
*/
|
||||||
|
stack: string[];
|
||||||
/**
|
/**
|
||||||
* Sorting weight for chronological display
|
* Sorting weight for chronological display
|
||||||
*/
|
*/
|
||||||
@@ -100,7 +104,7 @@ export type ProjectRecord = BaseRecord & {
|
|||||||
*/
|
*/
|
||||||
role: string;
|
role: string;
|
||||||
/**
|
/**
|
||||||
* Short summary of the project
|
* Project description as HTML from the PocketBase rich-text editor
|
||||||
*/
|
*/
|
||||||
description: string;
|
description: string;
|
||||||
/**
|
/**
|
||||||
@@ -115,12 +119,86 @@ export type ProjectRecord = BaseRecord & {
|
|||||||
* Primary thumbnail or hero image filename
|
* Primary thumbnail or hero image filename
|
||||||
*/
|
*/
|
||||||
image: string;
|
image: string;
|
||||||
|
/**
|
||||||
|
* Project's url
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
/**
|
/**
|
||||||
* Sorting weight for the project list
|
* Sorting weight for the project list
|
||||||
*/
|
*/
|
||||||
order: number;
|
order: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for individual social profile links.
|
||||||
|
*/
|
||||||
|
export type SocialRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* Display name shown as the link text
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* Full URL for the social profile
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* SVG markup string stored in PocketBase
|
||||||
|
*/
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for the primary contact record.
|
||||||
|
* Single-record collection — only the first record is consumed.
|
||||||
|
*/
|
||||||
|
export type ContactsRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* Raw relation ID — use expand?.email for the resolved record
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
/**
|
||||||
|
* Raw relation IDs — use expand?.socials for resolved records
|
||||||
|
*/
|
||||||
|
socials: string[];
|
||||||
|
/**
|
||||||
|
* Expanded relation data, present when fetched with expand=email,socials
|
||||||
|
*/
|
||||||
|
expand?: {
|
||||||
|
/**
|
||||||
|
* Resolved email contact record
|
||||||
|
*/
|
||||||
|
email?: SocialRecord;
|
||||||
|
/**
|
||||||
|
* Resolved social link records
|
||||||
|
*/
|
||||||
|
socials?: SocialRecord[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase collection for global site configuration.
|
||||||
|
* Single-record collection — only the first record is consumed.
|
||||||
|
*/
|
||||||
|
export type SiteSettingsRecord = BaseRecord & {
|
||||||
|
/**
|
||||||
|
* CV filename stored in PocketBase — build the full URL with buildFileUrl()
|
||||||
|
*/
|
||||||
|
cv: string;
|
||||||
|
/**
|
||||||
|
* Raw relation ID — use expand?.contacts for the resolved record
|
||||||
|
*/
|
||||||
|
contacts: string;
|
||||||
|
/**
|
||||||
|
* Expanded relation data, present when fetched with expand=contacts,contacts.socials
|
||||||
|
*/
|
||||||
|
expand?: {
|
||||||
|
/**
|
||||||
|
* Resolved contacts record
|
||||||
|
*/
|
||||||
|
contacts?: ContactsRecord;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic response for a list of PocketBase records.
|
* Generic response for a list of PocketBase records.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* CSS classes on the svg element
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close / X icon (Lucide).
|
||||||
|
*/
|
||||||
|
export function CloseIcon({ className }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden={true}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* CSS classes on the svg element
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magnify / search icon (Lucide).
|
||||||
|
*/
|
||||||
|
export function MagnifyIcon({ className }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden={true}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="m21 21-4.34-4.34" />
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { CloseIcon } from './CloseIcon';
|
||||||
|
export { MagnifyIcon } from './MagnifyIcon';
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export type { ClassValue } from 'clsx';
|
export type { ClassValue } from 'clsx';
|
||||||
export { CONTACT_LINKS } from './config/config';
|
export { CONTACT_LINKS } from './config/config';
|
||||||
export * from './fonts/fonts';
|
export * from './fonts/fonts';
|
||||||
|
export { buildFileUrl } from './utils/buildFileUrl/buildFileUrl';
|
||||||
export { cn } from './utils/cn/cn';
|
export { cn } from './utils/cn/cn';
|
||||||
export * from './utils/formatDate/formatDate';
|
export * from './utils/formatDate/formatDate';
|
||||||
export { groupByKey } from './utils/groupByKey/groupByKey';
|
export { groupByKey } from './utils/groupByKey/groupByKey';
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { buildFileUrl } from './buildFileUrl';
|
||||||
|
|
||||||
|
describe('buildFileUrl', () => {
|
||||||
|
describe('default base URL', () => {
|
||||||
|
it('builds correct URL with default base', () => {
|
||||||
|
expect(buildFileUrl('site_settings', 'ss1', 'cv_2024.pdf')).toBe(
|
||||||
|
'http://127.0.0.1:8090/api/files/site_settings/ss1/cv_2024.pdf',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('custom base URL', () => {
|
||||||
|
it('uses provided baseUrl when given', () => {
|
||||||
|
expect(buildFileUrl('photos', 'rec1', 'avatar.png', 'https://pb.example.com')).toBe(
|
||||||
|
'https://pb.example.com/api/files/photos/rec1/avatar.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('different collections, records, filenames', () => {
|
||||||
|
it('handles projects collection', () => {
|
||||||
|
expect(buildFileUrl('projects', 'proj42', 'screenshot.jpg', 'http://127.0.0.1:8090')).toBe(
|
||||||
|
'http://127.0.0.1:8090/api/files/projects/proj42/screenshot.jpg',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles contacts collection', () => {
|
||||||
|
expect(buildFileUrl('contacts', 'cid99', 'photo.webp', 'http://127.0.0.1:8090')).toBe(
|
||||||
|
'http://127.0.0.1:8090/api/files/contacts/cid99/photo.webp',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Builds a URL for a file stored in a PocketBase record.
|
||||||
|
*/
|
||||||
|
export function buildFileUrl(
|
||||||
|
collectionId: string,
|
||||||
|
recordId: string,
|
||||||
|
filename: string,
|
||||||
|
baseUrl: string = process.env.PB_PUBLIC_URL ?? 'http://127.0.0.1:8090',
|
||||||
|
): string {
|
||||||
|
return `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
|
||||||
|
}
|
||||||
@@ -1,47 +1,47 @@
|
|||||||
import { formatYearRange } from './formatDate';
|
import { formatMonthYearRange } from './formatDate';
|
||||||
|
|
||||||
describe('formatYearRange', () => {
|
describe('formatMonthYearRange', () => {
|
||||||
describe('Success Paths', () => {
|
describe('open-ended range', () => {
|
||||||
it('formats a date range within the same year', () => {
|
it('formats start date with Present when end is null', () => {
|
||||||
const start = '2024-01-01 12:00:00.000Z';
|
expect(formatMonthYearRange('2022-01-01T00:00:00Z', null)).toBe('Jan 2022 — Present');
|
||||||
const end = '2024-12-31 12:00:00.000Z';
|
|
||||||
expect(formatYearRange(start, end)).toBe('2024');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats a range between different years', () => {
|
it('uses abbreviated month name', () => {
|
||||||
const start = '2021-05-15 12:00:00.000Z';
|
expect(formatMonthYearRange('2020-08-15T00:00:00Z', null)).toBe('Aug 2020 — Present');
|
||||||
const end = '2024-03-20 12:00:00.000Z';
|
|
||||||
expect(formatYearRange(start, end)).toBe('2021 — 2024');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats a range with null end date as "Present"', () => {
|
|
||||||
const start = '2022-08-01 12:00:00.000Z';
|
|
||||||
const end = null;
|
|
||||||
expect(formatYearRange(start, end)).toBe('2022 — Present');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Error & Edge Cases', () => {
|
describe('closed range', () => {
|
||||||
|
it('formats start and end with month and year', () => {
|
||||||
|
expect(formatMonthYearRange('2021-05-01T00:00:00Z', '2024-03-31T00:00:00Z')).toBe('May 2021 — Mar 2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles same year with different months', () => {
|
||||||
|
expect(formatMonthYearRange('2024-01-01T00:00:00Z', '2024-12-31T00:00:00Z')).toBe('Jan 2024 — Dec 2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles same month and year', () => {
|
||||||
|
expect(formatMonthYearRange('2024-06-01T00:00:00Z', '2024-06-30T00:00:00Z')).toBe('Jun 2024');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error cases', () => {
|
||||||
it('throws if start date is invalid', () => {
|
it('throws if start date is invalid', () => {
|
||||||
const start = 'not-a-date';
|
expect(() => formatMonthYearRange('not-a-date', null)).toThrow('Invalid start date');
|
||||||
const end = '2024-01-01';
|
|
||||||
expect(() => formatYearRange(start, end)).toThrow('Invalid start date');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if end date is provided but invalid', () => {
|
it('throws if end date is provided but invalid', () => {
|
||||||
const start = '2024-01-01';
|
expect(() => formatMonthYearRange('2024-01-01T00:00:00Z', 'invalid')).toThrow('Invalid end date');
|
||||||
const end = 'invalid';
|
|
||||||
expect(() => formatYearRange(start, end)).toThrow('Invalid end date');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if start year is after end year', () => {
|
it('throws if start is after end', () => {
|
||||||
const start = '2024-01-01';
|
expect(() => formatMonthYearRange('2024-01-01T00:00:00Z', '2020-01-01T00:00:00Z')).toThrow(
|
||||||
const end = '2020-01-01';
|
'Start date cannot be after end date',
|
||||||
expect(() => formatYearRange(start, end)).toThrow('Start year cannot be after end year');
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty strings by throwing', () => {
|
it('throws on empty string', () => {
|
||||||
expect(() => formatYearRange('', null)).toThrow('Invalid start date');
|
expect(() => formatMonthYearRange('', null)).toThrow('Invalid start date');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
|
const MONTH_FMT = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
|
||||||
|
|
||||||
|
function formatMonthYear(date: Date): string {
|
||||||
|
return MONTH_FMT.format(date);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a PocketBase date string into a localized year string or "Present".
|
* Formats a PocketBase date string into a localized month+year range or "Present".
|
||||||
* @throws {Error} if any date is invalid or if the range is logically impossible.
|
* @throws {Error} if any date is invalid or if the range is logically impossible.
|
||||||
*/
|
*/
|
||||||
export function formatYearRange(start: string, end: string | null): string {
|
export function formatMonthYearRange(start: string, end: string | null): string {
|
||||||
const startDate = new Date(start);
|
const startDate = new Date(start);
|
||||||
if (Number.isNaN(startDate.getTime())) {
|
if (Number.isNaN(startDate.getTime())) {
|
||||||
throw new Error('Invalid start date');
|
throw new Error('Invalid start date');
|
||||||
}
|
}
|
||||||
const startYear = startDate.getFullYear();
|
|
||||||
|
|
||||||
if (end === null) {
|
if (end === null) {
|
||||||
return `${startYear} — Present`;
|
return `${formatMonthYear(startDate)} — Present`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const endDate = new Date(end);
|
const endDate = new Date(end);
|
||||||
if (Number.isNaN(endDate.getTime())) {
|
if (Number.isNaN(endDate.getTime())) {
|
||||||
throw new Error('Invalid end date');
|
throw new Error('Invalid end date');
|
||||||
}
|
}
|
||||||
const endYear = endDate.getFullYear();
|
|
||||||
|
|
||||||
if (startYear > endYear) {
|
if (startDate > endDate) {
|
||||||
throw new Error('Start year cannot be after end year');
|
throw new Error('Start date cannot be after end date');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startYear === endYear) {
|
const startLabel = formatMonthYear(startDate);
|
||||||
return `${startYear}`;
|
const endLabel = formatMonthYear(endDate);
|
||||||
|
|
||||||
|
if (startLabel === endLabel) {
|
||||||
|
return startLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${startYear} — ${endYear}`;
|
return `${startLabel} — ${endLabel}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,13 @@
|
|||||||
--font-weight-body: 600;
|
--font-weight-body: 600;
|
||||||
--font-weight-normal: 400;
|
--font-weight-normal: 400;
|
||||||
|
|
||||||
|
/* Fluid section title: scales from 2rem at ~267px to 8rem at ~1707px */
|
||||||
|
--text-section-title: clamp(2rem, 7.5vw, 8rem);
|
||||||
|
|
||||||
/* === LINE HEIGHT === */
|
/* === LINE HEIGHT === */
|
||||||
--line-height-tight: 1.2;
|
--line-height-tight: 1.2;
|
||||||
--line-height-normal: 1.5;
|
--line-height-normal: 1.5;
|
||||||
|
--line-height-relaxed: 1.65;
|
||||||
|
|
||||||
/* === FRAUNCES VARIABLE AXES === */
|
/* === FRAUNCES VARIABLE AXES === */
|
||||||
--fraunces-wonk: 1;
|
--fraunces-wonk: 1;
|
||||||
@@ -69,21 +73,29 @@
|
|||||||
--radius: 0px;
|
--radius: 0px;
|
||||||
|
|
||||||
/* === BRUTALIST SHADOWS === */
|
/* === BRUTALIST SHADOWS === */
|
||||||
--shadow-brutal: 8px 8px 0 var(--blue);
|
--shadow-brutal-xs: 1px 1px 0 var(--blue);
|
||||||
--shadow-brutal-sm: 4px 4px 0 var(--blue);
|
--shadow-brutal-sm: 3px 3px 0 var(--blue);
|
||||||
--shadow-brutal-lg: 12px 12px 0 var(--blue);
|
--shadow-brutal: 5px 5px 0 var(--blue);
|
||||||
|
--shadow-brutal-md: 7px 7px 0 var(--blue);
|
||||||
|
--shadow-brutal-lg: 8px 8px 0 var(--blue);
|
||||||
|
--shadow-brutal-xl: 10px 10px 0 var(--blue);
|
||||||
|
--shadow-brutal-2xl: 12px 12px 0 var(--blue);
|
||||||
|
|
||||||
/* === GRID === */
|
/* === GRID === */
|
||||||
--grid-gap: var(--space-3);
|
--grid-gap: var(--space-3);
|
||||||
|
--section-content-width: 72rem;
|
||||||
|
|
||||||
/* === ANIMATION === */
|
/* === ANIMATION === */
|
||||||
--ease-default: ease;
|
--ease-default: ease;
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
--ease-decelerate: cubic-bezier(0.25, 0, 0, 1);
|
--ease-decelerate: cubic-bezier(0.25, 0, 0, 1);
|
||||||
|
--ease-micro: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
--duration-fast: 100ms;
|
--duration-fast: 100ms;
|
||||||
--duration-normal: 200ms;
|
--duration-normal: 150ms;
|
||||||
--duration-slow: 350ms;
|
--duration-slow: 350ms;
|
||||||
--duration-spring: 380ms;
|
--duration-spring: 220ms;
|
||||||
|
--delay-normal: 200ms;
|
||||||
|
--slide-section-body-in: clamp(1.25rem, 5vw, 3rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -110,6 +122,18 @@
|
|||||||
--radius-sm: var(--radius);
|
--radius-sm: var(--radius);
|
||||||
--radius-md: var(--radius);
|
--radius-md: var(--radius);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
|
--container-section: var(--section-content-width);
|
||||||
|
--spacing-footer: 5rem;
|
||||||
|
--spacing-footer-wide: 4rem;
|
||||||
|
--text-section-title: var(--text-section-title);
|
||||||
|
|
||||||
|
--shadow-brutal-xs: var(--shadow-brutal-xs);
|
||||||
|
--shadow-brutal-sm: var(--shadow-brutal-sm);
|
||||||
|
--shadow-brutal: var(--shadow-brutal);
|
||||||
|
--shadow-brutal-md: var(--shadow-brutal-md);
|
||||||
|
--shadow-brutal-lg: var(--shadow-brutal-lg);
|
||||||
|
--shadow-brutal-xl: var(--shadow-brutal-xl);
|
||||||
|
--shadow-brutal-2xl: var(--shadow-brutal-2xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -117,8 +141,22 @@
|
|||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: var(--blue);
|
||||||
|
color: var(--cream);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: var(--border-width) solid var(--blue);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
/* Reserve scrollbar gutter so locking body scroll (e.g. when a modal
|
||||||
|
* opens) doesn't widen the viewport and shift fixed elements. */
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -129,30 +167,16 @@
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subtle blue-tinted grain on parchment */
|
/* Page-wide blue dot grain overlay. z-index 100 puts it above the footer
|
||||||
|
* (z-50) so the grain reads as continuous across the entire viewport;
|
||||||
|
* pointer-events: none keeps everything clickable through it. */
|
||||||
body::before {
|
body::before {
|
||||||
|
@apply grain-pattern;
|
||||||
content: "";
|
content: "";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
|
||||||
repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
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;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -196,12 +220,6 @@
|
|||||||
a {
|
a {
|
||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-bottom: 2px solid var(--blue);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
border-bottom-width: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
@@ -213,6 +231,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Button elevation transition — only transform animates; shadow snaps instantly */
|
||||||
|
.btn-transition {
|
||||||
|
transition: transform 0.13s var(--ease-micro);
|
||||||
|
}
|
||||||
|
|
||||||
/* Brutalist utility classes */
|
/* Brutalist utility classes */
|
||||||
.brutal-shadow {
|
.brutal-shadow {
|
||||||
box-shadow: var(--shadow-brutal);
|
box-shadow: var(--shadow-brutal);
|
||||||
@@ -223,47 +246,110 @@
|
|||||||
.brutal-shadow-lg {
|
.brutal-shadow-lg {
|
||||||
box-shadow: var(--shadow-brutal-lg);
|
box-shadow: var(--shadow-brutal-lg);
|
||||||
}
|
}
|
||||||
.brutal-border {
|
@utility brutal-border {
|
||||||
border: var(--border-width) solid var(--blue);
|
border: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
.brutal-border-top {
|
@utility brutal-border-top {
|
||||||
border-top: var(--border-width) solid var(--blue);
|
border-top: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
.brutal-border-bottom {
|
@utility brutal-border-bottom {
|
||||||
border-bottom: var(--border-width) solid var(--blue);
|
border-bottom: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
.brutal-border-left {
|
@utility brutal-border-left {
|
||||||
border-left: var(--border-width) solid var(--blue);
|
border-left: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
.brutal-border-right {
|
@utility brutal-border-right {
|
||||||
border-right: var(--border-width) solid var(--blue);
|
border-right: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
|
/* Border drawn as an outline — painted after children, so an image's
|
||||||
/* Section content enter animation (initial render, no navigation) */
|
* subpixel paint bleed can't cover it. Doesn't take layout space; the
|
||||||
.section-content {
|
* ancestor must not have overflow:hidden or the outline gets clipped. */
|
||||||
opacity: 1;
|
@utility brutal-outline {
|
||||||
transform: translateY(0);
|
outline: var(--border-width) solid var(--blue);
|
||||||
transition:
|
}
|
||||||
opacity var(--duration-slow) var(--ease-default),
|
/* Tiled blue dot pattern — applied to body::before (page-wide) and reusable
|
||||||
transform var(--duration-slow) var(--ease-default);
|
* on any surface that should share the same paper-grain texture. The SVG
|
||||||
|
* tile is rasterized once and composited cheaply via repeating background. */
|
||||||
|
@utility grain-pattern {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Ccircle cx='1' cy='1' r='1' fill='%23041cf3' opacity='0.10'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
/* Apply Fraunces variable axes to non-heading elements using the heading font */
|
||||||
|
.font-wonk {
|
||||||
|
font-variation-settings:
|
||||||
|
"WONK" var(--fraunces-wonk),
|
||||||
|
"SOFT" var(--fraunces-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@starting-style {
|
/* Sidebar divider: bottom border on mobile, right border on desktop */
|
||||||
.section-content {
|
.brutal-border-sidebar {
|
||||||
opacity: 0;
|
border-bottom: var(--border-width) solid var(--blue);
|
||||||
transform: translateY(12px);
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.brutal-border-sidebar {
|
||||||
|
border-bottom: none;
|
||||||
|
border-right: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Editorial rich-text typography */
|
||||||
|
.rich-text {
|
||||||
|
max-width: 65ch;
|
||||||
|
line-height: var(--line-height-relaxed);
|
||||||
|
font-feature-settings: "onum";
|
||||||
|
hanging-punctuation: first last;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text a {
|
||||||
|
border-bottom: var(--border-width) solid var(--blue);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity var(--duration-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text a:hover {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text p + p {
|
||||||
|
margin-top: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text ul li {
|
||||||
|
text-indent: -1.5em;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text ul li:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text ul li::before {
|
||||||
|
content: "◆";
|
||||||
|
display: inline-block;
|
||||||
|
width: calc(1.5em / 0.55);
|
||||||
|
/* reset inherited text-indent so glyph isn't shifted inside the ::before box */
|
||||||
|
text-indent: 0;
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 0.55em;
|
||||||
|
/* line-height matches parent so diamond centers within the first line box */
|
||||||
|
line-height: calc(var(--line-height-relaxed) / 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
/* Cross-section view transition (navigation between sections) */
|
/* Cross-section view transition (navigation between sections) */
|
||||||
::view-transition-old(section-content) {
|
::view-transition-old(section-title) {
|
||||||
animation-name: section-fade-out;
|
animation-name: section-fade-out;
|
||||||
animation-duration: var(--duration-normal);
|
animation-duration: var(--duration-normal);
|
||||||
animation-timing-function: var(--ease-default);
|
animation-timing-function: var(--ease-default);
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-new(section-content) {
|
::view-transition-new(section-title) {
|
||||||
animation-name: section-fade-in;
|
animation-name: section-fade-in;
|
||||||
animation-duration: var(--duration-spring);
|
animation-duration: var(--duration-spring);
|
||||||
animation-timing-function: var(--ease-spring);
|
animation-timing-function: var(--ease-spring);
|
||||||
@@ -292,40 +378,110 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section body: instant blur-out, clean slide-in */
|
/* Disable group geometry interpolation — OLD and NEW live at different scroll
|
||||||
|
* positions, so morphing the container drags the slide-in across the viewport.
|
||||||
|
* Let old/new each animate at their own positions instead. */
|
||||||
|
::view-transition-group(section-body) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section body — snap OLD out and slide NEW in. Running an explicit OLD
|
||||||
|
* animation left a visible ghost of the previous section's content sitting
|
||||||
|
* behind the incoming slide; hiding it immediately keeps the transition
|
||||||
|
* clean and prevents the two-content overlap. */
|
||||||
::view-transition-old(section-body) {
|
::view-transition-old(section-body) {
|
||||||
animation-name: section-body-out;
|
animation: none;
|
||||||
animation-duration: var(--duration-fast);
|
opacity: 0;
|
||||||
animation-timing-function: var(--ease-default);
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-new(section-body) {
|
::view-transition-new(section-body) {
|
||||||
animation-name: section-body-in;
|
animation-name: section-body-in;
|
||||||
animation-duration: var(--duration-slow);
|
animation-duration: var(--duration-spring);
|
||||||
animation-delay: var(--duration-normal);
|
animation-timing-function: var(--ease-spring);
|
||||||
animation-timing-function: var(--ease-decelerate);
|
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
}
|
/* Hold the start-state for this delay before the slide-in begins — gives
|
||||||
|
* the snap-out a beat to register visually before new content arrives. */
|
||||||
@keyframes section-body-out {
|
animation-delay: var(--delay-normal);
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
filter: blur(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
filter: blur(3px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes section-body-in {
|
@keyframes section-body-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(16px);
|
transform: translateX(var(--slide-section-body-in)) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page-load entry animation for the footer. Previously also carried
|
||||||
|
* `view-transition-name: site-footer` to layer above section-body's slide
|
||||||
|
* group, but the section-body group now has `animation: none` (purely
|
||||||
|
* horizontal slide of OLD/NEW snapshots), so the footer can live in the root
|
||||||
|
* snapshot during transitions — which also lets the lightbox dialog group
|
||||||
|
* stack cleanly above it. */
|
||||||
|
.footer-vt {
|
||||||
|
animation: footer-enter var(--duration-slow) var(--ease-spring) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes footer-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Lightbox dialog backdrop — flat cream wash. No filter, no gradient.
|
||||||
|
* Cheapest possible; Firefox-friendly during view transitions. */
|
||||||
|
dialog.lightbox::backdrop {
|
||||||
|
background-color: color-mix(in srgb, var(--cream) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Give the lightbox dialog its own view-transition group so it can stack
|
||||||
|
* above the footer's named group (z=10) during the open/close transition.
|
||||||
|
* Closed dialogs have `display: none` (UA) and don't get snapshotted, so
|
||||||
|
* sharing the name across all `.lightbox` dialogs at rest is harmless —
|
||||||
|
* only the open one participates in any given transition. */
|
||||||
|
dialog.lightbox {
|
||||||
|
view-transition-name: lightbox-dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-group(lightbox-dialog) {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The image wrapper (and the thumb during the OLD snapshot of an open
|
||||||
|
* transition) shares this static name — imperatively assigned only during
|
||||||
|
* the morph, so there's never more than one element holding it at a time.
|
||||||
|
* z=30 keeps it above the dialog (z=20) and the footer (z=10) during VT. */
|
||||||
|
::view-transition-group(lightbox-frame) {
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lightbox image sizing — leaves vertical headroom for the fixed close button
|
||||||
|
* (sits at top-3, ~2.5rem tall). 8rem total = ~4rem top/bottom after centering,
|
||||||
|
* so the button never overlaps the image.
|
||||||
|
* box-sizing: content-box so max-w/max-h apply to the image pixels and the
|
||||||
|
* 3px brutal-border sits outside them — avoids subpixel clipping of the
|
||||||
|
* border by the dialog's overflow:hidden when both have border-box. */
|
||||||
|
@utility lightbox-image {
|
||||||
|
box-sizing: content-box;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
max-height: calc(100vh - 10rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable transition animation for Firefox
|
||||||
|
* since it isn't supported yet */
|
||||||
|
@supports (-moz-appearance: none) {
|
||||||
|
::view-transition-group(lightbox-frame),
|
||||||
|
::view-transition-old(lightbox-frame),
|
||||||
|
::view-transition-new(lightbox-frame) {
|
||||||
|
animation-duration: 0s !important;
|
||||||
|
animation-delay: 0s !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export type { BadgeVariant } from './ui/Badge';
|
export type { BadgeSize, BadgeVariant } from './ui/Badge';
|
||||||
export { Badge } from './ui/Badge';
|
export { Badge } from './ui/Badge';
|
||||||
|
|||||||
@@ -42,6 +42,28 @@ describe('Badge', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('sizes', () => {
|
||||||
|
it('defaults to sm size', () => {
|
||||||
|
render(<Badge>Tag</Badge>);
|
||||||
|
expect(screen.getByText('Tag')).toHaveClass('px-3', 'py-1', 'text-xs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies xs size classes', () => {
|
||||||
|
render(<Badge size="xs">Tag</Badge>);
|
||||||
|
expect(screen.getByText('Tag')).toHaveClass('px-2', 'py-0.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies sm size classes', () => {
|
||||||
|
render(<Badge size="sm">Tag</Badge>);
|
||||||
|
expect(screen.getByText('Tag')).toHaveClass('px-3', 'py-1', 'text-xs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies md size classes', () => {
|
||||||
|
render(<Badge size="md">Tag</Badge>);
|
||||||
|
expect(screen.getByText('Tag')).toHaveClass('px-4', 'py-2', 'text-sm');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('className passthrough', () => {
|
describe('className passthrough', () => {
|
||||||
it('merges custom className', () => {
|
it('merges custom className', () => {
|
||||||
render(<Badge className="mt-4">Tag</Badge>);
|
render(<Badge className="mt-4">Tag</Badge>);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
|
|||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline';
|
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline';
|
||||||
|
export type BadgeSize = 'xs' | 'sm' | 'md';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -13,6 +14,11 @@ interface Props {
|
|||||||
* @default 'default'
|
* @default 'default'
|
||||||
*/
|
*/
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant;
|
||||||
|
/**
|
||||||
|
* Size preset
|
||||||
|
* @default 'sm'
|
||||||
|
*/
|
||||||
|
size?: BadgeSize;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
@@ -26,12 +32,18 @@ const VARIANTS: Record<BadgeVariant, string> = {
|
|||||||
outline: 'brutal-border bg-transparent text-blue',
|
outline: 'brutal-border bg-transparent text-blue',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SIZES: Record<BadgeSize, string> = {
|
||||||
|
xs: 'px-2 py-0.5 text-[10px]',
|
||||||
|
sm: 'px-3 py-1 text-xs',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small label for categorization or status.
|
* Small label for categorization or status.
|
||||||
*/
|
*/
|
||||||
export function Badge({ children, variant = 'default', className }: Props) {
|
export function Badge({ children, variant = 'default', size = 'sm', className }: Props) {
|
||||||
return (
|
return (
|
||||||
<span className={cn('inline-block px-3 py-1 text-xs uppercase tracking-wider', VARIANTS[variant], className)}>
|
<span className={cn('inline-block uppercase tracking-wider', SIZES[size], VARIANTS[variant], className)}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ describe('Button', () => {
|
|||||||
});
|
});
|
||||||
it('applies outline variant', () => {
|
it('applies outline variant', () => {
|
||||||
render(<Button variant="outline">Go</Button>);
|
render(<Button variant="outline">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
expect(screen.getByRole('button')).toHaveClass('bg-cream');
|
||||||
});
|
});
|
||||||
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-cream');
|
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('sizes', () => {
|
describe('sizes', () => {
|
||||||
@@ -38,7 +38,7 @@ describe('Button', () => {
|
|||||||
});
|
});
|
||||||
it('applies sm size', () => {
|
it('applies sm size', () => {
|
||||||
render(<Button size="sm">Go</Button>);
|
render(<Button size="sm">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2');
|
expect(screen.getByRole('button')).toHaveClass('px-3', 'py-1.5', 'sm:px-4', 'sm:py-2');
|
||||||
});
|
});
|
||||||
it('applies lg size', () => {
|
it('applies lg size', () => {
|
||||||
render(<Button size="lg">Go</Button>);
|
render(<Button size="lg">Go</Button>);
|
||||||
@@ -63,4 +63,31 @@ describe('Button', () => {
|
|||||||
expect(screen.getByRole('button')).toHaveClass('w-full');
|
expect(screen.getByRole('button')).toHaveClass('w-full');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('as anchor', () => {
|
||||||
|
it('renders an anchor when href is provided', () => {
|
||||||
|
render(<Button href="/cv.pdf">Download</Button>);
|
||||||
|
expect(screen.getByRole('link', { name: 'Download' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('sets href on the anchor', () => {
|
||||||
|
render(<Button href="/cv.pdf">Download</Button>);
|
||||||
|
expect(screen.getByRole('link')).toHaveAttribute('href', '/cv.pdf');
|
||||||
|
});
|
||||||
|
it('sets download attribute when provided', () => {
|
||||||
|
render(
|
||||||
|
<Button href="/cv.pdf" download>
|
||||||
|
Download
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link')).toHaveAttribute('download');
|
||||||
|
});
|
||||||
|
it('applies the same variant and size classes as button', () => {
|
||||||
|
render(
|
||||||
|
<Button href="/test" variant="primary" size="sm">
|
||||||
|
Go
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveClass('bg-blue', 'px-3', 'py-1.5', 'sm:px-4', 'sm:py-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
type BaseProps = {
|
||||||
/**
|
/**
|
||||||
* Visual variant
|
* Visual variant
|
||||||
* @default 'primary'
|
* @default 'primary'
|
||||||
@@ -19,30 +19,66 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
* Button content
|
* Button content
|
||||||
*/
|
*/
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* CSS classes
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AsButton = BaseProps & ButtonHTMLAttributes<HTMLButtonElement> & { href?: never };
|
||||||
|
type AsAnchor = BaseProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
|
||||||
|
|
||||||
|
type Props = AsButton | AsAnchor;
|
||||||
|
|
||||||
|
type RestButton = Omit<AsButton, keyof BaseProps>;
|
||||||
|
type RestAnchor = Omit<AsAnchor, keyof BaseProps>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows spread props to anchor shape when href is a non-undefined string.
|
||||||
|
*/
|
||||||
|
function isAnchorProps(props: RestButton | RestAnchor): props is RestAnchor {
|
||||||
|
return typeof props.href === 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANTS: Record<ButtonVariant, string> = {
|
const VARIANTS = {
|
||||||
primary: 'bg-blue text-cream outline-[3px] outline-cream',
|
primary:
|
||||||
secondary: 'bg-blue text-cream outline-[3px] outline-cream',
|
'brutal-border bg-blue text-cream shadow-[5px_5px_0_rgba(4,28,243,0.35)] hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_rgba(4,28,243,0.35)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-[1px_1px_0_rgba(4,28,243,0.35)]',
|
||||||
outline: 'bg-transparent text-blue border-blue',
|
secondary:
|
||||||
ghost: 'bg-cream text-blue border-blue',
|
'brutal-border bg-blue text-cream shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
|
||||||
};
|
outline:
|
||||||
|
'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
|
||||||
|
ghost:
|
||||||
|
'brutal-border bg-transparent text-blue hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5',
|
||||||
|
} as const satisfies Record<ButtonVariant, string>;
|
||||||
|
|
||||||
const SIZES: Record<ButtonSize, string> = {
|
const SIZES = {
|
||||||
sm: 'px-4 py-2 text-sm',
|
sm: 'px-3 py-1.5 sm:px-4 sm:py-2 text-sm',
|
||||||
md: 'px-6 py-3 text-base',
|
md: 'px-6 py-3 text-base',
|
||||||
lg: 'px-8 py-4 text-lg',
|
lg: 'px-8 py-4 text-lg',
|
||||||
};
|
} as const satisfies Record<ButtonSize, string>;
|
||||||
|
|
||||||
const BASE =
|
/* box-shadow excluded from transition intentionally — snaps instantly so the
|
||||||
'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';
|
* eye follows the 130ms button movement, not the shadow change. */
|
||||||
|
const BASE = 'cursor-pointer btn-transition uppercase tracking-wider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brutalist button with variants and sizes.
|
* Brutalist button with variants and sizes.
|
||||||
|
* Renders as <a> when href is provided, <button> otherwise.
|
||||||
*/
|
*/
|
||||||
export function Button({ variant = 'primary', size = 'md', className, children, ...props }: Props) {
|
export function Button({ variant = 'primary', size = 'md', className, children, ...props }: Props) {
|
||||||
|
const cls = cn(BASE, VARIANTS[variant], SIZES[size], className);
|
||||||
|
|
||||||
|
if (isAnchorProps(props)) {
|
||||||
|
const { href, ...anchorProps } = props;
|
||||||
return (
|
return (
|
||||||
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}>
|
<a href={href} rel="noopener noreferrer" target="_blank" className={cls} {...anchorProps}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={cls} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export type { CardBackground } from './ui/Card';
|
export type { CardBackground } from './ui/Card';
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/Card';
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './ui/Card';
|
||||||
|
|||||||
@@ -13,24 +13,17 @@ type Story = StoryObj<typeof Card>;
|
|||||||
export const AllBackgrounds: Story = {
|
export const AllBackgrounds: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex gap-6 flex-wrap p-8 bg-white">
|
<div className="flex gap-6 flex-wrap p-8 bg-white">
|
||||||
<Card background="ochre" className="w-64">
|
<Card background="cream" className="w-64">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Ochre Card</CardTitle>
|
<CardTitle>Cream Card</CardTitle>
|
||||||
<CardDescription>Background ochre-clay variant</CardDescription>
|
<CardDescription>Default cream background variant</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter>Footer content</CardFooter>
|
<CardFooter>Footer content</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
<Card background="slate" className="w-64">
|
<Card background="blue" className="w-64">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Slate Card</CardTitle>
|
<CardTitle>Blue Card</CardTitle>
|
||||||
<CardDescription>Background slate-indigo variant</CardDescription>
|
<CardDescription>Blue background variant</CardDescription>
|
||||||
</CardHeader>
|
|
||||||
<CardFooter>Footer content</CardFooter>
|
|
||||||
</Card>
|
|
||||||
<Card background="white" className="w-64">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>White Card</CardTitle>
|
|
||||||
<CardDescription>Background white variant</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter>Footer content</CardFooter>
|
<CardFooter>Footer content</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -40,9 +33,9 @@ export const AllBackgrounds: Story = {
|
|||||||
|
|
||||||
export const NoPadding: Story = {
|
export const NoPadding: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="p-8 bg-ochre-clay">
|
<div className="p-8">
|
||||||
<Card noPadding className="w-64 overflow-hidden">
|
<Card noPadding className="w-64 overflow-hidden">
|
||||||
<div className="h-40 bg-slate-indigo flex items-center justify-center text-ochre-clay">Image placeholder</div>
|
<div className="h-40 bg-blue flex items-center justify-center">Image placeholder</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -51,7 +44,7 @@ export const NoPadding: Story = {
|
|||||||
export const FullComposition: Story = {
|
export const FullComposition: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="p-8 bg-white max-w-md">
|
<div className="p-8 bg-white max-w-md">
|
||||||
<Card background="ochre">
|
<Card background="cream">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Full Composition</CardTitle>
|
<CardTitle>Full Composition</CardTitle>
|
||||||
<CardDescription>A card using all available slot components</CardDescription>
|
<CardDescription>A card using all available slot components</CardDescription>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
|
||||||
|
|
||||||
describe('Card', () => {
|
describe('Card', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
@@ -42,7 +42,7 @@ describe('Card', () => {
|
|||||||
describe('CardHeader', () => {
|
describe('CardHeader', () => {
|
||||||
it('renders children with bottom margin', () => {
|
it('renders children with bottom margin', () => {
|
||||||
render(<CardHeader>Header</CardHeader>);
|
render(<CardHeader>Header</CardHeader>);
|
||||||
expect(screen.getByText('Header')).toHaveClass('mb-4');
|
expect(screen.getByText('Header')).toHaveClass('mb-6');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('CardTitle', () => {
|
describe('CardTitle', () => {
|
||||||
@@ -69,6 +69,54 @@ describe('CardFooter', () => {
|
|||||||
it('renders children with top border', () => {
|
it('renders children with top border', () => {
|
||||||
render(<CardFooter>Footer</CardFooter>);
|
render(<CardFooter>Footer</CardFooter>);
|
||||||
const el = screen.getByText('Footer');
|
const el = screen.getByText('Footer');
|
||||||
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6');
|
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6', 'md:mt-8', 'md:pt-8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('CardSidebar', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders sidebar content', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
expect(screen.getByText('Sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders main content', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
expect(screen.getByText('Main')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('structure', () => {
|
||||||
|
it('root wrapper is a flex container', () => {
|
||||||
|
const { container } = render(<CardSidebar sidebar={<span>S</span>}>M</CardSidebar>);
|
||||||
|
expect(container.firstChild).toHaveClass('flex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sidebar column has brutal-border-sidebar class', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
const sidebar = screen.getByText('Sidebar').parentElement;
|
||||||
|
expect(sidebar).toHaveClass('brutal-border-sidebar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sidebar column has fixed width on lg', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
const sidebar = screen.getByText('Sidebar').parentElement;
|
||||||
|
expect(sidebar).toHaveClass('lg:w-64');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('main column fills remaining space', () => {
|
||||||
|
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
|
||||||
|
expect(screen.getByText('Main')).toHaveClass('flex-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('className passthrough', () => {
|
||||||
|
it('forwards className to the root wrapper', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CardSidebar sidebar={<span>S</span>} className="custom">
|
||||||
|
M
|
||||||
|
</CardSidebar>,
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toHaveClass('custom');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ interface SlotProps {
|
|||||||
* Card header wrapper — adds bottom margin.
|
* Card header wrapper — adds bottom margin.
|
||||||
*/
|
*/
|
||||||
export function CardHeader({ children, className }: SlotProps) {
|
export function CardHeader({ children, className }: SlotProps) {
|
||||||
return <div className={cn('mb-4', className)}>{children}</div>;
|
return <div className={cn('mb-6 md:mb-8', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,5 +83,34 @@ export function CardContent({ children, className }: SlotProps) {
|
|||||||
* Card footer — separated by a brutal border-top.
|
* Card footer — separated by a brutal border-top.
|
||||||
*/
|
*/
|
||||||
export function CardFooter({ children, className }: SlotProps) {
|
export function CardFooter({ children, className }: SlotProps) {
|
||||||
return <div className={cn('mt-6 pt-6 brutal-border-top', className)}>{children}</div>;
|
return <div className={cn('mt-6 md:mt-8 pt-6 md:pt-8 brutal-border-top', className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardSidebarProps {
|
||||||
|
/**
|
||||||
|
* Left sidebar content — metadata such as period, company, stack
|
||||||
|
*/
|
||||||
|
sidebar: ReactNode;
|
||||||
|
/**
|
||||||
|
* Main content — primary info such as role title and description
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Additional CSS classes for the root wrapper
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-column card layout: narrow sidebar on the left, main content on the right.
|
||||||
|
* On mobile the columns stack vertically with a bottom border separator;
|
||||||
|
* on md+ they sit side-by-side with a right border separator.
|
||||||
|
*/
|
||||||
|
export function CardSidebar({ sidebar, children, className }: CardSidebarProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col lg:flex-row', className)}>
|
||||||
|
<div className="shrink-0 lg:w-64 brutal-border-sidebar pb-6 lg:pb-0 lg:pr-8 mb-6 lg:mb-0">{sidebar}</div>
|
||||||
|
<div className="flex-1 min-w-0 lg:pl-8">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ImageLightbox } from './ui/ImageLightbox';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
|
||||||
|
const meta: Meta<typeof ImageLightbox> = {
|
||||||
|
title: 'Shared/ImageLightbox',
|
||||||
|
component: ImageLightbox,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof ImageLightbox>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
src: 'https://picsum.photos/800/450',
|
||||||
|
alt: 'Sample project image',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div className="p-8 max-w-2xl">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
|
||||||
|
// jsdom does not implement dialog methods — mock them
|
||||||
|
beforeAll(() => {
|
||||||
|
HTMLDialogElement.prototype.showModal = vi.fn();
|
||||||
|
HTMLDialogElement.prototype.close = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_PROPS = { src: '/project.jpg', alt: 'My Project' };
|
||||||
|
|
||||||
|
describe('ImageLightbox', () => {
|
||||||
|
describe('thumbnail', () => {
|
||||||
|
it('renders a thumbnail image', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByRole('img', { name: 'My Project' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thumbnail button has cursor-zoom-in', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const btn = screen.getByRole('button', { name: 'My Project' });
|
||||||
|
expect(btn).toHaveClass('cursor-zoom-in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards className to the thumbnail button', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} className="extra-class" />);
|
||||||
|
expect(screen.getByRole('button', { name: 'My Project' })).toHaveClass('extra-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dialog', () => {
|
||||||
|
it('clicking the thumbnail opens the dialog', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
|
||||||
|
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the close button closes the dialog', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' })); // open first
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /close/i, hidden: true }));
|
||||||
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the backdrop (dialog element itself) closes the dialog', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent.click(dialog, { target: dialog });
|
||||||
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking inside the dialog (not backdrop) does not close', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
const inner = dialog.querySelector('img') as HTMLImageElement;
|
||||||
|
fireEvent.click(inner);
|
||||||
|
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dialog has accessible label matching alt text', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My Project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening the lightbox blocks body scroll', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
|
||||||
|
expect(document.body.style.overflow).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing the lightbox restores body scroll', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('close'));
|
||||||
|
expect(document.body.style.overflow).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close button is positioned fixed', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const closeBtn = screen.getByRole('button', { name: /close/i, hidden: true });
|
||||||
|
expect(closeBtn).toHaveClass('fixed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { type SyntheticEvent, useRef } from 'react';
|
||||||
|
import { CloseIcon } from '$shared/assets/icons';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
import { Button } from '$shared/ui/Button';
|
||||||
|
import { Modal, type ModalHandle } from '$shared/ui/Modal';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* Image source URL
|
||||||
|
*/
|
||||||
|
src: string;
|
||||||
|
/**
|
||||||
|
* Image alt text, also used as the dialog accessible label
|
||||||
|
*/
|
||||||
|
alt: string;
|
||||||
|
/**
|
||||||
|
* CSS classes forwarded to the thumbnail button wrapper
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Skip lazy-loading and preload the thumbnail. Set true for above-the-fold
|
||||||
|
* images to improve LCP.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
priority?: boolean;
|
||||||
|
/**
|
||||||
|
* Responsive `sizes` attribute for the thumbnail. Without this, next/image
|
||||||
|
* `fill` defaults to `100vw` and the browser fetches the largest srcset
|
||||||
|
* variant. Tune to match the actual rendered width at each breakpoint.
|
||||||
|
* @default '(min-width: 1024px) 56rem, 100vw'
|
||||||
|
*/
|
||||||
|
sizes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clickable image thumbnail that opens a fullscreen brutalist dialog on click.
|
||||||
|
*
|
||||||
|
* Uses the View Transitions API to morph the thumbnail's rect into the dialog
|
||||||
|
* frame's rect (and back on close). The view-transition-name is seated
|
||||||
|
* just-in-time around each transition rather than living on the thumb at rest
|
||||||
|
* — a persistent name would isolate the thumb from any parent transition
|
||||||
|
* (e.g. the section-body slide-in), causing the image to snap into place
|
||||||
|
* while the rest of the section animates.
|
||||||
|
*/
|
||||||
|
export function ImageLightbox({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
priority = false,
|
||||||
|
sizes = '(min-width: 1024px) 56rem, 100vw',
|
||||||
|
}: Props) {
|
||||||
|
const modalRef = useRef<ModalHandle>(null);
|
||||||
|
const thumbRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dialogFrameRef = useRef<HTMLDivElement>(null);
|
||||||
|
/* Shared static name across all instances. Only one dialog can be open at a
|
||||||
|
* time (showModal is browser-exclusive), and we set the name imperatively
|
||||||
|
* only during a transition — so at any snapshot, exactly one element has it. */
|
||||||
|
const vtName = 'lightbox-frame';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the view-transition-name from both thumb and dialog frame. Called
|
||||||
|
* after the lightbox close transition settles (and as a safety net on any
|
||||||
|
* unexpected close path) so the thumb rejoins parent transitions.
|
||||||
|
*/
|
||||||
|
function clearVtNames() {
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
if (dialogFrameRef.current) {
|
||||||
|
dialogFrameRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `mutate` inside a view transition when supported; falls back to a
|
||||||
|
* plain synchronous call otherwise (Firefox without VT support, jsdom).
|
||||||
|
* Returns the transition handle so callers can await `finished` for cleanup.
|
||||||
|
*/
|
||||||
|
function withTransition(mutate: () => void): { finished: Promise<void> } | null {
|
||||||
|
const doc = document as Document & {
|
||||||
|
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
||||||
|
};
|
||||||
|
if (typeof doc.startViewTransition === 'function') {
|
||||||
|
return doc.startViewTransition(mutate);
|
||||||
|
}
|
||||||
|
mutate();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
/* Seat the name on the thumb *before* startViewTransition so it's
|
||||||
|
* captured in the OLD snapshot. The thumb otherwise carries no vt-name. */
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = vtName;
|
||||||
|
}
|
||||||
|
withTransition(() => {
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
if (dialogFrameRef.current) {
|
||||||
|
dialogFrameRef.current.style.viewTransitionName = vtName;
|
||||||
|
}
|
||||||
|
modalRef.current?.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
const transition = withTransition(() => {
|
||||||
|
if (dialogFrameRef.current) {
|
||||||
|
dialogFrameRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = vtName;
|
||||||
|
}
|
||||||
|
modalRef.current?.close();
|
||||||
|
});
|
||||||
|
/* Drop the name from the thumb once the transition settles. Otherwise the
|
||||||
|
* thumb stays its own snapshot until the next open, isolated from any
|
||||||
|
* parent transition that runs in the meantime. */
|
||||||
|
if (transition) {
|
||||||
|
transition.finished.finally(clearVtNames);
|
||||||
|
} else {
|
||||||
|
clearVtNames();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept ESC so it also runs through our view-transition-wrapped close.
|
||||||
|
* Without this, ESC would snap the dialog away without the morph.
|
||||||
|
*/
|
||||||
|
function handleCancel(e: SyntheticEvent<HTMLDialogElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={thumbRef}
|
||||||
|
type="button"
|
||||||
|
onClick={open}
|
||||||
|
aria-label={alt}
|
||||||
|
className={cn('relative block w-full aspect-video overflow-hidden cursor-zoom-in', className)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
fill
|
||||||
|
loading={priority ? 'eager' : undefined}
|
||||||
|
priority={priority}
|
||||||
|
sizes={sizes}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
ref={modalRef}
|
||||||
|
aria-label={alt}
|
||||||
|
className="lightbox bg-cream overflow-visible"
|
||||||
|
onClose={clearVtNames}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onBackdropClose={close}
|
||||||
|
>
|
||||||
|
{/* Wrapper carries the border as an `outline` (not `border`) — paint
|
||||||
|
* order is border→children→outline, so an `outline` is drawn ON TOP
|
||||||
|
* of any subpixel image bleed and stays fully visible. The dialog
|
||||||
|
* uses `overflow-visible` because outline can be clipped by an
|
||||||
|
* ancestor's overflow:hidden.
|
||||||
|
* The wrapper is the named VT element so the brutalist frame
|
||||||
|
* participates in the morph.
|
||||||
|
* aria-hidden: the dialog element itself carries the accessible label. */}
|
||||||
|
<div ref={dialogFrameRef} className="brutal-outline block w-fit">
|
||||||
|
{/* Explicit width/height are placeholders next/image requires when not using `fill`; CSS
|
||||||
|
* (`lightbox-image` max-w/max-h + `w-auto h-auto`) drives the actual rendered size, so
|
||||||
|
* the dialog still hugs the image's intrinsic dimensions (capped at viewport bounds).
|
||||||
|
* `sizes="100vw"` hints the browser to fetch the srcset variant matching viewport width. */}
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={2000}
|
||||||
|
height={2000}
|
||||||
|
loading="lazy"
|
||||||
|
sizes="100vw"
|
||||||
|
aria-hidden={true}
|
||||||
|
className="lightbox-image block w-auto h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={close} aria-label="Close image" className="fixed top-3 right-3">
|
||||||
|
<CloseIcon />
|
||||||
|
</Button>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { InlineSvg } from './ui/InlineSvg';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import parse from 'html-react-parser';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* SVG markup string to inline as React elements
|
||||||
|
*/
|
||||||
|
svg: string;
|
||||||
|
/**
|
||||||
|
* Additional CSS classes on the wrapper span
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an SVG markup string into React elements.
|
||||||
|
* Inherits color from parent via currentColor.
|
||||||
|
*/
|
||||||
|
export function InlineSvg({ svg, className }: Props) {
|
||||||
|
if (!svg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className={cn('inline-flex items-center', className)}>{parse(svg)}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type { LinkVariant } from './ui/Link/Link';
|
||||||
|
export { Link } from './ui/Link/Link';
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
|
import { Link } from './Link';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Link> = {
|
||||||
|
title: 'Shared/Link',
|
||||||
|
component: Link,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Link>;
|
||||||
|
|
||||||
|
const decorator = (Story: React.ComponentType) => (
|
||||||
|
<div className="p-8 bg-cream">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {
|
||||||
|
href: '/about',
|
||||||
|
children: 'Internal page',
|
||||||
|
},
|
||||||
|
decorators: [decorator],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PrimaryExternal: Story = {
|
||||||
|
args: {
|
||||||
|
href: 'https://example.com',
|
||||||
|
external: true,
|
||||||
|
children: 'External site',
|
||||||
|
},
|
||||||
|
decorators: [decorator],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Secondary: Story = {
|
||||||
|
args: {
|
||||||
|
href: 'https://github.com',
|
||||||
|
external: true,
|
||||||
|
variant: 'secondary',
|
||||||
|
children: 'GitHub',
|
||||||
|
},
|
||||||
|
decorators: [decorator],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecondaryWithIcon: Story = {
|
||||||
|
args: {
|
||||||
|
href: 'https://github.com',
|
||||||
|
external: true,
|
||||||
|
variant: 'secondary',
|
||||||
|
className: 'flex items-center gap-1.5 text-sm',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<span className="hidden sm:block">GitHub</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
decorators: [decorator],
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
vi.mock('next/link', () => ({
|
||||||
|
default: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => (
|
||||||
|
<a href={href} className={className}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import type React from 'react';
|
||||||
|
import { Link } from './Link';
|
||||||
|
|
||||||
|
const PRIMARY = 'underline underline-offset-2 hover:opacity-60 transition-opacity';
|
||||||
|
const SECONDARY = 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity';
|
||||||
|
|
||||||
|
describe('internal link', () => {
|
||||||
|
it('renders an anchor element', () => {
|
||||||
|
render(<Link href="/about">About</Link>);
|
||||||
|
expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct href', () => {
|
||||||
|
render(<Link href="/about">About</Link>);
|
||||||
|
expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not have target attribute', () => {
|
||||||
|
render(<Link href="/about">About</Link>);
|
||||||
|
expect(screen.getByRole('link', { name: 'About' })).not.toHaveAttribute('target');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies primary classes by default', () => {
|
||||||
|
render(<Link href="/about">About</Link>);
|
||||||
|
const link = screen.getByRole('link', { name: 'About' });
|
||||||
|
for (const cls of PRIMARY.split(' ')) {
|
||||||
|
expect(link).toHaveClass(cls);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('external link', () => {
|
||||||
|
it('has target="_blank"', () => {
|
||||||
|
render(
|
||||||
|
<Link href="https://example.com" external>
|
||||||
|
External
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('target', '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has rel="noopener noreferrer"', () => {
|
||||||
|
render(
|
||||||
|
<Link href="https://example.com" external>
|
||||||
|
External
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct href', () => {
|
||||||
|
render(
|
||||||
|
<Link href="https://example.com" external>
|
||||||
|
External
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('href', 'https://example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('variant', () => {
|
||||||
|
it('primary applies underline classes', () => {
|
||||||
|
render(
|
||||||
|
<Link href="/about" variant="primary">
|
||||||
|
About
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
const link = screen.getByRole('link', { name: 'About' });
|
||||||
|
for (const cls of PRIMARY.split(' ')) {
|
||||||
|
expect(link).toHaveClass(cls);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('secondary applies secondary classes', () => {
|
||||||
|
render(
|
||||||
|
<Link href="/about" variant="secondary">
|
||||||
|
About
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
const link = screen.getByRole('link', { name: 'About' });
|
||||||
|
for (const cls of SECONDARY.split(' ')) {
|
||||||
|
expect(link).toHaveClass(cls);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('secondary does not apply underline', () => {
|
||||||
|
render(
|
||||||
|
<Link href="/about" variant="secondary">
|
||||||
|
About
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: 'About' })).not.toHaveClass('underline');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('className passthrough', () => {
|
||||||
|
it('merges custom className with variant classes', () => {
|
||||||
|
render(
|
||||||
|
<Link href="/about" className="text-red-500">
|
||||||
|
Styled
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
const link = screen.getByRole('link', { name: 'Styled' });
|
||||||
|
expect(link).toHaveClass('text-red-500');
|
||||||
|
for (const cls of PRIMARY.split(' ')) {
|
||||||
|
expect(link).toHaveClass(cls);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import NextLink from 'next/link';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
|
export type LinkVariant = 'primary' | 'secondary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for Link.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Destination URL. Use a path (e.g. /about) for internal routes, or a full URL for external.
|
||||||
|
*/
|
||||||
|
href: string;
|
||||||
|
/**
|
||||||
|
* Link content
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* CSS classes
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Visual variant.
|
||||||
|
* primary — text-decoration underline.
|
||||||
|
* secondary — border-bottom at sm+, no underline on mobile (for icon+label links).
|
||||||
|
* @default 'primary'
|
||||||
|
*/
|
||||||
|
variant?: LinkVariant;
|
||||||
|
/**
|
||||||
|
* When true, renders a plain <a> with target="_blank" rel="noopener noreferrer".
|
||||||
|
* Use for links that open outside the app.
|
||||||
|
*/
|
||||||
|
external?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANTS = {
|
||||||
|
primary: 'underline underline-offset-2 hover:opacity-60 transition-opacity',
|
||||||
|
secondary: 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity',
|
||||||
|
} as const satisfies Record<LinkVariant, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline text link.
|
||||||
|
* Renders as Next.js Link for internal routes, plain <a> for external links.
|
||||||
|
*/
|
||||||
|
export function Link({ href, children, className, variant = 'primary', external }: Props) {
|
||||||
|
const cls = cn(VARIANTS[variant], className);
|
||||||
|
|
||||||
|
if (external) {
|
||||||
|
return (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" className={cls}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NextLink href={href} className={cls}>
|
||||||
|
{children}
|
||||||
|
</NextLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Modal, type ModalHandle } from './ui/Modal';
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { Button } from '../../Button';
|
||||||
|
import { Modal, type ModalHandle } from './Modal';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Modal> = {
|
||||||
|
title: 'Shared/Modal',
|
||||||
|
component: Modal,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Modal>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperative trigger — Storybook can't drive a ref-based API directly, so each
|
||||||
|
* story renders its own trigger button that calls `modalRef.current?.open()`.
|
||||||
|
*/
|
||||||
|
function TriggerWrapper({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||||
|
const modalRef = useRef<ModalHandle>(null);
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<Button onClick={() => modalRef.current?.open()}>Open modal</Button>
|
||||||
|
<Modal ref={modalRef} aria-label="Story modal" className={className}>
|
||||||
|
<div className="p-8 max-w-md">
|
||||||
|
{children}
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => modalRef.current?.close()}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => (
|
||||||
|
<TriggerWrapper className="bg-cream brutal-border">
|
||||||
|
<h3 className="mb-2">Default modal</h3>
|
||||||
|
<p>Native dialog with scroll lock, backdrop-click close, and ESC close.</p>
|
||||||
|
</TriggerWrapper>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Brutalist: Story = {
|
||||||
|
render: () => (
|
||||||
|
<TriggerWrapper className="bg-blue text-cream brutal-border">
|
||||||
|
<h3 className="mb-2 text-cream">Brutalist modal</h3>
|
||||||
|
<p className="text-cream">
|
||||||
|
Blue background, hard border. Style is fully driven by the className you pass — Modal stays neutral.
|
||||||
|
</p>
|
||||||
|
</TriggerWrapper>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { createRef } from 'react';
|
||||||
|
import { Modal, type ModalHandle } from './Modal';
|
||||||
|
|
||||||
|
// jsdom does not implement dialog methods — mock them
|
||||||
|
beforeAll(() => {
|
||||||
|
HTMLDialogElement.prototype.showModal = vi.fn();
|
||||||
|
HTMLDialogElement.prototype.close = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(<Modal aria-label="Test">child content</Modal>);
|
||||||
|
expect(screen.getByText('child content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards className to dialog element', () => {
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test" className="extra-class">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
expect(document.querySelector('dialog')).toHaveClass('extra-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-label on dialog', () => {
|
||||||
|
render(<Modal aria-label="My modal">x</Modal>);
|
||||||
|
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My modal');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('imperative API', () => {
|
||||||
|
it('open() calls showModal', () => {
|
||||||
|
const ref = createRef<ModalHandle>();
|
||||||
|
render(
|
||||||
|
<Modal ref={ref} aria-label="Test">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
ref.current?.open();
|
||||||
|
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close() calls dialog.close', () => {
|
||||||
|
const ref = createRef<ModalHandle>();
|
||||||
|
render(
|
||||||
|
<Modal ref={ref} aria-label="Test">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
ref.current?.close();
|
||||||
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scroll lock', () => {
|
||||||
|
it('locks body scroll on open()', () => {
|
||||||
|
const ref = createRef<ModalHandle>();
|
||||||
|
render(
|
||||||
|
<Modal ref={ref} aria-label="Test">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
ref.current?.open();
|
||||||
|
expect(document.body.style.overflow).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores body scroll when the dialog close event fires', () => {
|
||||||
|
const ref = createRef<ModalHandle>();
|
||||||
|
render(
|
||||||
|
<Modal ref={ref} aria-label="Test">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
ref.current?.open();
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('close'));
|
||||||
|
expect(document.body.style.overflow).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('backdrop interaction', () => {
|
||||||
|
it('closes on backdrop click (dialog element itself)', () => {
|
||||||
|
render(<Modal aria-label="Test">x</Modal>);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent.click(dialog, { target: dialog });
|
||||||
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not close on click inside content', () => {
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test">
|
||||||
|
<span>inner</span>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('inner'));
|
||||||
|
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes backdrop click through onBackdropClose when provided (skips default close)', () => {
|
||||||
|
const onBackdropClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test" onBackdropClose={onBackdropClose}>
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent.click(dialog, { target: dialog });
|
||||||
|
expect(onBackdropClose).toHaveBeenCalledOnce();
|
||||||
|
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('callbacks', () => {
|
||||||
|
it('invokes onClose when close event fires', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test" onClose={onClose}>
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('close'));
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onCancel when cancel event fires', () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test" onCancel={onCancel}>
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('cancel'));
|
||||||
|
expect(onCancel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DialogHTMLAttributes,
|
||||||
|
forwardRef,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type MouseEvent,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
|
export type ModalHandle = {
|
||||||
|
/**
|
||||||
|
* Opens the dialog as a modal and locks body scroll.
|
||||||
|
*/
|
||||||
|
open: () => void;
|
||||||
|
/**
|
||||||
|
* Closes the dialog. Body scroll is restored on the native `close` event,
|
||||||
|
* so any close path (ESC, backdrop, this method) restores it.
|
||||||
|
*/
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Props extends DialogHTMLAttributes<HTMLDialogElement> {
|
||||||
|
/**
|
||||||
|
* Called when the user activates the backdrop (click or Enter/Space).
|
||||||
|
* Replaces the default close — useful when the close path must be wrapped
|
||||||
|
* (e.g. in a view transition). When omitted, the dialog closes itself.
|
||||||
|
*/
|
||||||
|
onBackdropClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper over native `<dialog>` with `showModal()`. Locks body scroll
|
||||||
|
* while open, restores it on any close path, and treats backdrop clicks /
|
||||||
|
* Enter|Space on the backdrop as a close intent. Style the backdrop externally
|
||||||
|
* via a className on this component + a `::backdrop` selector.
|
||||||
|
*
|
||||||
|
* All standard dialog attributes pass through — `aria-label`, `onClose`,
|
||||||
|
* `onCancel`, etc. Use `onCancel` with `e.preventDefault()` to intercept ESC
|
||||||
|
* (e.g. to route close through a view transition wrapper).
|
||||||
|
*/
|
||||||
|
export const Modal = forwardRef<ModalHandle, Props>(function Modal(
|
||||||
|
{ className, onClick, onKeyUp, onBackdropClose, children, ...rest },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
/* Either run consumer-supplied close path (e.g. view-transition-wrapped) or
|
||||||
|
* fall back to closing the dialog directly. Shared between click and keyboard
|
||||||
|
* backdrop activation. */
|
||||||
|
function triggerBackdropClose() {
|
||||||
|
if (onBackdropClose) {
|
||||||
|
onBackdropClose();
|
||||||
|
} else {
|
||||||
|
dialogRef.current?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
open: () => {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
dialogRef.current?.close();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = dialogRef.current;
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Scroll restore lives on the native event listener (not a React onClose
|
||||||
|
* prop) so it can't be unintentionally overridden by a caller that passes
|
||||||
|
* their own onClose. Both fire for the same close event. */
|
||||||
|
const handleClose = () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
el.addEventListener('close', handleClose);
|
||||||
|
return () => el.removeEventListener('close', handleClose);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the dialog when the user clicks the backdrop area directly.
|
||||||
|
* Target===currentTarget distinguishes the <dialog> element itself (the
|
||||||
|
* backdrop hit-area) from its content children. Caller's onClick still runs.
|
||||||
|
*/
|
||||||
|
function handleClick(e: MouseEvent<HTMLDialogElement>) {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
triggerBackdropClose();
|
||||||
|
}
|
||||||
|
onClick?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard equivalent of backdrop click — Enter/Space on the backdrop area
|
||||||
|
* closes the dialog. ESC is handled natively by `showModal()`.
|
||||||
|
*/
|
||||||
|
function handleKeyUp(e: KeyboardEvent<HTMLDialogElement>) {
|
||||||
|
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
triggerBackdropClose();
|
||||||
|
}
|
||||||
|
onKeyUp?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
{...rest}
|
||||||
|
className={cn('fixed inset-0 m-auto p-0 overflow-hidden', className)}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,29 +1,25 @@
|
|||||||
import parse from 'html-react-parser';
|
import parse from 'html-react-parser';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* HTML string from PocketBase rich-text editor
|
* HTML string from PocketBase rich-text editor
|
||||||
*/
|
*/
|
||||||
html: string;
|
html: string;
|
||||||
/**
|
/**
|
||||||
* CSS classes applied to the wrapper div
|
* Additional CSS classes merged onto the wrapper div
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a PocketBase rich-text HTML string as React elements.
|
* Renders a PocketBase rich-text HTML string as React elements.
|
||||||
|
* Always applies editorial magazine typography via the rich-text CSS class.
|
||||||
*/
|
*/
|
||||||
export function RichText({ html, className }: Props) {
|
export function RichText({ html, className }: Props) {
|
||||||
if (!html) {
|
if (!html) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parse(html);
|
return <div className={cn('rich-text', className)}>{parse(html)}</div>;
|
||||||
|
|
||||||
if (className) {
|
|
||||||
return <div className={className}>{parsed}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{parsed}</>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,27 +13,18 @@ type Story = StoryObj<typeof Section>;
|
|||||||
export const AllBackgrounds: Story = {
|
export const AllBackgrounds: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div>
|
<div>
|
||||||
<Section background="ochre" className="py-12">
|
<Section background="cream" className="py-12">
|
||||||
<Container>
|
<Container>
|
||||||
<h2>Ochre Section</h2>
|
<h2>Cream Section</h2>
|
||||||
<p>
|
<p>
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||||
dolore magna aliqua.
|
dolore magna aliqua.
|
||||||
</p>
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
<Section background="slate" className="py-12">
|
<Section background="blue" className="py-12">
|
||||||
<Container>
|
<Container>
|
||||||
<h2>Slate Section</h2>
|
<h2>Blue Section</h2>
|
||||||
<p>
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
|
||||||
dolore magna aliqua.
|
|
||||||
</p>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
<Section background="white" className="py-12">
|
|
||||||
<Container>
|
|
||||||
<h2>White Section</h2>
|
|
||||||
<p>
|
<p>
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||||
dolore magna aliqua.
|
dolore magna aliqua.
|
||||||
@@ -46,7 +37,7 @@ export const AllBackgrounds: Story = {
|
|||||||
|
|
||||||
export const Bordered: Story = {
|
export const Bordered: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<Section background="ochre" bordered className="py-12">
|
<Section background="cream" bordered className="py-12">
|
||||||
<Container>
|
<Container>
|
||||||
<h2>Bordered Section</h2>
|
<h2>Bordered Section</h2>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Fragment, type ReactNode, ViewTransition as VT } from 'react';
|
|||||||
*/
|
*/
|
||||||
const Transition = (VT ?? Fragment) as typeof VT;
|
const Transition = (VT ?? Fragment) as typeof VT;
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Maps to the view-transition-name CSS property
|
* Maps to the view-transition-name CSS property
|
||||||
*/
|
*/
|
||||||
@@ -15,7 +15,7 @@ type Props = {
|
|||||||
* Content to animate
|
* Content to animate
|
||||||
*/
|
*/
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps children in React's ViewTransition when available,
|
* Wraps children in React's ViewTransition when available,
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
export type { BadgeVariant } from './Badge';
|
export type { BadgeSize, BadgeVariant } from './Badge';
|
||||||
export { Badge } from './Badge';
|
export { Badge } from './Badge';
|
||||||
export type { ButtonSize, ButtonVariant } from './Button';
|
export type { ButtonSize, ButtonVariant } from './Button';
|
||||||
export { Button } from './Button';
|
export { Button } from './Button';
|
||||||
export type { CardBackground } from './Card';
|
export type { CardBackground } from './Card';
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
|
||||||
|
export { ImageLightbox } from './ImageLightbox';
|
||||||
|
export { InlineSvg } from './InlineSvg';
|
||||||
export { Input, Textarea } from './Input';
|
export { Input, Textarea } from './Input';
|
||||||
|
export type { LinkVariant } from './Link';
|
||||||
|
export { Link } from './Link';
|
||||||
|
export { Modal, type ModalHandle } from './Modal';
|
||||||
export { RichText } from './RichText';
|
export { RichText } from './RichText';
|
||||||
export type { ContainerSize, SectionBackground } from './Section';
|
export type { ContainerSize, SectionBackground } from './Section';
|
||||||
export { Container, Section } from './Section';
|
export { Container, Section } from './Section';
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { RichText } from '$shared/ui';
|
|||||||
* Displays personal biography content from PocketBase.
|
* Displays personal biography content from PocketBase.
|
||||||
*/
|
*/
|
||||||
export default async function BioSection() {
|
export default async function BioSection() {
|
||||||
const data = await getFirstRecord<PageContentRecord>('bio');
|
const data = await getFirstRecord<PageContentRecord>('bio', { tags: ['bio'] });
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RichText html={data.content} className="prose prose-lg max-w-none" />;
|
return <RichText html={data.content} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const mockItems = [
|
|||||||
start_date: '2022-01-01T00:00:00Z',
|
start_date: '2022-01-01T00:00:00Z',
|
||||||
end_date: null,
|
end_date: null,
|
||||||
description: 'Built critical systems.',
|
description: 'Built critical systems.',
|
||||||
|
stack: ['React', 'TypeScript'],
|
||||||
order: 1,
|
order: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -31,6 +32,7 @@ const mockItems = [
|
|||||||
start_date: '2020-01-01T00:00:00Z',
|
start_date: '2020-01-01T00:00:00Z',
|
||||||
end_date: '2021-12-31T00:00:00Z',
|
end_date: '2021-12-31T00:00:00Z',
|
||||||
description: 'Learned the ropes.',
|
description: 'Learned the ropes.',
|
||||||
|
stack: [],
|
||||||
order: 2,
|
order: 2,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -63,12 +65,12 @@ describe('ExperienceSection', () => {
|
|||||||
|
|
||||||
it('formats open-ended period as "Present"', async () => {
|
it('formats open-ended period as "Present"', async () => {
|
||||||
render(await ExperienceSection());
|
render(await ExperienceSection());
|
||||||
expect(screen.getByText('2022 — Present')).toBeInTheDocument();
|
expect(screen.getByText('Jan 2022 — Present')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats closed period with year range', async () => {
|
it('formats closed period with month and year range', async () => {
|
||||||
render(await ExperienceSection());
|
render(await ExperienceSection());
|
||||||
expect(screen.getByText('2020 — 2021')).toBeInTheDocument();
|
expect(screen.getByText('Jan 2020 — Dec 2021')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders description text', async () => {
|
it('renders description text', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ExperienceCard } from '$entities/experience';
|
import { ExperienceCard } from '$entities/experience';
|
||||||
import type { ExperienceRecord } from '$shared/api';
|
import type { ExperienceRecord } from '$shared/api';
|
||||||
import { getCollection } from '$shared/api';
|
import { getCollection } from '$shared/api';
|
||||||
import { formatYearRange } from '$shared/lib';
|
import { formatMonthYearRange } from '$shared/lib';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Experience section component.
|
* Experience section component.
|
||||||
@@ -10,17 +10,19 @@ import { formatYearRange } from '$shared/lib';
|
|||||||
export default async function ExperienceSection() {
|
export default async function ExperienceSection() {
|
||||||
const { items } = await getCollection<ExperienceRecord>('experience', {
|
const { items } = await getCollection<ExperienceRecord>('experience', {
|
||||||
sort: 'order',
|
sort: 'order',
|
||||||
|
tags: ['experience'],
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 max-w-section">
|
||||||
{items.map((exp) => (
|
{items.map((exp) => (
|
||||||
<ExperienceCard
|
<ExperienceCard
|
||||||
key={exp.id}
|
key={exp.id}
|
||||||
title={exp.role}
|
title={exp.role}
|
||||||
company={exp.company}
|
company={exp.company}
|
||||||
period={formatYearRange(exp.start_date, exp.end_date)}
|
period={formatMonthYearRange(exp.start_date, exp.end_date)}
|
||||||
description={exp.description}
|
description={exp.description}
|
||||||
|
stack={exp.stack}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Footer } from './ui/Footer/Footer';
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
vi.mock('$shared/api', () => ({
|
||||||
|
getFirstRecord: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { getFirstRecord } from '$shared/api';
|
||||||
|
import { Footer } from './Footer';
|
||||||
|
|
||||||
|
const mockSettings = {
|
||||||
|
id: 'ss1',
|
||||||
|
collectionId: 'site_settings',
|
||||||
|
collectionName: 'site_settings',
|
||||||
|
created: '',
|
||||||
|
updated: '',
|
||||||
|
cv: 'cv_2024.pdf',
|
||||||
|
contacts: 'c1',
|
||||||
|
expand: {
|
||||||
|
contacts: {
|
||||||
|
id: 'c1',
|
||||||
|
collectionId: 'contacts',
|
||||||
|
collectionName: 'contacts',
|
||||||
|
created: '',
|
||||||
|
updated: '',
|
||||||
|
email: 'e1',
|
||||||
|
socials: ['s1'],
|
||||||
|
expand: {
|
||||||
|
email: {
|
||||||
|
id: 'e1',
|
||||||
|
collectionId: 'contact',
|
||||||
|
collectionName: 'contact',
|
||||||
|
created: '',
|
||||||
|
updated: '',
|
||||||
|
label: 'hello@allmy.work',
|
||||||
|
url: 'mailto:hello@allmy.work',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
socials: [
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
collectionId: 'contact',
|
||||||
|
collectionName: 'contact',
|
||||||
|
created: '',
|
||||||
|
updated: '',
|
||||||
|
label: 'GitHub',
|
||||||
|
url: 'https://github.com',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Footer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getFirstRecord).mockResolvedValue(mockSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('structure', () => {
|
||||||
|
it('renders a footer element', async () => {
|
||||||
|
const { container } = render(await Footer());
|
||||||
|
expect(container.querySelector('footer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has brutal-border-top separator', async () => {
|
||||||
|
const { container } = render(await Footer());
|
||||||
|
expect(container.querySelector('footer')).toHaveClass('brutal-border-top');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('email link', () => {
|
||||||
|
it('renders the contact email link', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
const link = screen.getByRole('link', { name: /hello@allmy\.work/i });
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('email link points to the mailto url', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
const link = screen.getByRole('link', { name: /hello@allmy\.work/i });
|
||||||
|
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render email link when expand.email is missing', async () => {
|
||||||
|
vi.mocked(getFirstRecord).mockResolvedValue({
|
||||||
|
...mockSettings,
|
||||||
|
expand: {
|
||||||
|
contacts: {
|
||||||
|
...mockSettings.expand.contacts,
|
||||||
|
expand: {
|
||||||
|
...mockSettings.expand.contacts.expand,
|
||||||
|
email: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.queryByRole('link', { name: /hello@allmy\.work/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('social links', () => {
|
||||||
|
it('renders GitHub social link with correct href', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
const link = screen.getByRole('link', { name: 'GitHub' });
|
||||||
|
expect(link).toHaveAttribute('href', 'https://github.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('social links have target="_blank"', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
const link = screen.getByRole('link', { name: 'GitHub' });
|
||||||
|
expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render social links when expand.socials is empty', async () => {
|
||||||
|
vi.mocked(getFirstRecord).mockResolvedValue({
|
||||||
|
...mockSettings,
|
||||||
|
expand: {
|
||||||
|
contacts: {
|
||||||
|
...mockSettings.expand.contacts,
|
||||||
|
expand: {
|
||||||
|
...mockSettings.expand.contacts.expand,
|
||||||
|
socials: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.queryByRole('link', { name: 'GitHub' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CV download', () => {
|
||||||
|
it('renders a CV download link when cv is available', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.getByRole('link', { name: /download cv/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CV link points to the PocketBase file URL', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.getByRole('link', { name: /download cv/i })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'http://127.0.0.1:8090/api/files/site_settings/ss1/cv_2024.pdf',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CV link has download attribute', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.getByRole('link', { name: /download cv/i })).toHaveAttribute('download');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CV link has button styling', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
const link = screen.getByRole('link', { name: /download cv/i });
|
||||||
|
expect(link).toHaveClass('brutal-border', 'uppercase');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render CV link when no cv field', async () => {
|
||||||
|
vi.mocked(getFirstRecord).mockResolvedValue({ ...mockSettings, cv: '' });
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.queryByRole('link', { name: /download cv/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render CV link when settings record is missing', async () => {
|
||||||
|
vi.mocked(getFirstRecord).mockResolvedValue(null);
|
||||||
|
render(await Footer());
|
||||||
|
expect(screen.queryByRole('link', { name: /download cv/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { SiteSettingsRecord } from '$shared/api';
|
||||||
|
import { getFirstRecord } from '$shared/api';
|
||||||
|
import { buildFileUrl } from '$shared/lib';
|
||||||
|
import { Button, InlineSvg, Link } from '$shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site-wide footer with contact email, social links, and CV download.
|
||||||
|
* All contact data is fetched from the site_settings CMS collection with nested expand.
|
||||||
|
*/
|
||||||
|
export async function Footer() {
|
||||||
|
const settings = await getFirstRecord<SiteSettingsRecord>('site_settings', {
|
||||||
|
expand: 'contacts,contacts.email,contacts.socials',
|
||||||
|
tags: ['site_settings'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cvUrl = settings?.cv ? buildFileUrl(settings.collectionId, settings.id, settings.cv) : null;
|
||||||
|
const contacts = settings?.expand?.contacts;
|
||||||
|
const email = contacts?.expand?.email;
|
||||||
|
const socials = contacts?.expand?.socials ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="footer-vt fixed bottom-0 left-0 right-0 z-50 h-footer md:h-footer-wide brutal-border-top bg-background px-4 sm:px-8 lg:px-16 flex items-center">
|
||||||
|
<div className="w-full flex flex-row justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-6 sm:gap-4">
|
||||||
|
{email && (
|
||||||
|
<Link href={email.url} external variant="secondary" className="flex items-center gap-1.5 text-sm">
|
||||||
|
{email.icon && <InlineSvg svg={email.icon} className="inline-flex w-8 h-8 sm:hidden" />}
|
||||||
|
<span className="hidden sm:block">{email.label}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{socials.map((social) => (
|
||||||
|
<Link
|
||||||
|
key={social.id}
|
||||||
|
href={social.url}
|
||||||
|
external
|
||||||
|
variant="secondary"
|
||||||
|
className="flex items-center gap-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{social.icon && <InlineSvg svg={social.icon} className="inline-flex w-8 h-8 sm:hidden" />}
|
||||||
|
<span className="hidden sm:block">{social.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{cvUrl && (
|
||||||
|
<Button href={cvUrl} download size="sm" className="self-start sm:self-auto">
|
||||||
|
Download CV
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,11 +8,11 @@ import { RichText } from '$shared/ui';
|
|||||||
* Displays primary introduction content from PocketBase.
|
* Displays primary introduction content from PocketBase.
|
||||||
*/
|
*/
|
||||||
export default async function IntroSection() {
|
export default async function IntroSection() {
|
||||||
const data = await getFirstRecord<PageContentRecord>('intro');
|
const data = await getFirstRecord<PageContentRecord>('intro', { tags: ['intro'] });
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RichText html={data.content} className="prose prose-lg max-w-none" />;
|
return <RichText html={data.content} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export type { NavItem } from './model/types';
|
|
||||||
export { MobileNav } from './ui/MobileNav';
|
|
||||||
export { SidebarNav } from './ui/SidebarNav';
|
|
||||||
export { UtilityBar } from './ui/UtilityBar';
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export type NavItem = {
|
|
||||||
/**
|
|
||||||
* Section HTML id for anchor scrolling
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/**
|
|
||||||
* Display label
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
/**
|
|
||||||
* Display number prefix (e.g. "01")
|
|
||||||
*/
|
|
||||||
number: string;
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
||||||
import { MobileNav } from './MobileNav';
|
|
||||||
|
|
||||||
// MobileNav is lg:hidden — it renders only on mobile viewports.
|
|
||||||
// Use the viewport toolbar in Storybook to switch to a mobile size to see it.
|
|
||||||
const meta: Meta<typeof MobileNav> = {
|
|
||||||
title: 'Widgets/MobileNav',
|
|
||||||
component: MobileNav,
|
|
||||||
parameters: {
|
|
||||||
viewport: {
|
|
||||||
defaultViewport: 'mobile1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof MobileNav>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
items: [
|
|
||||||
{ id: 'bio', label: 'Bio', number: '01' },
|
|
||||||
{ id: 'work', label: 'Work', number: '02' },
|
|
||||||
{ id: 'contact', label: 'Contact', number: '03' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import type { NavItem } from '../model/types';
|
|
||||||
import { MobileNav } from './MobileNav';
|
|
||||||
|
|
||||||
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('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: /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', () => {
|
|
||||||
it('click toggle shows 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.getByText('Intro')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes menu when pathname changes', async () => {
|
|
||||||
const { usePathname } = await import('next/navigation');
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/');
|
|
||||||
const { rerender } = render(<MobileNav items={ITEMS} />);
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
|
||||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
|
||||||
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/bio');
|
|
||||||
rerender(<MobileNav items={ITEMS} />);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { cn } from '$shared/lib';
|
|
||||||
import type { NavItem } from '../model/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for MobileNav.
|
|
||||||
*/
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Navigation items to render
|
|
||||||
*/
|
|
||||||
items: NavItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mobile navigation overlay, hidden on lg+ screens.
|
|
||||||
* Closes automatically when the URL pathname changes after navigation.
|
|
||||||
*/
|
|
||||||
export function MobileNav({ items }: Props) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger, not a value used inside the callback
|
|
||||||
useEffect(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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">
|
|
||||||
<h4>allmy.work</h4>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen((prev) => !prev)}
|
|
||||||
className="brutal-border px-4 py-2 bg-blue text-cream"
|
|
||||||
>
|
|
||||||
{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}`} className="block w-full brutal-border bg-cream 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
||||||
import { SidebarNav } from './SidebarNav';
|
|
||||||
|
|
||||||
// SidebarNav is hidden lg:block — it renders only on desktop viewports.
|
|
||||||
// Use the viewport toolbar in Storybook to switch to a desktop size to see it.
|
|
||||||
const meta: Meta<typeof SidebarNav> = {
|
|
||||||
title: 'Widgets/SidebarNav',
|
|
||||||
component: SidebarNav,
|
|
||||||
parameters: {
|
|
||||||
layout: 'fullscreen',
|
|
||||||
viewport: {
|
|
||||||
defaultViewport: 'desktop',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof SidebarNav>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
items: [
|
|
||||||
{ id: 'bio', label: 'Bio', number: '01' },
|
|
||||||
{ id: 'work', label: 'Work', number: '02' },
|
|
||||||
{ id: 'contact', label: 'Contact', number: '03' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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 nav 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 (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,87 +0,0 @@
|
|||||||
'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';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for SidebarNav.
|
|
||||||
*/
|
|
||||||
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-cream 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-cream px-6 py-4 transition-all duration-300',
|
|
||||||
isActive(item)
|
|
||||||
? 'shadow-[12px_12px_0_var(--blue)] 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
||||||
import { UtilityBar } from './UtilityBar';
|
|
||||||
|
|
||||||
const meta: Meta<typeof UtilityBar> = {
|
|
||||||
title: 'Widgets/UtilityBar',
|
|
||||||
component: UtilityBar,
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<div className="relative h-24 bg-ochre-clay">
|
|
||||||
<Story />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof UtilityBar>;
|
|
||||||
|
|
||||||
export const Default: Story = {};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { UtilityBar } from './UtilityBar';
|
|
||||||
|
|
||||||
describe('UtilityBar', () => {
|
|
||||||
describe('rendering', () => {
|
|
||||||
it('renders "Contact" label', () => {
|
|
||||||
render(<UtilityBar />);
|
|
||||||
expect(screen.getByText('Contact')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders email link with correct href', () => {
|
|
||||||
render(<UtilityBar />);
|
|
||||||
const link = screen.getByRole('link', { name: 'hello@allmy.work' });
|
|
||||||
expect(link).toBeInTheDocument();
|
|
||||||
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders "Download CV" button', () => {
|
|
||||||
render(<UtilityBar />);
|
|
||||||
expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Download CV button has primary variant class', () => {
|
|
||||||
render(<UtilityBar />);
|
|
||||||
const btn = screen.getByRole('button', { name: /download cv/i });
|
|
||||||
expect(btn).toHaveClass('bg-blue');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { CONTACT_LINKS } from '$shared/lib';
|
|
||||||
import { Button } from '$shared/ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixed bottom utility bar with contact info and CV download.
|
|
||||||
*/
|
|
||||||
export function UtilityBar() {
|
|
||||||
/**
|
|
||||||
* Handles CV download action.
|
|
||||||
*/
|
|
||||||
function handleDownloadCV() {
|
|
||||||
console.log('Downloading CV...');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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="flex items-center gap-4">
|
|
||||||
<span className="text-sm uppercase tracking-wider">Contact</span>
|
|
||||||
<a href={`mailto:${CONTACT_LINKS.email}`} className="text-base hover:opacity-60 transition-opacity">
|
|
||||||
{CONTACT_LINKS.email}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Button variant="primary" size="sm" onClick={handleDownloadCV}>
|
|
||||||
Download CV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,7 @@
|
|||||||
import { ProjectCard } from '$entities/project';
|
import { ProjectCard } from '$entities/project';
|
||||||
import type { ProjectRecord } from '$shared/api';
|
import type { ProjectRecord } from '$shared/api';
|
||||||
import { getCollection } from '$shared/api';
|
import { getCollection } from '$shared/api';
|
||||||
|
import { buildFileUrl } from '$shared/lib';
|
||||||
/** Base URL for PocketBase file storage */
|
|
||||||
const PB_URL = process.env.NEXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a PocketBase file URL for a project image.
|
|
||||||
*/
|
|
||||||
function buildImageUrl(project: ProjectRecord): string | undefined {
|
|
||||||
if (!project.image) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return `${PB_URL}/api/files/${project.collectionId}/${project.id}/${project.image}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Projects section component.
|
* Projects section component.
|
||||||
@@ -22,18 +10,26 @@ function buildImageUrl(project: ProjectRecord): string | undefined {
|
|||||||
export default async function ProjectsSection() {
|
export default async function ProjectsSection() {
|
||||||
const { items } = await getCollection<ProjectRecord>('projects', {
|
const { items } = await getCollection<ProjectRecord>('projects', {
|
||||||
sort: 'order',
|
sort: 'order',
|
||||||
|
tags: ['projects'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Mark the first project that actually has an image as LCP-priority.
|
||||||
|
* Using `index === 0` alone misses the case where the first card has no
|
||||||
|
* image and the LCP candidate ends up being the next card's image. */
|
||||||
|
const lcpIndex = items.findIndex((project) => project.image);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="space-y-6 max-w-section">
|
||||||
{items.map((project) => (
|
{items.map((project, index) => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
title={project.title}
|
title={project.title}
|
||||||
year={project.year}
|
year={project.year}
|
||||||
description={project.description}
|
description={project.description}
|
||||||
tags={project.stack}
|
tags={project.stack}
|
||||||
imageUrl={buildImageUrl(project)}
|
url={project.url}
|
||||||
|
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
|
||||||
|
priority={index === lcpIndex}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { Component } from 'react';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* Section content to render
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
/**
|
||||||
|
* Whether an error was caught
|
||||||
|
*/
|
||||||
|
hasError: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Isolates a single section's render errors so a broken section
|
||||||
|
* does not crash the rest of the page.
|
||||||
|
*/
|
||||||
|
export class SectionErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): State {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.error('[SectionErrorBoundary]', error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import ExperienceSection from '../../../ExperienceSection/ui/ExperienceSection/E
|
|||||||
import IntroSection from '../../../IntroSection/ui/IntroSection/IntroSection';
|
import IntroSection from '../../../IntroSection/ui/IntroSection/IntroSection';
|
||||||
import ProjectsSection from '../../../ProjectsSection/ui/ProjectsSection/ProjectsSection';
|
import ProjectsSection from '../../../ProjectsSection/ui/ProjectsSection/ProjectsSection';
|
||||||
import SkillsSection from '../../../SkillsSection/ui/SkillsSection/SkillsSection';
|
import SkillsSection from '../../../SkillsSection/ui/SkillsSection/SkillsSection';
|
||||||
|
import { SectionErrorBoundary } from '../SectionErrorBoundary/SectionErrorBoundary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the SectionFactory widget.
|
* Props for the SectionFactory widget.
|
||||||
@@ -36,5 +37,9 @@ export function SectionFactory({ slug }: SectionFactoryProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Component />;
|
return (
|
||||||
|
<SectionErrorBoundary>
|
||||||
|
<Component />
|
||||||
|
</SectionErrorBoundary>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Ordered section metadata — drives navigation labels and IDs
|
* Ordered section metadata — drives navigation labels and IDs
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +17,7 @@ type Props = {
|
|||||||
* Pre-rendered RSC content slots, one per section, matched by index
|
* Pre-rendered RSC content slots, one per section, matched by index
|
||||||
*/
|
*/
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders all portfolio sections as an accordion list.
|
* Renders all portfolio sections as an accordion list.
|
||||||
@@ -28,7 +28,7 @@ export function SectionsAccordion({ sections, activeSlug, children }: Props) {
|
|||||||
const slots = Children.toArray(children);
|
const slots = Children.toArray(children);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-0 sm:space-y-2">
|
||||||
{sections.map((section, i) => (
|
{sections.map((section, i) => (
|
||||||
<SectionAccordion
|
<SectionAccordion
|
||||||
key={section.slug}
|
key={section.slug}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Badge } from '$shared/ui';
|
|||||||
export default async function SkillsSection() {
|
export default async function SkillsSection() {
|
||||||
const data = await getCollection<SkillRecord>('skills', {
|
const data = await getCollection<SkillRecord>('skills', {
|
||||||
sort: 'category,order',
|
sort: 'category,order',
|
||||||
|
tags: ['skills'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data.items.length) {
|
if (!data.items.length) {
|
||||||
@@ -20,10 +21,10 @@ export default async function SkillsSection() {
|
|||||||
const categories = groupByKey(data.items, 'category');
|
const categories = groupByKey(data.items, 'category');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12 max-w-section">
|
||||||
{Object.entries(categories).map(([category, items]) => (
|
{Object.entries(categories).map(([category, items]) => (
|
||||||
<div key={category} className="space-y-4">
|
<div key={category} className="space-y-4">
|
||||||
<h3 className="text-xl font-bold uppercase tracking-widest opacity-50">{category}</h3>
|
<h3 className="text-xl font-bold uppercase tracking-widest opacity-60">{category}</h3>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{items.map((skill) => (
|
{items.map((skill) => (
|
||||||
<Badge key={skill.id}>{skill.name}</Badge>
|
<Badge key={skill.id}>{skill.name}</Badge>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from './Navigation';
|
export * from './Footer';
|
||||||
|
|||||||
@@ -35,13 +35,6 @@
|
|||||||
"$routes": ["./src/routes"]
|
"$routes": ["./src/routes"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
|
||||||
"next-env.d.ts",
|
"exclude": ["node_modules", "**/*.stories.ts", "**/*.stories.tsx"]
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts",
|
|
||||||
"**/*.mts"
|
|
||||||
],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import path from 'node:path';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||