217 lines
6.4 KiB
TypeScript
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} />;
|
|
}
|