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

217 lines
6.4 KiB
TypeScript

import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { cache } from "react";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { ArtistReleasesTab, type ArtistRelease } from "@/components/artist/ArtistReleasesTab";
import { ReleaseCover } from "@/components/release/ReleaseCover";
import { AnimatedText } from "@/components/AnimatedText";
import { TabsClient, type TabDef } from "@/components/TabsClient";
import { PortableText } from "@portabletext/react";
import { urlFor } from "@/lib/sanityImage";
import { portableTextComponents } from "@/lib/portableTextComponents";
import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants";
import { Breadcrumb } from "@/components/Breadcrumb";
export const dynamicParams = true;
export const revalidate = 86400;
type Work = {
title?: string;
slug?: string;
description?: any;
composerName?: string;
composerSlug?: string;
composerImage?: any;
arrangerName?: string;
releases?: ArtistRelease[];
};
const WORK_DETAIL_QUERY = defineQuery(`
*[_type == "work" && slug.current == $slug][0]{
title,
description,
"slug": slug.current,
"composerName": composer->name,
"composerSlug": composer->slug.current,
"composerImage": composer->image,
"arrangerName": arranger->name,
"releases": *[
_type == "release" &&
count(tracks[work->slug.current == $slug]) > 0
]
| order(releaseDate desc, catalogNo desc) {
_id,
name,
albumArtist,
catalogNo,
"slug": slug.current,
releaseDate,
albumCover,
genre,
instrumentation
}
}
`);
const getWork = cache(async (slug: string) => {
try {
return await sanity.fetch<Work>(WORK_DETAIL_QUERY, { slug });
} catch (error) {
console.error("Failed to fetch work:", error);
return null;
}
});
const WORK_SLUGS_QUERY = defineQuery(
`*[_type == "work" && defined(slug.current)]{ "slug": slug.current }`,
);
export async function generateStaticParams() {
const slugs = await sanity.fetch<Array<{ slug: string }>>(WORK_SLUGS_QUERY);
return slugs.map((w) => ({ slug: w.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug: rawSlug } = await params;
const slug = rawSlug.toLowerCase();
const work = await getWork(slug);
if (!work) notFound();
const subtitle = work.arrangerName
? `${work.composerName} (arr. ${work.arrangerName})`
: (work.composerName ?? "");
const description = `Explore releases featuring ${work.title} by ${subtitle} on TRPTK.`;
const ogImage = work.composerImage
? urlFor(work.composerImage).width(1200).height(630).url()
: undefined;
return {
title: `${work.title} ${subtitle ? `${subtitle}` : ""}`,
description,
alternates: { canonical: `/work/${slug}` },
openGraph: {
title: work.title,
description: `${subtitle}${work.releases?.length ? ` - ${work.releases.length} release(s)` : ""}`,
type: "music.album",
...(ogImage && { images: [{ url: ogImage, width: 1200, height: 630, alt: `${work.title} by ${subtitle}` }] }),
},
twitter: {
card: "summary_large_image",
title: work.title,
description,
...(ogImage && { images: [ogImage] }),
},
};
}
export default async function WorkPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!slug) notFound();
const normalizedSlug = slug.toLowerCase();
if (slug !== normalizedSlug) {
redirect(`/work/${normalizedSlug}`);
}
const work = await getWork(slug);
if (!work) notFound();
const displayTitle = work.title ?? "";
const displaySubtitle = work.arrangerName
? `${work.composerName} (arr. ${work.arrangerName})`
: (work.composerName ?? "");
const jsonLd = {
"@context": "https://schema.org",
"@type": "MusicComposition",
name: work.title,
url: `https://trptk.com/work/${slug}`,
...(work.composerName && {
composer: { "@type": "Person", name: work.composerName },
}),
...(work.composerImage && {
image: urlFor(work.composerImage).width(800).url(),
}),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<main className="mx-auto my-auto max-w-250 px-6 py-12 font-silka md:px-8 md:py-16">
<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={work.composerImage ? urlFor(work.composerImage).url() : ARTIST_PLACEHOLDER_SRC}
alt={`Photo of ${work.composerName}`}
/>
</div>
<div className="flex-1 content-center">
<Breadcrumb crumbs={[
{ label: "Composers", href: "/composers" },
...(work.composerName && work.composerSlug
? [{ label: work.composerName, href: `/composer/${work.composerSlug}` }]
: []),
]} />
<AnimatedText
text={displayTitle}
as="h1"
className="mb-2 font-argesta text-3xl break-words"
/>
<AnimatedText
text={displaySubtitle}
as="h2"
className="font-silka text-base break-words text-lightsec dark:text-darksec"
delay={0.25}
/>
</div>
</div>
<WorkTabs description={work.description} releases={work.releases ?? []} />
</main>
</>
);
}
function WorkTabs({ description, releases }: { description?: any; releases: ArtistRelease[] }) {
const tabs: TabDef[] = [
{
id: "description",
label: "Description",
content: (
<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">
<h2 className="sr-only">Description</h2>
{description ? (
<PortableText value={description} components={portableTextComponents} />
) : (
<p>No description available for this work yet.</p>
)}
</article>
),
},
];
if (releases.length > 0) {
tabs.push({
id: "releases",
label: "Releases",
content: <ArtistReleasesTab releases={releases} />,
});
}
return <TabsClient defaultTabId="description" tabs={tabs} />;
}