diff --git a/.gitignore b/.gitignore index 1834d9d..ed89f8b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ # production /build +/docs + # misc .DS_Store *.pem @@ -43,5 +45,16 @@ next-env.d.ts *.md !README.md +# hidden files (allow some, ignore others) +.** +!/.storybook +!/.yarn +!/yarnrc.yml +!/.claude +!/.vscode +!/.gitattributes +!/.gitignore +!/biome.json + *storybook.log storybook-static diff --git a/.storybook/preview.ts b/.storybook/preview.tsx similarity index 66% rename from .storybook/preview.ts rename to .storybook/preview.tsx index 133286b..d1ab09b 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.tsx @@ -1,4 +1,6 @@ +import React from 'react' import type { Preview } from '@storybook/nextjs-vite' +import { fraunces, publicSans } from '../src/shared/lib' import '../app/globals.css' const preview: Preview = { @@ -16,6 +18,13 @@ const preview: Preview = { test: 'todo', }, }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], } export default preview diff --git a/app/[[...slug]]/page.tsx b/app/[[...slug]]/page.tsx new file mode 100644 index 0000000..00450ec --- /dev/null +++ b/app/[[...slug]]/page.tsx @@ -0,0 +1,52 @@ +import { notFound } from 'next/navigation'; +import type { SectionRecord } from '$entities/Section'; +import { getCollection } from '$shared/api'; +import { SectionFactory } from '$widgets/SectionFactory'; +import { SectionsAccordion } from '$widgets/SectionsAccordion'; + +/** + * Optional catchall: `/` → first section, `/:slug` → that section. + */ +export async function generateStaticParams() { + const { items: sections } = await getCollection('sections', { + sort: 'order', + tags: ['sections'], + }); + return [{}, ...sections.map((s) => ({ slug: [s.slug] }))]; +} + +type Props = { + params: Promise<{ slug?: string[] }>; +}; + +/** + * Portfolio page — one route per section, sections list always visible. + */ +export default async function SectionPage({ params }: Props) { + const { slug } = await params; + + const { items: sections } = await getCollection('sections', { + sort: 'order', + tags: ['sections'], + }); + + if (sections.length === 0) { + notFound(); + } + + const activeSlug = slug?.[0] ?? sections[0].slug; + + if (!sections.some((s) => s.slug === activeSlug)) { + notFound(); + } + + return ( +
+ + {sections.map((s) => ( + + ))} + +
+ ); +} diff --git a/app/api/collections/[collection]/records/route.ts b/app/api/collections/[collection]/records/route.ts new file mode 100644 index 0000000..1d235f2 --- /dev/null +++ b/app/api/collections/[collection]/records/route.ts @@ -0,0 +1,335 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-static'; + +const base = { created: '', updated: '' }; + +const FIXTURES: Record = { + site_settings: [ + { + id: 'ss1', + collectionId: 'site_settings', + collectionName: 'site_settings', + ...base, + cv: '', + contacts: 'c1', + expand: { + contacts: { + id: 'c1', + collectionId: 'contacts', + collectionName: 'contacts', + ...base, + email: 'hello@allmy.work', + socials: ['s1', 's2'], + expand: { + socials: [ + { + id: 's1', + collectionId: 'contact', + collectionName: 'contact', + ...base, + label: 'GitHub', + url: 'https://github.com', + }, + { + id: 's2', + collectionId: 'contact', + collectionName: 'contact', + ...base, + label: 'LinkedIn', + url: 'https://linkedin.com', + }, + ], + }, + }, + }, + }, + ], + contacts: [ + { + id: 'c1', + collectionId: 'contacts', + collectionName: 'contacts', + ...base, + email: 'hello@allmy.work', + socials: ['s1', 's2'], + }, + ], + contact: [ + { + id: 's1', + collectionId: 'contact', + collectionName: 'contact', + ...base, + label: 'GitHub', + url: 'https://github.com', + }, + { + id: 's2', + collectionId: 'contact', + collectionName: 'contact', + ...base, + label: 'LinkedIn', + url: 'https://linkedin.com', + }, + ], + sections: [ + { + id: '1', + collectionId: 'sections', + collectionName: 'sections', + ...base, + slug: 'intro', + title: 'Introduction', + number: '01', + order: 1, + }, + { + id: '2', + collectionId: 'sections', + collectionName: 'sections', + ...base, + slug: 'bio', + title: 'Biography', + number: '02', + order: 2, + }, + { + id: '3', + collectionId: 'sections', + collectionName: 'sections', + ...base, + slug: 'skills', + title: 'Skills', + number: '03', + order: 3, + }, + { + id: '4', + collectionId: 'sections', + collectionName: 'sections', + ...base, + slug: 'experience', + title: 'Experience', + number: '04', + order: 4, + }, + { + id: '5', + collectionId: 'sections', + collectionName: 'sections', + ...base, + slug: 'projects', + title: 'Projects', + number: '05', + order: 5, + }, + ], + intro: [ + { + id: '1', + collectionId: 'intro', + collectionName: 'intro', + ...base, + slug: 'intro', + content: + "I'm a software engineer and designer building thoughtful digital products. I combine technical depth with a strong eye for design to create experiences that are both functional and beautiful.", + }, + ], + bio: [ + { + id: '1', + collectionId: 'bio', + collectionName: 'bio', + ...base, + slug: 'bio', + content: + "Based in Berlin. I've spent the last 8 years working at the intersection of product, design, and engineering. I believe the best products come from teams where these disciplines overlap and inform each other. When I'm not building, I'm reading, cooking, or somewhere with bad WiFi.", + }, + ], + skills: [ + { + id: 's1', + collectionId: 'skills', + collectionName: 'skills', + ...base, + name: 'TypeScript', + category: 'Frontend', + order: 1, + }, + { + id: 's2', + collectionId: 'skills', + collectionName: 'skills', + ...base, + name: 'React', + category: 'Frontend', + order: 2, + }, + { + id: 's3', + collectionId: 'skills', + collectionName: 'skills', + ...base, + name: 'Next.js', + category: 'Frontend', + order: 3, + }, + { + id: 's4', + collectionId: 'skills', + collectionName: 'skills', + ...base, + name: 'Tailwind CSS', + category: 'Frontend', + order: 4, + }, + { + id: 's5', + collectionId: 'skills', + collectionName: 'skills', + ...base, + name: 'Node.js', + category: 'Backend', + order: 1, + }, + { id: 's6', collectionId: 'skills', collectionName: 'skills', ...base, name: 'Go', category: 'Backend', order: 2 }, + { + id: 's7', + collectionId: 'skills', + collectionName: 'skills', + ...base, + name: 'PostgreSQL', + category: 'Backend', + order: 3, + }, + { + id: 's8', + collectionId: 'skills', + collectionName: 'skills', + ...base, + name: 'Figma', + category: 'Design', + order: 1, + }, + { + id: 's9', + collectionId: 'skills', + collectionName: 'skills', + ...base, + name: 'Docker', + category: 'Tools', + order: 1, + }, + { id: 's10', collectionId: 'skills', collectionName: 'skills', ...base, name: 'Git', category: 'Tools', order: 2 }, + ], + experience: [ + { + id: 'e1', + collectionId: 'experience', + collectionName: 'experience', + ...base, + company: 'Figma', + role: 'Senior Software Engineer', + start_date: '2022-03-01T00:00:00Z', + end_date: null, + description: + 'Building the multiplayer infrastructure for real-time collaborative design tools. Led the migration from polling to WebSocket-based sync, reducing latency by 60%.', + order: 1, + }, + { + id: 'e2', + collectionId: 'experience', + collectionName: 'experience', + ...base, + company: 'N26', + role: 'Frontend Engineer', + start_date: '2019-06-01T00:00:00Z', + end_date: '2022-02-28T00:00:00Z', + description: + 'Built and maintained the mobile banking web app serving 8 million customers. Owned the transaction history feature and drove accessibility improvements to WCAG 2.1 AA.', + order: 2, + }, + { + id: 'e3', + collectionId: 'experience', + collectionName: 'experience', + ...base, + company: 'Freelance', + role: 'Full-Stack Developer', + start_date: '2017-01-01T00:00:00Z', + end_date: '2019-05-31T00:00:00Z', + description: 'Worked with early-stage startups across fintech and healthtech to design and ship product MVPs.', + order: 3, + }, + ], + projects: [ + { + id: 'p1', + collectionId: 'projects', + collectionName: 'projects', + ...base, + title: 'Monograph', + year: '2024', + role: 'Lead Engineer & Designer', + description: 'A personal publishing platform built for long-form writing and visual essays.', + details: ['Custom rich-text editor', 'SSG with 100/100 Lighthouse', 'Sub-second TTFB globally'], + stack: ['Next.js', 'TypeScript', 'Tailwind CSS', 'PocketBase'], + image: '', + order: 1, + }, + { + id: 'p2', + collectionId: 'projects', + collectionName: 'projects', + ...base, + title: 'Verdant', + year: '2023', + role: 'Full-Stack Developer', + description: 'An inventory and sales management tool for independent plant nurseries.', + details: ['QR-code scanning', 'Offline-first PWA', 'Multi-location sync'], + stack: ['React', 'Go', 'PostgreSQL', 'Docker'], + image: '', + order: 2, + }, + { + id: 'p3', + collectionId: 'projects', + collectionName: 'projects', + ...base, + title: 'Folio', + year: '2022', + role: 'Designer & Developer', + description: 'A minimal portfolio generator for creative professionals.', + details: ['No-code page builder', 'Custom domain support'], + stack: ['Next.js', 'Prisma', 'Figma'], + image: '', + order: 3, + }, + ], +}; + +export function generateStaticParams() { + return Object.keys(FIXTURES).map((collection) => ({ collection })); +} + +/** + * Mock API route handler for PocketBase collection records. + * Returns fixture data shaped as a PocketBase list response. + */ +export async function GET(_req: Request, { params }: { params: Promise<{ collection: string }> }) { + const { collection } = await params; + const items = FIXTURES[collection]; + + if (!items) { + return NextResponse.json({ message: 'Not found' }, { status: 404 }); + } + + return NextResponse.json({ + page: 1, + perPage: items.length, + totalItems: items.length, + totalPages: 1, + items, + }); +} diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts new file mode 100644 index 0000000..dfdc5bb --- /dev/null +++ b/app/api/revalidate/route.ts @@ -0,0 +1,45 @@ +import { revalidateTag } from 'next/cache'; +import { type NextRequest, NextResponse } from 'next/server'; + +/** + * POST /api/revalidate + * + * Webhook endpoint for on-demand ISR. PocketBase (or any external + * caller) sends this request after mutating CMS content so the + * relevant tag is purged from the Next.js data cache. + * + * Expected body: `{ "tag": "" }` + * Required header: `x-revalidate-secret: ` + */ +export async function POST(request: NextRequest): Promise { + const secret = request.headers.get('x-revalidate-secret'); + + if (secret !== process.env.REVALIDATE_SECRET) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if ( + typeof body !== 'object' || + body === null || + !('tag' in body) || + typeof (body as Record).tag !== 'string' + ) { + return NextResponse.json({ error: 'Missing or invalid "tag" field' }, { status: 400 }); + } + + const tag = (body as { tag: string }).tag; + + /* Second arg is required by the Next.js 15 type signature; + * "max" means the purge propagates indefinitely — correct for + * an on-demand webhook that has no TTL of its own. */ + revalidateTag(tag, 'max'); + + return NextResponse.json({ revalidated: true, tag }, { status: 200 }); +} diff --git a/app/layout.tsx b/app/layout.tsx index 6b624cd..80d4e48 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,28 +1,12 @@ -import type { Metadata } from 'next' -import { Fraunces, Public_Sans } from 'next/font/google' -import './globals.css' - -/** - * Heading font — variable axes for brutalist variation settings - */ -const fraunces = Fraunces({ - subsets: ['latin'], - variable: '--font-fraunces', - axes: ['opsz', 'SOFT', 'WONK'], -}) - -/** - * Body font - */ -const publicSans = Public_Sans({ - subsets: ['latin'], - variable: '--font-public-sans', -}) +import type { Metadata } from 'next'; +import { fraunces, publicSans } from '$shared/lib'; +import { Footer } from '$widgets/Footer'; +import './globals.css'; export const metadata: Metadata = { title: 'Portfolio', description: 'Portfolio', -} +}; /** * Root layout — injects font CSS variables used by theme.css @@ -30,9 +14,10 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} +