Files
professional-website/src/app/(frontend)/projects/[slug]/page.tsx
Jason Woltje b47c5e420a
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(site): port stitch design system + seed-ready content (#5)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-15 01:16:41 +00:00

244 lines
8.7 KiB
TypeScript

import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { getPayload } from "payload";
import config from "@payload-config";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { GridOverlay, TechChip } from "@/components/site";
import type { Project, Media } from "@/payload-types";
export const dynamic = "force-dynamic";
type Params = { slug: string };
function isMedia(val: unknown): val is Media {
return typeof val === "object" && val !== null && "url" in val;
}
const STATUS_PILL: Record<string, string> = {
active: "bg-primary/10 text-primary border border-primary/20",
production: "bg-tertiary/10 text-tertiary border border-tertiary/20",
prototype: "bg-secondary/10 text-secondary border border-secondary/20",
archived:
"bg-surface-container-highest text-on-surface-variant border border-outline-variant/15",
};
const LINK_ICONS: Record<string, string> = {
live: "↗",
repo: "⌥",
docs: "⎕",
writeup: "✎",
};
export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { slug } = await params;
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "projects",
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
});
const project = docs[0] as Project | undefined;
if (!project) return { title: "Project Not Found" };
return {
title: project.title,
description: project.summary,
};
}
export default async function ProjectDetailPage({
params,
}: {
params: Promise<Params>;
}) {
const { slug } = await params;
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "projects",
where: { slug: { equals: slug } },
depth: 2,
limit: 1,
});
const project = docs[0] as Project | undefined;
if (!project) notFound();
const hero = isMedia(project.heroImage) ? project.heroImage : null;
return (
<main>
{/* Hero */}
<header className="relative overflow-hidden pb-16 pt-16">
<GridOverlay opacity={0.1} />
{hero?.url && (
<div className="absolute inset-0 -z-10">
<Image
src={hero.url}
alt={hero.alt}
fill
className="object-cover opacity-10 grayscale"
priority
sizes="100vw"
/>
<div className="absolute inset-0 bg-gradient-to-b from-background/60 via-background/80 to-background" />
</div>
)}
<div className="relative z-10 mx-auto max-w-7xl px-6">
<div className="mb-6 inline-flex items-center gap-2">
<span className="h-px w-12 bg-primary" />
<span className="label-sm uppercase tracking-[0.2em] text-primary">
PROJECTS
</span>
</div>
<div className="mb-6 flex flex-wrap items-center gap-4">
<span
className={`label-sm rounded-sm px-3 py-1 uppercase tracking-widest ${STATUS_PILL[project.status] ?? STATUS_PILL.archived}`}
>
{project.status}
</span>
{project.year && (
<span className="label-sm text-on-surface-variant">
{project.year}
</span>
)}
</div>
<h1 className="display-md mb-4 leading-tight tracking-tighter text-on-surface">
{project.title}
</h1>
{project.role && (
<p className="label-md uppercase tracking-widest text-on-surface-variant">
{project.role}
</p>
)}
</div>
</header>
{/* Body + Sidebar */}
<section className="mx-auto max-w-7xl px-6 pb-32">
<div className="grid grid-cols-1 gap-16 lg:grid-cols-12">
{/* Main content — 70% */}
<div className="space-y-12 lg:col-span-8">
<p className="body-lg leading-relaxed text-on-surface-variant">
{project.summary}
</p>
{project.body && (
<div className="body-lg space-y-4 leading-relaxed text-on-surface-variant [&_h1]:display-sm [&_h1]:text-on-surface [&_h2]:headline-lg [&_h2]:text-on-surface [&_h3]:title-lg [&_h3]:text-on-surface [&_a]:text-primary [&_a]:underline [&_a:hover]:opacity-80 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6">
<RichText data={project.body as Parameters<typeof RichText>[0]["data"]} />
</div>
)}
{/* Gallery */}
{project.gallery && project.gallery.length > 0 && (
<div className="space-y-6">
<span className="label-sm block uppercase tracking-[0.2em] text-secondary">
GALLERY
</span>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{project.gallery.map((entry, i) => {
const img = isMedia(entry.image) ? entry.image : null;
if (!img?.url) return null;
return (
<figure key={entry.id ?? i} className="space-y-2">
<div className="relative aspect-video w-full overflow-hidden rounded-md border border-outline-variant/15">
<Image
src={img.url}
alt={img.alt}
fill
className="object-cover"
sizes="(max-width: 640px) 100vw, 50vw"
/>
</div>
{entry.caption && (
<figcaption className="label-sm text-on-surface-variant">
{entry.caption}
</figcaption>
)}
</figure>
);
})}
</div>
</div>
)}
</div>
{/* Sidebar — 30% */}
<aside className="space-y-10 lg:col-span-4">
{/* Links */}
{project.links && project.links.length > 0 && (
<div className="space-y-4">
<span className="label-sm block uppercase tracking-[0.2em] text-primary">
LINKS
</span>
<ul className="space-y-3">
{project.links.map((link, li) => (
<li key={link.id ?? li}>
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-3 rounded-sm bg-surface-container-low px-4 py-3 transition-colors hover:bg-surface-container-high"
>
<span className="label-sm text-primary">
{link.type ? (LINK_ICONS[link.type] ?? "→") : "→"}
</span>
<span className="label-sm flex-1 uppercase tracking-wider text-on-surface transition-colors group-hover:text-primary">
{link.label}
</span>
{link.type && (
<span className="label-sm uppercase text-on-surface-variant">
{link.type}
</span>
)}
</a>
</li>
))}
</ul>
</div>
)}
{/* Tech */}
{project.tech && project.tech.length > 0 && (
<div className="space-y-4">
<span className="label-sm block uppercase tracking-[0.2em] text-secondary">
TECH STACK
</span>
<div className="flex flex-wrap gap-2">
{project.tech.map((t, ti) =>
t.label ? (
<TechChip key={t.id ?? ti} accent="secondary">
{t.label}
</TechChip>
) : null,
)}
</div>
</div>
)}
</aside>
</div>
</section>
{/* Footer nav */}
<div className="border-t border-outline-variant/15 bg-surface-container-low py-10">
<div className="mx-auto max-w-7xl px-6">
<Link
href="/projects"
className="label-sm inline-flex items-center gap-2 uppercase tracking-widest text-on-surface-variant transition-colors hover:text-primary"
>
All projects
</Link>
</div>
</div>
</main>
);
}