Compare commits

...
10 Commits
20 changed files with 380 additions and 96 deletions
+52 -21
View File
@@ -1,36 +1,67 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# portfolio
## Getting Started
Personal portfolio site. Next.js App Router frontend, PocketBase as the CMS,
rendered with on-demand ISR — content is served static and revalidated by
webhook when the CMS changes.
First, run the development server:
## Stack
- **Next.js 16** (App Router, React 19, standalone output)
- **TypeScript** (strict)
- **Tailwind CSS v4**
- **PocketBase** — headless CMS, fetched at build/runtime via a typed client
- **Biome** — lint + format
- **Vitest** + Testing Library — unit/component tests
- **Storybook** (a11y + Vitest addons) — component workshop
- **Yarn 4**
Architecture follows [Feature-Sliced Design](https://feature-sliced.design/)
adapted to Next.js (`src/{shared,entities,widgets}` + `app/`).
## Rendering
Pages are statically generated and served through ISR. There is no fixed TTL —
PocketBase calls the `/api/revalidate` webhook after any content mutation,
which purges the matching cache tag (`revalidateTag`). See
[`app/api/revalidate/route.ts`](app/api/revalidate/route.ts).
## Getting started
```bash
npm run dev
# or
yarn install
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open http://localhost:3000.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
Without a reachable PocketBase, the app falls back to fixtures in
[`app/api/collections/[collection]/records/route.ts`](app/api/collections/%5Bcollection%5D/records/route.ts).
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Environment
## Learn More
```
PB_URL # internal PocketBase URL (server-side fetches)
PB_PUBLIC_URL # public PocketBase URL (image optimizer allowlist, build-time)
REVALIDATE_SECRET # shared secret for the /api/revalidate webhook
```
To learn more about Next.js, take a look at the following resources:
`PB_PUBLIC_URL` must be present at `next build` time — `images.remotePatterns`
is frozen into the build.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## Scripts
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
| Command | Description |
|---|---|
| `yarn dev` | Dev server |
| `yarn build` | Production build (standalone) |
| `yarn start` | Serve the build |
| `yarn test` | Run tests (Vitest) |
| `yarn check` | Lint + format (Biome, writes fixes) |
| `yarn check:ci` | Lint + format check (no writes) |
| `yarn storybook` | Storybook on :6006 |
## Deploy on Vercel
## Deployment
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Built as a standalone Docker image; PocketBase reached over the internal
Docker network. Content updates trigger the revalidation webhook — no rebuild
needed.
+3
View File
@@ -21,6 +21,9 @@ const nextConfig: NextConfig = {
experimental: {
viewTransition: true,
},
turbopack: {
root: __dirname,
},
};
export default nextConfig;
@@ -41,7 +41,8 @@ export function ExperienceCard({ title, company, period, description, stack, cla
<p className="text-sm font-medium brutal-border-left pl-3">{period}</p>
<p className="text-lg font-black">{company}</p>
{stack.length > 0 && (
<div className="flex flex-wrap gap-2">
/* Golden-ratio cap for the stacked (top) tablet layout; full width on mobile, and lg sidebar is already width-constrained. */
<div className="flex flex-wrap gap-2 sm:max-w-golden lg:max-w-none">
{stack.map((tech) => (
<Badge key={tech} variant="outline" size="xs">
{tech}
@@ -47,7 +47,8 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl, pri
<div className="flex flex-col gap-4">
<p className="text-sm font-medium brutal-border-left pl-3">{year}</p>
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
/* Golden-ratio cap for the stacked (top) tablet layout; full width on mobile, and lg sidebar is already width-constrained. */
<div className="flex flex-wrap gap-2 sm:max-w-golden lg:max-w-none">
{tags.map((tag) => (
<Badge key={tag} variant="outline" size="xs">
{tag}
+120 -25
View File
@@ -69,17 +69,21 @@
--space-20: 10rem;
/* === BORDERS === */
--border-width: 3px;
--radius: 0px;
--border-width: 1.5px;
--radius: 5px;
/* === BRUTALIST SHADOWS === */
--shadow-brutal-xs: 1px 1px 0 var(--blue);
--shadow-brutal-sm: 3px 3px 0 var(--blue);
--shadow-brutal: 5px 5px 0 var(--blue);
--shadow-brutal-md: 7px 7px 0 var(--blue);
--shadow-brutal-lg: 8px 8px 0 var(--blue);
--shadow-brutal-xl: 10px 10px 0 var(--blue);
--shadow-brutal-2xl: 12px 12px 0 var(--blue);
/* Muted offset shadow — quieter than the old hard-blue brutalist stamp.
* Softer alpha + tighter offsets keep the offset-shadow feel, just lighter. */
--shadow-color: rgba(4, 28, 243, 0.25);
/* === OFFSET SHADOWS === */
--shadow-brutal-xs: 1px 1px 0 var(--shadow-color);
--shadow-brutal-sm: 2px 2px 0 var(--shadow-color);
--shadow-brutal: 3px 3px 0 var(--shadow-color);
--shadow-brutal-md: 3px 3px 0 var(--shadow-color);
--shadow-brutal-lg: 4px 4px 0 var(--shadow-color);
--shadow-brutal-xl: 5px 5px 0 var(--shadow-color);
--shadow-brutal-2xl: 6px 6px 0 var(--shadow-color);
/* === GRID === */
--grid-gap: var(--space-3);
@@ -151,7 +155,12 @@
outline-offset: 2px;
}
/* Background lives on html (not body) so the grain overlay can sit behind
* body's content at a negative z-index and still paint against the cream
* canvas — with the background on body, a z-index:-1 body::before would hide
* behind body's own background instead of showing through the page gaps. */
html {
@apply bg-background;
font-size: var(--font-size);
scroll-behavior: smooth;
/* Reserve scrollbar gutter so locking body scroll (e.g. when a modal
@@ -160,23 +169,24 @@
}
body {
@apply bg-background text-foreground;
@apply text-foreground;
font-family: var(--font-body);
font-weight: var(--font-weight-body);
line-height: var(--line-height-tight);
overflow-x: hidden;
}
/* 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. */
/* Page-wide blue dot grain. z-index -1 drops it behind body's content so
* opaque surfaces (cards, badges, footer) cover it and the grain only shows
* through the transparent page gaps; pointer-events: none keeps everything
* clickable through it. */
body::before {
@apply grain-pattern;
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 100;
z-index: -1;
}
h1,
@@ -231,9 +241,46 @@
}
}
/* Button elevation transition — only transform animates; shadow snaps instantly */
.btn-transition {
transition: transform 0.13s var(--ease-micro);
/* Button elevation transition — transform + shadow animate together over 130ms
* on the micro easing curve; the spring feel comes from the curve, no keyframes. */
@utility btn-transition {
transition: transform 130ms var(--ease-micro);
}
/* Offset "shadow" as a static pseudo-block instead of box-shadow. box-shadow
* swims because it repaints on the main thread while the button's translate
* runs on the compositor — the two desync per frame. Here both the button and
* its ::before are transforms, so they stay frame-synced. The ::before counter-
* translates to hold a fixed 3px world offset while the button lifts/presses,
* so the shadow appears frozen and only the button moves. */
.btn-shadow {
position: relative;
z-index: 0;
transition: transform 130ms var(--ease-micro);
}
.btn-shadow::before {
content: "";
position: absolute;
/* ponytail: inset:0 tracks the padding box, ~1.5px shy of the border box;
* imperceptible on a hard offset block. Widen with negative inset if it shows. */
inset: 0;
z-index: -1;
border-radius: inherit;
background: var(--shadow-color);
transform: translate(3px, 3px);
transition: transform 130ms var(--ease-micro);
}
.btn-shadow:hover {
transform: translate(-2px, -2px);
}
.btn-shadow:hover::before {
transform: translate(5px, 5px);
}
.btn-shadow:active {
transform: translate(2px, 2px);
}
.btn-shadow:active::before {
transform: translate(1px, 1px);
}
/* Brutalist utility classes */
@@ -246,8 +293,16 @@
.brutal-shadow-lg {
box-shadow: var(--shadow-brutal-lg);
}
/* Golden-ratio (1/φ ≈ 0.618) cap for badge/tag groups so they form a tidy
* block instead of spanning the full width. Apply with a breakpoint prefix
* (e.g. sm:) — on mobile the group should use the full available width. */
@utility max-w-golden {
max-width: 61.8%;
}
@utility brutal-border {
border: var(--border-width) solid var(--blue);
border-radius: var(--radius);
}
@utility brutal-border-top {
border-top: var(--border-width) solid var(--blue);
@@ -266,6 +321,7 @@
* ancestor must not have overflow:hidden or the outline gets clipped. */
@utility brutal-outline {
outline: var(--border-width) solid var(--blue);
border-radius: var(--radius);
}
/* 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
@@ -329,16 +385,28 @@
margin-top: 0;
}
/* Some CMS output wraps li text in a block <p> (<li><p>…</p></li>), which drops
* the text below the marker. Keep it inline so marker and text share a line. */
.rich-text ul li > p {
display: inline;
margin: 0;
}
.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 */
/* 0.5em glyph + 1em gap = 1.5em advance, filling the hanging indent */
width: 0.5em;
height: 0.5em;
margin-right: 1em;
/* reset inherited text-indent so the box isn't shifted */
text-indent: 0;
color: var(--blue);
font-size: 0.55em;
/* line-height matches parent so diamond centers within the first line box */
line-height: calc(var(--line-height-relaxed) / 0.55);
background: var(--blue);
/* squircle: corner-shape is progressive enhancement, ignored where unsupported */
border-radius: 30%;
/* biome-ignore lint/correctness/noUnknownProperty: corner-shape is valid CSS, newer than Biome's property list */
corner-shape: squircle;
vertical-align: middle;
}
/* Cross-section view transition (navigation between sections) */
@@ -415,6 +483,30 @@
}
}
/* Per-item slide-in reusing the section-body distance/easing. Stagger via
* --stagger-index set inline by the consumer. */
.stagger-in {
animation: stagger-in var(--duration-spring) var(--ease-spring) both;
animation-delay: calc(var(--stagger-index, 0) * var(--stagger-step, 50ms));
}
@keyframes stagger-in {
from {
opacity: 0;
transform: translateX(var(--slide-section-body-in));
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.stagger-in {
animation: none;
}
}
/* 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
@@ -473,6 +565,9 @@ dialog.lightbox {
box-sizing: content-box;
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 10rem);
/* Match the wrapper's brutal-outline radius so image corners sit flush
* inside the rounded outline instead of poking past it. */
border-radius: var(--radius);
}
/* Disable transition animation for Firefox
+10
View File
@@ -0,0 +1,10 @@
import 'react';
/**
* Allow CSS custom properties in `style` objects without casts.
*/
declare module 'react' {
interface CSSProperties {
[key: `--${string}`]: string | number | undefined;
}
}
+12 -12
View File
@@ -1,14 +1,10 @@
import type { ReactNode } from 'react';
import type { ComponentPropsWithoutRef } from 'react';
import { cn } from '$shared/lib';
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline';
export type BadgeSize = 'xs' | 'sm' | 'md';
interface Props {
/**
* Badge content
*/
children: ReactNode;
interface Props extends Pick<ComponentPropsWithoutRef<'span'>, 'children' | 'className' | 'style'> {
/**
* Visual variant
* @default 'default'
@@ -19,10 +15,6 @@ interface Props {
* @default 'sm'
*/
size?: BadgeSize;
/**
* Additional CSS classes
*/
className?: string;
}
const VARIANTS: Record<BadgeVariant, string> = {
@@ -41,9 +33,17 @@ const SIZES: Record<BadgeSize, string> = {
/**
* Small label for categorization or status.
*/
export function Badge({ children, variant = 'default', size = 'sm', className }: Props) {
export function Badge({ children, variant = 'default', size = 'sm', className, ...rest }: Props) {
return (
<span className={cn('inline-block uppercase tracking-wider', SIZES[size], VARIANTS[variant], className)}>
<span
className={cn(
'inline-flex items-center leading-none uppercase tracking-wider',
SIZES[size],
VARIANTS[variant],
className,
)}
{...rest}
>
{children}
</span>
);
+6 -8
View File
@@ -41,14 +41,12 @@ function isAnchorProps(props: RestButton | RestAnchor): props is RestAnchor {
}
const VARIANTS = {
primary:
'brutal-border bg-blue text-cream shadow-[5px_5px_0_rgba(4,28,243,0.35)] hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_rgba(4,28,243,0.35)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-[1px_1px_0_rgba(4,28,243,0.35)]',
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',
primary: 'brutal-border bg-blue text-cream btn-shadow',
secondary: 'brutal-border bg-blue text-cream btn-shadow',
outline:
'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
ghost:
'brutal-border bg-transparent text-blue hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5',
'brutal-border bg-transparent text-blue btn-transition hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5',
} as const satisfies Record<ButtonVariant, string>;
const SIZES = {
@@ -57,9 +55,9 @@ const SIZES = {
lg: 'px-8 py-4 text-lg',
} as const satisfies Record<ButtonSize, string>;
/* box-shadow excluded from transition intentionally — snaps instantly so the
* eye follows the 130ms button movement, not the shadow change. */
const BASE = 'cursor-pointer btn-transition uppercase tracking-wider';
/* Elevation lives per-variant: primary/secondary use btn-shadow (static offset
* block, button moves), ghost uses btn-transition + translate. */
const BASE = 'cursor-pointer uppercase tracking-wider';
/**
* Brutalist button with variants and sizes.
+13
View File
@@ -12,6 +12,7 @@ import { Link } from './Link';
const PRIMARY = 'underline underline-offset-2 hover:opacity-60 transition-opacity';
const SECONDARY = 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity';
const TERTIARY = 'no-underline opacity-100 hover:opacity-60 sm:brutal-border-bottom transition-opacity';
describe('internal link', () => {
it('renders an anchor element', () => {
@@ -92,6 +93,18 @@ describe('variant', () => {
}
});
it('tertiary applies reversed-hover classes', () => {
render(
<Link href="/about" variant="tertiary">
About
</Link>,
);
const link = screen.getByRole('link', { name: 'About' });
for (const cls of TERTIARY.split(' ')) {
expect(link).toHaveClass(cls);
}
});
it('secondary does not apply underline', () => {
render(
<Link href="/about" variant="secondary">
+3 -1
View File
@@ -2,7 +2,7 @@ import NextLink from 'next/link';
import type { ReactNode } from 'react';
import { cn } from '$shared/lib';
export type LinkVariant = 'primary' | 'secondary';
export type LinkVariant = 'primary' | 'secondary' | 'tertiary';
/**
* Props for Link.
@@ -24,6 +24,7 @@ interface Props {
* Visual variant.
* primary — text-decoration underline.
* secondary — border-bottom at sm+, no underline on mobile (for icon+label links).
* tertiary — like secondary but reversed: bright by default, dims on hover.
* @default 'primary'
*/
variant?: LinkVariant;
@@ -37,6 +38,7 @@ interface Props {
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',
tertiary: 'no-underline opacity-100 hover:opacity-60 sm:brutal-border-bottom transition-opacity',
} as const satisfies Record<LinkVariant, string>;
/**
+1
View File
@@ -0,0 +1 @@
export { Stagger } from './ui/Stagger';
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Badge } from '../../Badge';
import { Stagger } from './Stagger';
const meta: Meta<typeof Stagger> = {
title: 'Shared/Stagger',
component: Stagger,
};
export default meta;
type Story = StoryObj<typeof Stagger>;
const ITEMS = ['React', 'TypeScript', 'Next.js', 'Node', 'CSS', 'Vitest'];
/**
* Each item slides in from the right with a 50ms-per-index delay.
* Refresh the story to replay the entry animation.
*/
export const Staggered: Story = {
render: () => (
<div className="flex flex-wrap gap-3 p-8 bg-ochre-clay">
{ITEMS.map((item, i) => (
<Stagger key={item} index={i}>
<Badge>{item}</Badge>
</Stagger>
))}
</div>
),
};
+53
View File
@@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react';
import { Stagger } from './Stagger';
describe('Stagger', () => {
describe('rendering', () => {
it('renders children', () => {
render(<Stagger index={0}>Item</Stagger>);
expect(screen.getByText('Item')).toBeInTheDocument();
});
it('applies the stagger-in class', () => {
render(<Stagger index={0}>Item</Stagger>);
expect(screen.getByText('Item')).toHaveClass('stagger-in');
});
it('merges custom className', () => {
render(
<Stagger index={0} className="mt-4">
Item
</Stagger>,
);
expect(screen.getByText('Item')).toHaveClass('stagger-in', 'mt-4');
});
});
describe('stagger index', () => {
it('sets the --stagger-index custom property', () => {
render(<Stagger index={3}>Item</Stagger>);
expect(screen.getByText('Item').style.getPropertyValue('--stagger-index')).toBe('3');
});
it('sets index 0 for the first item', () => {
render(<Stagger index={0}>Item</Stagger>);
expect(screen.getByText('Item').style.getPropertyValue('--stagger-index')).toBe('0');
});
});
describe('step', () => {
it('omits --stagger-step by default', () => {
render(<Stagger index={1}>Item</Stagger>);
expect(screen.getByText('Item').style.getPropertyValue('--stagger-step')).toBe('');
});
it('sets --stagger-step in ms when provided', () => {
render(
<Stagger index={1} step={120}>
Item
</Stagger>,
);
expect(screen.getByText('Item').style.getPropertyValue('--stagger-step')).toBe('120ms');
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from 'react';
import { cn } from '$shared/lib';
interface Props {
/**
* Position in the list — drives the per-item animation-delay via the
* --stagger-index custom property consumed by the .stagger-in keyframe.
*/
index: number;
/**
* Item to animate in.
*/
children: ReactNode;
/**
* Per-item delay in ms. Bump it for few/large items (cards) so the
* stagger reads as sequential rather than simultaneous.
* @default 50
*/
step?: number;
/**
* Additional CSS classes merged onto the wrapper.
*/
className?: string;
}
/**
* Wraps a list item so it slides in from the right with a per-index delay.
* Reuses the section-body slide distance/easing; respects reduced-motion.
*/
export function Stagger({ index, children, step, className }: Props) {
return (
<div
className={cn('stagger-in', className)}
style={{ '--stagger-index': index, ...(step !== undefined && { '--stagger-step': `${step}ms` }) }}
>
{children}
</div>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@ export function TechStackBrick({ name, className }: TechStackBrickProps) {
<div
className={cn(
'brutal-border brutal-shadow bg-cream px-4 py-3 text-center',
'transition-all duration-200 hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px]',
'transition-all duration-200 hover:shadow-none hover:translate-x-[3px] hover:translate-y-[3px]',
className,
)}
>
+1
View File
@@ -13,5 +13,6 @@ export { Modal, type ModalHandle } from './Modal';
export { RichText } from './RichText';
export type { ContainerSize, SectionBackground } from './Section';
export { Container, Section } from './Section';
export { Stagger } from './Stagger';
export { TechStackBrick, TechStackGrid } from './TechStack';
export { ViewTransitionWrapper } from './ViewTransitionWrapper';
@@ -2,6 +2,7 @@ import { ExperienceCard } from '$entities/experience';
import type { ExperienceRecord } from '$shared/api';
import { getCollection } from '$shared/api';
import { formatMonthYearRange } from '$shared/lib';
import { Stagger } from '$shared/ui';
/**
* Experience section component.
@@ -15,15 +16,16 @@ export default async function ExperienceSection() {
return (
<div className="space-y-6 max-w-section">
{items.map((exp) => (
<ExperienceCard
key={exp.id}
title={exp.role}
company={exp.company}
period={formatMonthYearRange(exp.start_date, exp.end_date)}
description={exp.description}
stack={exp.stack}
/>
{items.map((exp, i) => (
<Stagger key={exp.id} index={i} step={240}>
<ExperienceCard
title={exp.role}
company={exp.company}
period={formatMonthYearRange(exp.start_date, exp.end_date)}
description={exp.description}
stack={exp.stack}
/>
</Stagger>
))}
</div>
);
+2 -2
View File
@@ -23,7 +23,7 @@ export async function Footer() {
<div className="w-full flex flex-row justify-between gap-4">
<div className="flex flex-wrap items-center gap-6 sm:gap-4">
{email && (
<Link href={email.url} external variant="secondary" className="flex items-center gap-1.5 text-sm">
<Link href={email.url} external variant="tertiary" className="flex items-center gap-1.5 text-sm">
{email.icon && <InlineSvg svg={email.icon} className="inline-flex w-8 h-8 sm:hidden" />}
<span className="hidden sm:block">{email.label}</span>
</Link>
@@ -33,7 +33,7 @@ export async function Footer() {
key={social.id}
href={social.url}
external
variant="secondary"
variant="tertiary"
className="flex items-center gap-1.5 text-sm"
>
{social.icon && <InlineSvg svg={social.icon} className="inline-flex w-8 h-8 sm:hidden" />}
@@ -2,6 +2,7 @@ import { ProjectCard } from '$entities/project';
import type { ProjectRecord } from '$shared/api';
import { getCollection } from '$shared/api';
import { buildFileUrl } from '$shared/lib';
import { Stagger } from '$shared/ui';
/**
* Projects section component.
@@ -21,16 +22,17 @@ export default async function ProjectsSection() {
return (
<div className="space-y-6 max-w-section">
{items.map((project, index) => (
<ProjectCard
key={project.id}
title={project.title}
year={project.year}
description={project.description}
tags={project.stack}
url={project.url}
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
priority={index === lcpIndex}
/>
<Stagger key={project.id} index={index} step={240}>
<ProjectCard
title={project.title}
year={project.year}
description={project.description}
tags={project.stack}
url={project.url}
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
priority={index === lcpIndex}
/>
</Stagger>
))}
</div>
);
@@ -2,7 +2,7 @@ import { notFound } from 'next/navigation';
import type { SkillRecord } from '$shared/api';
import { getCollection } from '$shared/api';
import { groupByKey } from '$shared/lib';
import { Badge } from '$shared/ui';
import { Badge, Stagger } from '$shared/ui';
/**
* Skills section component.
@@ -24,10 +24,12 @@ export default async function SkillsSection() {
<div className="space-y-12 max-w-section">
{Object.entries(categories).map(([category, items]) => (
<div key={category} className="space-y-4">
<h3 className="text-xl font-bold uppercase tracking-widest opacity-60">{category}</h3>
<div className="flex flex-wrap gap-3">
{items.map((skill) => (
<Badge key={skill.id}>{skill.name}</Badge>
<h3 className="font-heading">{category}</h3>
<div className="flex flex-wrap gap-3 sm:max-w-golden">
{items.map((skill, i) => (
<Stagger key={skill.id} index={i}>
<Badge>{skill.name}</Badge>
</Stagger>
))}
</div>
</div>