trptk/app/blog/[slug]/page.tsx
2026-02-24 17:14:07 +01:00

305 lines
9.4 KiB
TypeScript

import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { cache } from "react";
import { PortableText } from "@portabletext/react";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { ReleaseCover } from "@/components/release/ReleaseCover";
import { AnimatedText } from "@/components/AnimatedText";
import { urlFor } from "@/lib/sanityImage";
import { portableTextComponents } from "@/lib/portableTextComponents";
import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants";
import { Breadcrumb } from "@/components/Breadcrumb";
import { ArtistCardCompact } from "@/components/artist/ArtistCardCompact";
import { ReleaseCard, type ReleaseCardData } from "@/components/release/ReleaseCard";
import { formatYears, type ArtistCardData, type ComposerCardData } from "@/components/artist/types";
import type { SanityImageSource } from "@sanity/image-url";
export const dynamicParams = true;
export const revalidate = 86400;
type BlogDetail = {
title?: string;
subtitle?: string;
slug?: string;
author?: string;
publishDate?: string;
category?: string;
featuredImage?: SanityImageSource;
content?: any;
releases?: ReleaseCardData[];
artists?: ArtistCardData[];
composers?: ComposerCardData[];
};
const BLOG_DETAIL_QUERY = defineQuery(`
*[_type == "blog" && slug.current == $slug][0]{
title,
subtitle,
"slug": slug.current,
author,
publishDate,
category,
featuredImage,
content[]{
...,
_type == "image" => {
...,
asset->
}
},
"releases": releases[]->{
_id,
name,
albumArtist,
catalogNo,
releaseDate,
"slug": slug.current,
albumCover
},
"artists": artists[]->{
_id,
name,
role,
"slug": slug.current,
image
},
"composers": composers[]->{
_id,
name,
sortKey,
"slug": slug.current,
birthYear,
deathYear,
image
}
}
`);
const getBlog = cache(async (slug: string) => {
try {
return await sanity.fetch<BlogDetail>(BLOG_DETAIL_QUERY, { slug });
} catch (error) {
console.error("Failed to fetch blog:", error);
return null;
}
});
const BLOG_SLUGS_QUERY = defineQuery(
`*[_type == "blog" && defined(slug.current)]{ "slug": slug.current }`,
);
export async function generateStaticParams() {
const slugs = await sanity.fetch<Array<{ slug: string }>>(BLOG_SLUGS_QUERY);
return slugs.map((b) => ({ slug: b.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug: rawSlug } = await params;
const slug = rawSlug.toLowerCase();
const blog = await getBlog(slug);
if (!blog) notFound();
const description = blog.subtitle ?? `${blog.title} • Read more on the TRPTK blog.`;
const ogImage = blog.featuredImage
? urlFor(blog.featuredImage).width(1200).height(630).url()
: undefined;
return {
title: `${blog.title}`,
description,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
title: blog.title,
description,
type: "article",
...(blog.publishDate && { publishedTime: blog.publishDate }),
...(blog.author && { authors: [blog.author] }),
...(ogImage && {
images: [{ url: ogImage, width: 1200, height: 630, alt: blog.title }],
}),
},
twitter: {
card: "summary_large_image",
title: blog.title,
description,
...(ogImage && { images: [ogImage] }),
},
};
}
function formatPublishDate(dateString?: string) {
if (!dateString) return null;
return new Intl.DateTimeFormat("en-US", {
day: "numeric",
month: "long",
year: "numeric",
}).format(new Date(dateString));
}
export default async function BlogPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!slug) notFound();
const normalizedSlug = slug.toLowerCase();
if (slug !== normalizedSlug) {
redirect(`/blog/${normalizedSlug}`);
}
const blog = await getBlog(slug);
if (!blog) notFound();
const displayDate = formatPublishDate(blog.publishDate);
const artists = blog.artists ?? [];
const composers = (blog.composers ?? []).sort((a, b) =>
(a.sortKey ?? "").localeCompare(b.sortKey ?? ""),
);
const releases = blog.releases ?? [];
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: blog.title,
url: `https://trptk.com/blog/${slug}`,
...(blog.publishDate && { datePublished: blog.publishDate }),
...(blog.author && {
author: { "@type": "Person", name: blog.author },
}),
...(blog.featuredImage && {
image: urlFor(blog.featuredImage).width(1200).url(),
}),
...(blog.subtitle && { description: blog.subtitle }),
publisher: {
"@type": "Organization",
name: "TRPTK",
url: "https://trptk.com",
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<main className="mx-auto my-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
{/* Hero */}
<div className="mx-auto grid grid-cols-1 gap-10 sm:grid-cols-2 sm:gap-8 md:gap-12 lg:gap-20">
<div className="flex-1 content-center">
<ReleaseCover
src={blog.featuredImage ? urlFor(blog.featuredImage).url() : ARTIST_PLACEHOLDER_SRC}
alt={blog.title ? `Featured image for ${blog.title}` : "Blog post image"}
/>
</div>
<div className="flex-1 content-center">
<Breadcrumb
crumbs={[
{ label: "Blog", href: "/blog" },
...(blog.category ? [{ label: blog.category, href: `/blog?category=${encodeURIComponent(blog.category)}` }] : []),
]}
/>
<AnimatedText
text={blog.title ?? "Untitled post"}
as="h1"
className="mb-2 font-argesta text-3xl break-words"
/>
{blog.subtitle && (
<AnimatedText
text={blog.subtitle}
as="h2"
className="mb-4 font-silka text-base break-words text-lightsec dark:text-darksec"
delay={0.25}
/>
)}
{(blog.author || displayDate) && (
<p className="text-sm text-lightsec dark:text-darksec">
{blog.author && <>Posted by {blog.author}</>}
{blog.author && displayDate && <> on </>}
{!blog.author && displayDate && <>Posted on </>}
{displayDate}
</p>
)}
</div>
</div>
{/* Content */}
{blog.content && (
<section className="mx-auto mt-16 max-w-200 sm:mt-20">
<article className="prose max-w-none text-lighttext dark:text-darktext dark:prose-invert prose-h2:!mt-[3em] prose-h2:font-silkasb prose-h2:text-base prose-strong:font-silkasb">
<PortableText value={blog.content} components={portableTextComponents} />
</article>
</section>
)}
{/* Related releases */}
{releases.length > 0 && (
<section className="mx-auto mt-16 max-w-200 sm:mt-20">
<AnimatedText text="Related releases" as="h2" className="mb-4 font-argesta text-2xl" />
<div className="grid grid-cols-2 gap-6 sm:gap-8 md:grid-cols-3">
{releases.map((r) => (
<ReleaseCard key={r._id} release={r} />
))}
</div>
</section>
)}
{/* Related artists & composers */}
{(artists.length > 0 || composers.length > 0) && (
<section className="mx-auto mt-16 grid max-w-200 grid-cols-1 gap-16 sm:mt-20 md:grid-cols-2 md:gap-12 lg:gap-20">
{artists.length > 0 && (
<div>
<AnimatedText
text="Related artists"
as="h2"
className="mb-4 font-argesta text-2xl"
/>
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-1">
{artists.map((artist) => (
<ArtistCardCompact
key={artist._id}
name={artist.name}
subtitle={artist.role}
image={artist.image}
href={artist.slug ? `/artist/${artist.slug}` : "/artists/"}
/>
))}
</ul>
</div>
)}
{composers.length > 0 && (
<div>
<AnimatedText
text="Related composers"
as="h2"
className="mb-4 font-argesta text-2xl"
/>
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-1">
{composers.map((composer) => (
<ArtistCardCompact
key={composer._id}
name={composer.name}
subtitle={formatYears(composer.birthYear, composer.deathYear)}
image={composer.image}
href={composer.slug ? `/composer/${composer.slug}` : "/composers/"}
label="Composer"
/>
))}
</ul>
</div>
)}
</section>
)}
</main>
</>
);
}