feat(site): port stitch design system + seed-ready content (#5)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
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>
This commit was merged in pull request #5.
This commit is contained in:
@@ -1,16 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
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;
|
||||
return { title: slug };
|
||||
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({
|
||||
@@ -19,27 +58,186 @@ export default async function ProjectDetailPage({
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
if (!slug) notFound();
|
||||
|
||||
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 (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-4xl px-6 py-24">
|
||||
<span className="mb-4 block font-label text-xs uppercase tracking-[0.4em] text-primary">
|
||||
PROJECT //{" "}
|
||||
<code className="text-on-surface-variant">{slug}</code>
|
||||
</span>
|
||||
<h1 className="mb-6 font-headline text-4xl font-bold tracking-tight md:text-6xl">
|
||||
Project detail
|
||||
</h1>
|
||||
<p className="font-body text-lg text-on-surface-variant">
|
||||
Slug: <code className="font-label text-primary">{slug}</code>. Body
|
||||
will render from the Payload{" "}
|
||||
<code className="font-label text-primary">projects</code> collection
|
||||
once wired.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user