305 lines
9.4 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|