Compare commits

...

59 Commits

Author SHA1 Message Date
ilia d439e81236 Merge pull request 'fix: ul styles for rich text' (#9) from fixes/visuals into main
Build and push / build (push) Successful in 4m37s
Reviewed-on: #9
2026-05-26 12:40:28 +00:00
Ilia Mashkov 615f4afc2d fix: ul styles for rich text 2026-05-26 15:36:29 +03:00
ilia e5f5c7b82e Merge pull request 'Feature/image dialog' (#8) from feature/image-dialog into main
Build and push / build (push) Successful in 1m7s
Reviewed-on: #8
2026-05-23 10:16:54 +00:00
Ilia Mashkov e16b88ba7e chore: remove outdated code 2026-05-23 13:14:06 +03:00
Ilia Mashkov 83ddd2724f chore: enforce common prop typing style 2026-05-23 13:06:56 +03:00
Ilia Mashkov 7e87cbc3ae chore: experience card responsive style tweak 2026-05-23 13:00:56 +03:00
Ilia Mashkov 521aa7d05c feat: create new grain-pattern utility and use it for body 2026-05-23 12:53:37 +03:00
Ilia Mashkov 532f93d896 fix: disable lightbox animation in firefox since it isnt supported yet 2026-05-23 12:52:16 +03:00
Ilia Mashkov 9ebb515032 feat(projects): prioritize LCP image, fix View Project button on mobile
- ProjectCard accepts a `priority` prop forwarded to the thumbnail Image so
  above-the-fold cards skip lazy-loading and get a preload hint.
- ProjectsSection marks the first card *with an image* as priority; handles
  the case where the first project has no image and the LCP candidate ends
  up being a later card.
- View Project button drops `w-full` on mobile (collapsed sidebar above the
  card body), using `self-start` + `text-center` instead so it sizes to its
  content. Restores column-filling on lg+ where the sidebar is its own
  narrow column.
2026-05-23 09:54:43 +03:00
Ilia Mashkov cd59766f92 feat(ImageLightbox): morph to dialog via View Transitions, fix sizing and stacking
- Use the shared Modal for dialog mechanics; ImageLightbox keeps only the
  view-transition coordination (just-in-time view-transition-name handoff
  between thumb and dialog frame, ESC routed through onCancel).
- Switch to <Image> for both thumb and dialog image; thumb is priority/sizes-
  driven for LCP, dialog image is lazy-loaded (deferred until open).
- New brutal-outline utility for the dialog frame: outline paints after
  children so subpixel image bleed can't cover it; dialog gets overflow-
  visible so the outline isn't clipped.
- lightbox-image utility caps image dimensions to viewport minus close-button
  and footer headroom, with box-sizing: content-box.
- Lightbox dialog gets view-transition-name lightbox-dialog (z=20) and the
  frame gets lightbox-frame (z=30) so both stack cleanly above the page
  during the open/close transition. Footer drops its named VT group since
  section-body no longer slides over it.
- Cream-tinted backdrop replaces the blur (Firefox-friendly), color-mix
  with var(--cream) for the token reference.
- scrollbar-gutter: stable on html so locking body scroll doesn't shift
  the layout.
2026-05-23 09:54:20 +03:00
Ilia Mashkov ecbb76312b refactor(section): snap-out old section-body on navigation
The explicit fade-and-slide-left OLD animation left a visible ghost of the
previous section's content behind the incoming slide. Replacing it with an
instant opacity:0 keeps the transition clean while preserving the NEW
slide-in delay so the snap-out has a beat to register. Drops the now-dead
keyframes and --slide-section-body-out token.
2026-05-23 09:53:26 +03:00
Ilia Mashkov 82933dedf8 feat(shared): add Modal component
Native <dialog>+showModal() wrapper with imperative open()/close() via ref,
body scroll lock, and backdrop-click/keyboard-close behaviors. Exposes
onCancel and onBackdropClose escape hatches for consumers that need to
wrap the close path (e.g. in a view transition).
2026-05-23 09:50:42 +03:00
Ilia Mashkov c4002ebb4f chore: use node:path protocol in vitest config 2026-05-23 09:50:29 +03:00
Ilia Mashkov a31cf4deec fix: opt Next.js out of smooth scroll for route transitions
Adds data-scroll-behavior="smooth" to <html> so Next disables the global
scroll-behavior during programmatic route-change scrolls while keeping
smooth behavior for user-driven anchor jumps. Silences the Next 15 warning.
2026-05-22 15:30:42 +03:00
Ilia Mashkov 5b686ad87c fix: SectionAccordion animation misbehave 2026-05-22 14:22:08 +03:00
Ilia Mashkov 43242c3bed refactor: swap Button ghost/outline semantics, clean up ImageLightbox thumbnail
ghost now means transparent bg (no fill); outline keeps cream bg with subtle border.
Remove magnify icon overlay from ImageLightbox thumbnail — hover cursor-zoom-in is sufficient.
Close button updated to variant="outline" for cream-on-blue contrast in the dialog.
2026-05-22 14:22:08 +03:00
Ilia Mashkov 7a06d42d20 feat: add icon components and update ImageLightbox with icons and Button 2026-05-22 12:48:54 +03:00
Ilia Mashkov eeb7d6b4a6 feat: use ImageLightbox in ProjectCard 2026-05-22 12:18:00 +03:00
Ilia Mashkov eb13328f9a feat: add lightbox backdrop CSS and export ImageLightbox 2026-05-22 12:05:42 +03:00
Ilia Mashkov bfb0b46a37 feat: implement ImageLightbox with tests 2026-05-22 12:04:11 +03:00
Ilia Mashkov c7ed458c8e test: ImageLightbox failing tests 2026-05-22 10:14:53 +03:00
Ilia Mashkov 49cafe7161 feat: ImageLightbox placeholder 2026-05-21 20:28:28 +03:00
ilia 58eae96791 Merge pull request 'Features/visual improvements' (#7) from features/visual-improvements into main
Build and push / build (push) Successful in 1m5s
Reviewed-on: #7
2026-05-21 15:16:16 +00:00
Ilia Mashkov 1b0ffd41a2 chore: changes for deployment to get the pocketbase public url variable value 2026-05-21 18:15:16 +03:00
Ilia Mashkov 3e520f6abb fix: use https protocol in next config 2026-05-21 18:01:03 +03:00
Ilia Mashkov 4d54947a91 fix: tweak slide section animation 2026-05-21 18:00:12 +03:00
Ilia Mashkov f121443e52 fix: add footer z index in transition group to stay above main content during transitions 2026-05-21 17:59:40 +03:00
Ilia Mashkov df4526cabd chore: remove unused favicon.ico 2026-05-21 17:07:58 +03:00
Ilia Mashkov bf36a40bb5 chore: swap the favicon 2026-05-21 16:57:30 +03:00
Ilia Mashkov 886cf4b5c4 feat: add spring slide animation for section content 2026-05-21 16:36:00 +03:00
Ilia Mashkov fc588f9e66 feat: set page title and description 2026-05-21 16:01:42 +03:00
Ilia Mashkov f08ee51332 chore: remove unused files 2026-05-21 15:58:48 +03:00
Ilia Mashkov 9ded41db3c feat: set the favicon 2026-05-21 15:58:11 +03:00
Ilia Mashkov 0697e9ad72 feat: add error handling and tests for client.ts 2026-05-21 15:57:43 +03:00
Ilia Mashkov caff3fe7e3 fix: align footer paddings with main ones 2026-05-21 15:55:47 +03:00
ilia 56f3f94e41 Merge pull request 'feat: add PBHttpError and try/catch in getFirstRecord' (#6) from fix/get-first-record into main
Build and push / build (push) Successful in 3m35s
Reviewed-on: #6
2026-05-21 10:08:04 +00:00
Ilia Mashkov 9deefaf3fc feat: add PBHttpError and try/catch in getFirstRecord 2026-05-21 13:05:31 +03:00
ilia a54963091c Merge pull request 'fix: require PB_URL in production, fall back to localhost in dev only' (#5) from fix/pocketbase into main
Build and push / build (push) Failing after 1m10s
Reviewed-on: #5
2026-05-19 16:28:43 +00:00
Ilia Mashkov 6b15a0e658 fix: gracefully handle PocketBase unreachable during static generation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:27:04 +03:00
Ilia Mashkov 93b8adf55d fix: require PB_URL in production, fall back to localhost in dev only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:26:58 +03:00
ilia 03a90e1cf0 Merge pull request 'fix: old palette purged from stories and stories purged from production build' (#4) from fixes/storybook into main
Build and push / build (push) Failing after 1m14s
Reviewed-on: #4
2026-05-19 15:58:13 +00:00
Ilia Mashkov 06d69a860e fix: add css and json to biome check, set yarn version in package.json 2026-05-19 18:57:16 +03:00
Ilia Mashkov 181cfdebdf fix: old palette purged from stories and stories purged from production build 2026-05-19 18:50:45 +03:00
Ilia Mashkov e0565d6ddc fix: yarn instead of npm for dockerfile
Build and push / build (push) Failing after 2m17s
2026-05-19 18:37:58 +03:00
ilia 598d566487 Merge pull request 'feat: add route-level error page and per-section error boundary' (#3) from feature/error-handling into main
Build and push / build (push) Failing after 22s
Reviewed-on: #3
2026-05-19 15:27:17 +00:00
Ilia Mashkov dd9cc766d5 feat: add route-level error page and per-section error boundary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:20:53 +03:00
ilia d1b4452867 Merge pull request 'chore: add .dockerignore' (#2) from fixes/responsive into main
Build and push / build (push) Failing after 4m25s
Reviewed-on: #2
2026-05-19 15:11:13 +00:00
Ilia Mashkov d62c0ad501 chore: add Gitea Actions deploy workflow 2026-05-19 18:09:59 +03:00
Ilia Mashkov cf2f1bc7f3 chore: unignore .gitea directory 2026-05-19 18:09:26 +03:00
Ilia Mashkov 7f6e6369ff fix: reduce padding and spacing for mobile
Main: px-4 py-6 on mobile (was px-8 py-12). Section accordion:
mb/py on inactive links tightened to 1/1 on mobile, space-y-0
between sections. Active title text-xl on mobile to prevent
wrapping at ~400px, matches inactive title size.
2026-05-19 18:06:51 +03:00
Ilia Mashkov d0f09f0dbd feat: social links with inline SVG icons from CMS
SocialRecord gains icon field (SVG markup string). InlineSvg component
parses SVG string via html-react-parser. Footer renders icon on mobile
(sm:hidden label), label on sm+ (hidden icon). Email field refactored
from string to SocialRecord relation.
2026-05-19 18:06:20 +03:00
Ilia Mashkov 41af0b90a0 feat: add Link secondary variant with border-bottom at sm+
Secondary variant drops text-decoration, uses opacity-60/hover-100
and brutal-border-bottom at sm+ for use in icon+label links where
the underline should only appear alongside the label.
2026-05-19 18:06:10 +03:00
Ilia Mashkov 954b17d824 fix: reduce Button sm padding on mobile 2026-05-19 18:05:57 +03:00
Ilia Mashkov 906ec3b805 feat: fixed footer with responsive height tokens
Footer is fixed bottom-0 with h-footer (5rem mobile) / md:h-footer-wide
(4rem desktop). Body gets matching pb-footer/md:pb-footer-wide to
reserve space. Tokens registered in @theme inline as --spacing-footer*.
2026-05-19 18:05:46 +03:00
Ilia Mashkov 4d6d78a528 chore: add .dockerignore 2026-05-19 18:05:37 +03:00
Ilia Mashkov f40e9f54a3 chore: add dockerfile 2026-05-19 09:55:12 +03:00
Ilia Mashkov 7829f81d1a chore: add dockerfile 2026-05-19 09:46:35 +03:00
ilia cd9da6dd26 Merge pull request 'fix: storybook font rendering and shared fonts module' (#1) from feat/portfolio-setup into main
Reviewed-on: #1
2026-05-18 18:45:21 +00:00
Ilia Mashkov d5ba77b4ce feat: add poweredByHeader: false 2026-05-18 21:40:10 +03:00
79 changed files with 1452 additions and 1693 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.git
.gitea
.env*.local
README.md
Dockerfile
.dockerignore
.yarn
.pnp.*
+33
View File
@@ -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
+2
View File
@@ -54,6 +54,8 @@ next-env.d.ts
!/.vscode !/.vscode
!/.gitattributes !/.gitattributes
!/.gitignore !/.gitignore
!/.dockerignore
!/.gitea
!/biome.json !/biome.json
*storybook.log *storybook.log
+30
View File
@@ -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"]
+16
View File
@@ -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 -4
View File
@@ -8,16 +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',
tags: ['sections'],
}); });
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.
@@ -41,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} />
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+5 -4
View File
@@ -4,8 +4,9 @@ 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' },
}; };
/** /**
@@ -13,8 +14,8 @@ 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} flex flex-col min-h-screen`}> <body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}>
{children} {children}
<Footer /> <Footer />
</body> </body>
+1 -1
View File
@@ -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.
+9 -7
View File
@@ -1,18 +1,20 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
/* PocketBase origin — used to allowlist remote images. /* Public PocketBase host for the image optimizer allowlist.
* PB_HOSTNAME and PB_PORT are server-only env vars; safe to read here. */ * Derived from PB_PUBLIC_URL (e.g. https://cms.allmy.work) at BUILD time —
const pbHostname = process.env.PB_HOSTNAME ?? '127.0.0.1'; * remotePatterns is frozen into the build, so PB_PUBLIC_URL must be present
const pbPort = parseInt(process.env.PB_PORT ?? '8090', 10); * 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 = {
output: 'standalone', output: 'standalone',
poweredByHeader: false,
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: 'http', protocol: 'https',
hostname: pbHostname, hostname: pbPublicHost,
port: String(pbPort), pathname: '/api/files/**',
}, },
], ],
}, },
+1
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
});
}); });
}); });
@@ -38,23 +38,23 @@ export function SectionAccordion({ number, title, id, isActive, href, children }
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 className="font-heading font-black text-section-title leading-[1.2] mb-0">{heading}</h1> <h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</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 <Link
href={href} href={href}
aria-label={heading} aria-label={heading}
className="block w-full text-left mb-3 py-3 group border-b-0 hover:border-b-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"
> >
<span className="block font-heading font-wonk font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-60 transition-opacity duration-200"> <span className="block font-heading font-wonk font-black text-xl sm:text-3xl opacity-30 group-hover:opacity-60 transition-opacity duration-200">
{heading} {heading}
</span> </span>
</Link> </Link>
@@ -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 = {
@@ -1,6 +1,6 @@
import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui'; import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
type Props = { export interface Props {
/** /**
* Job title * Job title
*/ */
@@ -25,7 +25,7 @@ type Props = {
* Additional CSS classes forwarded to the card * Additional CSS classes forwarded to the card
*/ */
className?: string; className?: string;
}; }
/** /**
* Work experience card with sidebar layout. * Work experience card with sidebar layout.
@@ -37,9 +37,9 @@ export function ExperienceCard({ title, company, period, description, stack, cla
<Card className={className}> <Card className={className}>
<CardSidebar <CardSidebar
sidebar={ sidebar={
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-3 sm:gap-4">
<p className="text-sm font-medium brutal-border-left pl-3">{period}</p> <p className="text-sm font-medium brutal-border-left pl-3">{period}</p>
<p className="text-base font-medium">{company}</p> <p className="text-lg font-black">{company}</p>
{stack.length > 0 && ( {stack.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{stack.map((tech) => ( {stack.map((tech) => (
@@ -2,7 +2,7 @@ import Image from 'next/image';
import { Card, RichText } 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
*/ */
@@ -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.
@@ -98,7 +98,7 @@ describe('ProjectCard', () => {
it('View Project button uses sm size', () => { it('View Project button uses sm size', () => {
render(<ProjectCard {...DEFAULT_PROPS} />); render(<ProjectCard {...DEFAULT_PROPS} />);
const btn = screen.getByRole('link', { name: /view project/i }); const btn = screen.getByRole('link', { name: /view project/i });
expect(btn).toHaveClass('px-4', 'py-2', 'text-sm'); expect(btn).toHaveClass('px-3', 'py-1.5', 'sm:px-4', 'sm:py-2', 'text-sm');
}); });
it('tags are xs outline badges', () => { it('tags are xs outline badges', () => {
@@ -124,5 +124,11 @@ describe('ProjectCard', () => {
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 { Badge, Button, Card, CardSidebar, CardTitle, RichText } from '$shared/ui'; import { Badge, Button, Card, CardSidebar, CardTitle, ImageLightbox, RichText } from '$shared/ui';
type Props = { export interface Props {
/** /**
* Project name * Project name
*/ */
@@ -27,14 +26,20 @@ type Props = {
* 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;
}
/** /**
* Project card with sidebar layout. * Project card with sidebar layout.
* Sidebar: year badge, stack tags, View Project button. * Sidebar: year badge, stack tags, View Project button.
* Main: title, optional image, description. * Main: title, optional image, description.
*/ */
export function ProjectCard({ title, year, description, tags, url, imageUrl }: Props) { export function ProjectCard({ title, year, description, tags, url, imageUrl, priority = false }: Props) {
return ( return (
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}> <Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
<CardSidebar <CardSidebar
@@ -50,7 +55,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P
))} ))}
</div> </div>
)} )}
<Button href={url} variant="primary" size="sm" className="w-full"> <Button href={url} variant="primary" size="sm" className="self-start lg:w-full lg:self-auto text-center">
View Project View Project
</Button> </Button>
</div> </div>
@@ -59,9 +64,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<CardTitle className="font-heading">{title}</CardTitle> <CardTitle className="font-heading">{title}</CardTitle>
{imageUrl && ( {imageUrl && (
<div className="brutal-border aspect-video bg-blue overflow-hidden relative"> <ImageLightbox src={imageUrl} alt={title} priority={priority} className="brutal-border bg-blue" />
<Image src={imageUrl} alt={title} fill className="object-cover" />
</div>
)} )}
<RichText html={description} /> <RichText html={description} />
</div> </div>
@@ -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.
-79
View File
@@ -1,79 +0,0 @@
import type { ListResponse } from './types';
/*
* Native fetch wrapper for PocketBase API requests.
*/
/* Prefer the server-only var (not exposed to the browser bundle),
* fall back to the public var for client-side usage, then to the
* local dev default. */
const PB_URL = process.env.PB_URL ?? process.env.NEXT_PUBLIC_PB_URL ?? 'http://127.0.0.1:8090';
/**
* Options for PocketBase collection fetching.
*/
export type PBFetchOptions = {
/**
* Sorting criteria (e.g., "-created,order")
*/
sort?: string;
/**
* Filter query string
*/
filter?: string;
/**
* Fields to expand (e.g., "stack")
*/
expand?: string;
/**
* Cache tags for on-demand revalidation via `revalidateTag`.
* Typically set to the collection name.
*/
tags?: string[];
/**
* ISR revalidation interval in seconds.
* @default 3600
*/
revalidate?: number;
};
/**
* Fetch a list of records from a PocketBase collection.
*/
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
const { sort, filter, expand, tags, revalidate } = options;
const params = new URLSearchParams();
if (sort) {
params.set('sort', sort);
}
if (filter) {
params.set('filter', filter);
}
if (expand) {
params.set('expand', expand);
}
const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
const res = await fetch(url, {
next: {
tags: tags ?? [],
revalidate: revalidate ?? 3600,
},
});
if (!res.ok) {
throw new Error(`PocketBase ${res.status} ${res.statusText} on collection "${collection}"`);
}
return res.json();
}
/**
* Fetch the first record matching an optional filter from a PocketBase collection.
*/
export async function getFirstRecord<T>(collection: string, options: PBFetchOptions = {}): Promise<T | null> {
const data = await getCollection<T>(collection, options);
return data.items[0] ?? null;
}
+40
View File
@@ -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);
});
});
});
+102
View File
@@ -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;
}
}
+37
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
export * from './client'; export * from './client/client';
export * from './types'; export * from './types';
+10 -2
View File
@@ -141,6 +141,10 @@ export type SocialRecord = BaseRecord & {
* Full URL for the social profile * Full URL for the social profile
*/ */
url: string; url: string;
/**
* SVG markup string stored in PocketBase
*/
icon: string;
}; };
/** /**
@@ -149,7 +153,7 @@ export type SocialRecord = BaseRecord & {
*/ */
export type ContactsRecord = BaseRecord & { export type ContactsRecord = BaseRecord & {
/** /**
* Primary contact email address * Raw relation ID — use expand?.email for the resolved record
*/ */
email: string; email: string;
/** /**
@@ -157,9 +161,13 @@ export type ContactsRecord = BaseRecord & {
*/ */
socials: string[]; socials: string[];
/** /**
* Expanded relation data, present when fetched with expand=socials * Expanded relation data, present when fetched with expand=email,socials
*/ */
expand?: { expand?: {
/**
* Resolved email contact record
*/
email?: SocialRecord;
/** /**
* Resolved social link records * Resolved social link records
*/ */
+30
View File
@@ -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>
);
}
+30
View File
@@ -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>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { CloseIcon } from './CloseIcon';
export { MagnifyIcon } from './MagnifyIcon';
@@ -5,7 +5,7 @@ export function buildFileUrl(
collectionId: string, collectionId: string,
recordId: string, recordId: string,
filename: string, filename: string,
baseUrl: string = process.env.NEXT_PUBLIC_PB_URL ?? 'http://127.0.0.1:8090', baseUrl: string = process.env.PB_PUBLIC_URL ?? 'http://127.0.0.1:8090',
): string { ): string {
return `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`; return `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
} }
+156 -37
View File
@@ -94,6 +94,8 @@
--duration-normal: 150ms; --duration-normal: 150ms;
--duration-slow: 350ms; --duration-slow: 350ms;
--duration-spring: 220ms; --duration-spring: 220ms;
--delay-normal: 200ms;
--slide-section-body-in: clamp(1.25rem, 5vw, 3rem);
} }
@theme inline { @theme inline {
@@ -121,6 +123,8 @@
--radius-md: var(--radius); --radius-md: var(--radius);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--container-section: var(--section-content-width); --container-section: var(--section-content-width);
--spacing-footer: 5rem;
--spacing-footer-wide: 4rem;
--text-section-title: var(--text-section-title); --text-section-title: var(--text-section-title);
--shadow-brutal-xs: var(--shadow-brutal-xs); --shadow-brutal-xs: var(--shadow-brutal-xs);
@@ -149,6 +153,10 @@
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 {
@@ -159,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,
@@ -226,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 {
@@ -258,21 +246,33 @@
.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
* subpixel paint bleed can't cover it. Doesn't take layout space; the
* ancestor must not have overflow:hidden or the outline gets clipped. */
@utility brutal-outline {
outline: var(--border-width) solid var(--blue);
}
/* Tiled blue dot pattern — applied to body::before (page-wide) and reusable
* 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 */ /* Apply Fraunces variable axes to non-heading elements using the heading font */
.font-wonk { .font-wonk {
font-variation-settings: font-variation-settings:
@@ -300,21 +300,28 @@
text-wrap: pretty; 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 { .rich-text p + p {
margin-top: 1.2em; margin-top: 1.2em;
} }
.rich-text ul { .rich-text ul {
list-style: none; list-style: none;
padding-left: 0; padding-left: 1.5em;
margin: 1em 0; margin: 1em 0;
} }
.rich-text ul li { .rich-text ul li {
display: grid; text-indent: -1.5em;
grid-template-columns: auto 1fr;
gap: 0.65em;
align-items: start;
margin-top: 0.5em; margin-top: 0.5em;
} }
@@ -324,6 +331,10 @@
.rich-text ul li::before { .rich-text ul li::before {
content: "◆"; 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); color: var(--blue);
font-size: 0.55em; font-size: 0.55em;
/* line-height matches parent so diamond centers within the first line box */ /* line-height matches parent so diamond centers within the first line box */
@@ -331,14 +342,14 @@
} }
/* 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);
@@ -366,3 +377,111 @@
transform: translateY(0); transform: translateY(0);
} }
} }
/* 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) {
animation: none;
opacity: 0;
}
::view-transition-new(section-body) {
animation-name: section-body-in;
animation-duration: var(--duration-spring);
animation-timing-function: var(--ease-spring);
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. */
animation-delay: var(--delay-normal);
}
@keyframes section-body-in {
from {
opacity: 0;
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 {
opacity: 1;
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;
}
}
+4 -4
View File
@@ -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>);
@@ -87,7 +87,7 @@ describe('Button', () => {
</Button>, </Button>,
); );
const link = screen.getByRole('link'); const link = screen.getByRole('link');
expect(link).toHaveClass('bg-blue', 'px-4', 'py-2'); expect(link).toHaveClass('bg-blue', 'px-3', 'py-1.5', 'sm:px-4', 'sm:py-2');
}); });
}); });
}); });
+3 -3
View File
@@ -46,13 +46,13 @@ const VARIANTS = {
secondary: secondary:
'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', '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: outline:
'brutal-border bg-transparent text-blue shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
ghost:
'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream', '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>; } as const satisfies Record<ButtonVariant, string>;
const SIZES = { 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>; } as const satisfies Record<ButtonSize, string>;
+9 -16
View File
@@ -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
View File
@@ -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>
</>
);
}
+1
View File
@@ -0,0 +1 @@
export { InlineSvg } from './ui/InlineSvg';
+25
View File
@@ -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>;
}
+1
View File
@@ -1 +1,2 @@
export type { LinkVariant } from './ui/Link/Link';
export { Link } from './ui/Link/Link'; export { Link } from './ui/Link/Link';
+35 -16
View File
@@ -10,31 +10,50 @@ export default meta;
type Story = StoryObj<typeof Link>; type Story = StoryObj<typeof Link>;
export const Internal: Story = { const decorator = (Story: React.ComponentType) => (
<div className="p-8 bg-cream">
<Story />
</div>
);
export const Primary: Story = {
args: { args: {
href: '/about', href: '/about',
children: 'Internal page', children: 'Internal page',
}, },
decorators: [ decorators: [decorator],
(Story) => (
<div className="p-8 bg-cream">
<Story />
</div>
),
],
}; };
export const External: Story = { export const PrimaryExternal: Story = {
args: { args: {
href: 'https://example.com', href: 'https://example.com',
external: true, external: true,
children: 'External site', children: 'External site',
}, },
decorators: [ decorators: [decorator],
(Story) => ( };
<div className="p-8 bg-cream">
<Story /> export const Secondary: Story = {
</div> 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],
}; };
+41 -5
View File
@@ -10,7 +10,8 @@ import { render, screen } from '@testing-library/react';
import type React from 'react'; import type React from 'react';
import { Link } from './Link'; import { Link } from './Link';
const BASE = 'underline underline-offset-2 hover:opacity-60 transition-opacity'; 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', () => { describe('internal link', () => {
it('renders an anchor element', () => { it('renders an anchor element', () => {
@@ -28,10 +29,10 @@ describe('internal link', () => {
expect(screen.getByRole('link', { name: 'About' })).not.toHaveAttribute('target'); expect(screen.getByRole('link', { name: 'About' })).not.toHaveAttribute('target');
}); });
it('applies base classes', () => { it('applies primary classes by default', () => {
render(<Link href="/about">About</Link>); render(<Link href="/about">About</Link>);
const link = screen.getByRole('link', { name: 'About' }); const link = screen.getByRole('link', { name: 'About' });
for (const cls of BASE.split(' ')) { for (const cls of PRIMARY.split(' ')) {
expect(link).toHaveClass(cls); expect(link).toHaveClass(cls);
} }
}); });
@@ -66,8 +67,43 @@ describe('external link', () => {
}); });
}); });
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', () => { describe('className passthrough', () => {
it('merges custom className with base classes', () => { it('merges custom className with variant classes', () => {
render( render(
<Link href="/about" className="text-red-500"> <Link href="/about" className="text-red-500">
Styled Styled
@@ -75,7 +111,7 @@ describe('className passthrough', () => {
); );
const link = screen.getByRole('link', { name: 'Styled' }); const link = screen.getByRole('link', { name: 'Styled' });
expect(link).toHaveClass('text-red-500'); expect(link).toHaveClass('text-red-500');
for (const cls of BASE.split(' ')) { for (const cls of PRIMARY.split(' ')) {
expect(link).toHaveClass(cls); expect(link).toHaveClass(cls);
} }
}); });
+18 -4
View File
@@ -2,6 +2,8 @@ import NextLink from 'next/link';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
export type LinkVariant = 'primary' | 'secondary';
/** /**
* Props for Link. * Props for Link.
*/ */
@@ -18,6 +20,13 @@ interface Props {
* CSS classes * CSS classes
*/ */
className?: string; 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". * When true, renders a plain <a> with target="_blank" rel="noopener noreferrer".
* Use for links that open outside the app. * Use for links that open outside the app.
@@ -25,22 +34,27 @@ interface Props {
external?: boolean; external?: boolean;
} }
const BASE = 'underline underline-offset-2 hover:opacity-60 transition-opacity'; 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. * Inline text link.
* Renders as Next.js Link for internal routes, plain <a> for external links. * Renders as Next.js Link for internal routes, plain <a> for external links.
*/ */
export function Link({ href, children, className, external }: Props) { export function Link({ href, children, className, variant = 'primary', external }: Props) {
const cls = cn(VARIANTS[variant], className);
if (external) { if (external) {
return ( return (
<a href={href} target="_blank" rel="noopener noreferrer" className={cn(BASE, className)}> <a href={href} target="_blank" rel="noopener noreferrer" className={cls}>
{children} {children}
</a> </a>
); );
} }
return ( return (
<NextLink href={href} className={cn(BASE, className)}> <NextLink href={href} className={cls}>
{children} {children}
</NextLink> </NextLink>
); );
+1
View File
@@ -0,0 +1 @@
export { Modal, type ModalHandle } from './ui/Modal';
+56
View File
@@ -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>
),
};
+145
View File
@@ -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();
});
});
});
+121
View File
@@ -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>
);
});
+2 -2
View File
@@ -1,7 +1,7 @@
import parse from 'html-react-parser'; import parse from 'html-react-parser';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
type Props = { export interface Props {
/** /**
* HTML string from PocketBase rich-text editor * HTML string from PocketBase rich-text editor
*/ */
@@ -10,7 +10,7 @@ type Props = {
* Additional CSS classes merged onto 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.
+5 -14
View File
@@ -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,
+4 -1
View File
@@ -4,9 +4,12 @@ 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, CardSidebar, 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 { 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';
+28 -5
View File
@@ -21,9 +21,19 @@ const mockSettings = {
collectionName: 'contacts', collectionName: 'contacts',
created: '', created: '',
updated: '', updated: '',
email: 'hello@allmy.work', email: 'e1',
socials: ['s1'], socials: ['s1'],
expand: { expand: {
email: {
id: 'e1',
collectionId: 'contact',
collectionName: 'contact',
created: '',
updated: '',
label: 'hello@allmy.work',
url: 'mailto:hello@allmy.work',
icon: '',
},
socials: [ socials: [
{ {
id: 's1', id: 's1',
@@ -33,6 +43,7 @@ const mockSettings = {
updated: '', updated: '',
label: 'GitHub', label: 'GitHub',
url: 'https://github.com', url: 'https://github.com',
icon: '',
}, },
], ],
}, },
@@ -58,19 +69,28 @@ describe('Footer', () => {
}); });
describe('email link', () => { describe('email link', () => {
it('renders the contact email as a mailto link', async () => { 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()); render(await Footer());
const link = screen.getByRole('link', { name: /hello@allmy\.work/i }); const link = screen.getByRole('link', { name: /hello@allmy\.work/i });
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work'); expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
}); });
it('does not render email link when contacts.email is missing', async () => { it('does not render email link when expand.email is missing', async () => {
vi.mocked(getFirstRecord).mockResolvedValue({ vi.mocked(getFirstRecord).mockResolvedValue({
...mockSettings, ...mockSettings,
expand: { expand: {
contacts: { contacts: {
...mockSettings.expand.contacts, ...mockSettings.expand.contacts,
email: '', expand: {
...mockSettings.expand.contacts.expand,
email: undefined,
},
}, },
}, },
}); });
@@ -98,7 +118,10 @@ describe('Footer', () => {
expand: { expand: {
contacts: { contacts: {
...mockSettings.expand.contacts, ...mockSettings.expand.contacts,
expand: { socials: [] }, expand: {
...mockSettings.expand.contacts.expand,
socials: [],
},
}, },
}, },
}); });
+14 -10
View File
@@ -1,7 +1,7 @@
import type { SiteSettingsRecord } from '$shared/api'; import type { SiteSettingsRecord } from '$shared/api';
import { getFirstRecord } from '$shared/api'; import { getFirstRecord } from '$shared/api';
import { buildFileUrl } from '$shared/lib'; import { buildFileUrl } from '$shared/lib';
import { Button, Link } from '$shared/ui'; import { Button, InlineSvg, Link } from '$shared/ui';
/** /**
* Site-wide footer with contact email, social links, and CV download. * Site-wide footer with contact email, social links, and CV download.
@@ -9,21 +9,23 @@ import { Button, Link } from '$shared/ui';
*/ */
export async function Footer() { export async function Footer() {
const settings = await getFirstRecord<SiteSettingsRecord>('site_settings', { const settings = await getFirstRecord<SiteSettingsRecord>('site_settings', {
expand: 'contacts,contacts.socials', expand: 'contacts,contacts.email,contacts.socials',
tags: ['site_settings'], tags: ['site_settings'],
}); });
const cvUrl = settings?.cv ? buildFileUrl(settings.collectionId, settings.id, settings.cv) : null; const cvUrl = settings?.cv ? buildFileUrl(settings.collectionId, settings.id, settings.cv) : null;
const contacts = settings?.expand?.contacts; const contacts = settings?.expand?.contacts;
const email = contacts?.expand?.email;
const socials = contacts?.expand?.socials ?? []; const socials = contacts?.expand?.socials ?? [];
return ( return (
<footer className="brutal-border-top px-8 py-6 lg:px-16 lg:py-8"> <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="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="w-full flex flex-row justify-between gap-4">
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-6 sm:gap-4">
{contacts?.email && ( {email && (
<Link href={`mailto:${contacts.email}`} className="text-sm opacity-60 hover:opacity-100 no-underline"> <Link href={email.url} external variant="secondary" className="flex items-center gap-1.5 text-sm">
{contacts.email} {email.icon && <InlineSvg svg={email.icon} className="inline-flex w-8 h-8 sm:hidden" />}
<span className="hidden sm:block">{email.label}</span>
</Link> </Link>
)} )}
{socials.map((social) => ( {socials.map((social) => (
@@ -31,9 +33,11 @@ export async function Footer() {
key={social.id} key={social.id}
href={social.url} href={social.url}
external external
className="text-sm opacity-60 hover:opacity-100 no-underline" variant="secondary"
className="flex items-center gap-1.5 text-sm"
> >
{social.label} {social.icon && <InlineSvg svg={social.icon} className="inline-flex w-8 h-8 sm:hidden" />}
<span className="hidden sm:block">{social.label}</span>
</Link> </Link>
))} ))}
</div> </div>
-4
View File
@@ -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';
-14
View File
@@ -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();
});
});
});
-63
View File
@@ -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');
});
});
});
-87
View File
@@ -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-brutal-2xl 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');
});
});
});
-32
View File
@@ -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>
);
}
@@ -13,9 +13,14 @@ export default async function ProjectsSection() {
tags: ['projects'], 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="space-y-6 max-w-section"> <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}
@@ -24,6 +29,7 @@ export default async function ProjectsSection() {
tags={project.stack} tags={project.stack}
url={project.url} url={project.url}
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined} 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}
-1
View File
@@ -1,2 +1 @@
export * from './Footer'; export * from './Footer';
export * from './Navigation';
+2 -9
View File
@@ -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 -1
View File
@@ -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({