All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
244 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|