214 lines
6.2 KiB
TypeScript
214 lines
6.2 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 { ArtistTabs } from "@/components/artist/ArtistTabs";
|
||
import { ArtistReleasesTab, type ArtistRelease } from "@/components/artist/ArtistReleasesTab";
|
||
import { ReleaseCover } from "@/components/release/ReleaseCover";
|
||
import { AnimatedText } from "@/components/AnimatedText";
|
||
import { urlFor } from "@/lib/sanityImage";
|
||
import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants";
|
||
import { Breadcrumb } from "@/components/Breadcrumb";
|
||
|
||
|
||
export const dynamicParams = true;
|
||
export const revalidate = 86400;
|
||
|
||
type ComposedWork = {
|
||
title?: string;
|
||
slug?: string;
|
||
originalComposerName?: string;
|
||
arrangerName?: string;
|
||
};
|
||
|
||
type Composer = {
|
||
name?: string;
|
||
slug?: string;
|
||
birthYear?: number;
|
||
deathYear?: number;
|
||
bio?: any;
|
||
image?: any;
|
||
releases?: ArtistRelease[];
|
||
composedWorks?: ComposedWork[];
|
||
arrangedWorks?: ComposedWork[];
|
||
};
|
||
|
||
const COMPOSER_DETAIL_QUERY = defineQuery(`
|
||
*[_type == "composer" && slug.current == $slug][0]{
|
||
name,
|
||
birthYear,
|
||
deathYear,
|
||
"slug": slug.current,
|
||
bio,
|
||
image,
|
||
|
||
"releases": *[
|
||
_type == "release" &&
|
||
count(tracks[work->composer->slug.current == $slug]) > 0
|
||
]
|
||
| order(releaseDate desc, catalogNo desc) {
|
||
_id,
|
||
name,
|
||
albumArtist,
|
||
catalogNo,
|
||
"slug": slug.current,
|
||
releaseDate,
|
||
albumCover,
|
||
genre,
|
||
instrumentation
|
||
},
|
||
|
||
"composedWorks": *[
|
||
_type == "work" &&
|
||
composer->slug.current == $slug
|
||
]
|
||
| order(title asc, defined(arranger) asc, arranger->name asc) {
|
||
title,
|
||
"slug": slug.current,
|
||
"arrangerName": arranger->name
|
||
},
|
||
|
||
"arrangedWorks": *[
|
||
_type == "work" &&
|
||
arranger->slug.current == $slug
|
||
]
|
||
| order(coalesce(composer->sortKey, composer->name) asc, title asc) {
|
||
title,
|
||
"slug": slug.current,
|
||
"originalComposerName": composer->name,
|
||
"originalComposerSortKey": composer->sortKey
|
||
}
|
||
}
|
||
`);
|
||
|
||
const getComposer = cache(async (slug: string) => {
|
||
try {
|
||
const composer = await sanity.fetch<Composer>(COMPOSER_DETAIL_QUERY, { slug });
|
||
return composer;
|
||
} catch (error) {
|
||
console.error("Failed to fetch composer:", error);
|
||
return null;
|
||
}
|
||
});
|
||
|
||
const COMPOSER_SLUGS_QUERY = defineQuery(
|
||
`*[_type == "composer" && defined(slug.current)]{ "slug": slug.current }`,
|
||
);
|
||
|
||
export async function generateStaticParams() {
|
||
const slugs = await sanity.fetch<Array<{ slug: string }>>(COMPOSER_SLUGS_QUERY);
|
||
|
||
return slugs.map((c) => ({ slug: c.slug }));
|
||
}
|
||
|
||
export async function generateMetadata({
|
||
params,
|
||
}: {
|
||
params: Promise<{ slug: string }>;
|
||
}): Promise<Metadata> {
|
||
const { slug: rawSlug } = await params;
|
||
const slug = rawSlug.toLowerCase();
|
||
|
||
const composer = await getComposer(slug);
|
||
if (!composer) notFound();
|
||
|
||
const years = formatYears(composer.birthYear, composer.deathYear);
|
||
const description = `Explore ${composer.name}'s works${years ? ` (${years})` : ""} and releases on TRPTK.`;
|
||
|
||
const ogImage = composer.image
|
||
? urlFor(composer.image).width(1200).height(630).url()
|
||
: undefined;
|
||
|
||
return {
|
||
title: `${composer.name}${years ? ` (${years})` : ""}`,
|
||
description,
|
||
alternates: { canonical: `/composer/${slug}` },
|
||
openGraph: {
|
||
title: composer.name,
|
||
description: `Composer${years ? ` (${years})` : ""}${composer.composedWorks?.length ? ` - ${composer.composedWorks.length} works` : ""}`,
|
||
type: "profile",
|
||
...(ogImage && { images: [{ url: ogImage, width: 1200, height: 630, alt: composer.name }] }),
|
||
},
|
||
twitter: {
|
||
card: "summary_large_image",
|
||
title: composer.name,
|
||
description,
|
||
...(ogImage && { images: [ogImage] }),
|
||
},
|
||
};
|
||
}
|
||
|
||
const formatYears = (birthYear?: number, deathYear?: number): string => {
|
||
if (birthYear && deathYear) return `${birthYear}–${deathYear}`;
|
||
if (birthYear) return `${birthYear}`;
|
||
return "";
|
||
};
|
||
|
||
export default async function ComposerPage({ params }: { params: Promise<{ slug: string }> }) {
|
||
const { slug } = await params;
|
||
if (!slug) notFound();
|
||
|
||
const normalizedSlug = slug.toLowerCase();
|
||
if (slug !== normalizedSlug) {
|
||
redirect(`/composer/${normalizedSlug}`);
|
||
}
|
||
|
||
const composer = await getComposer(slug);
|
||
if (!composer) notFound();
|
||
|
||
const displayName = composer.name ?? "";
|
||
const displayYears = formatYears(composer.birthYear, composer.deathYear);
|
||
|
||
const jsonLd = {
|
||
"@context": "https://schema.org",
|
||
"@type": "Person",
|
||
name: composer.name,
|
||
url: `https://trptk.com/composer/${slug}`,
|
||
...(composer.birthYear && { birthDate: String(composer.birthYear) }),
|
||
...(composer.deathYear && { deathDate: String(composer.deathYear) }),
|
||
...(composer.image && { image: urlFor(composer.image).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={composer.image ? urlFor(composer.image).url() : ARTIST_PLACEHOLDER_SRC}
|
||
alt={`Photo of ${composer.name}`}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex-1 content-center">
|
||
<Breadcrumb crumbs={[{ label: "Composers", href: "/composers" }]} />
|
||
<AnimatedText
|
||
text={displayName}
|
||
as="h1"
|
||
className="mb-2 font-argesta text-3xl break-words"
|
||
/>
|
||
<AnimatedText
|
||
text={displayYears}
|
||
as="h2"
|
||
className="font-silka text-base break-words text-lightsec dark:text-darksec"
|
||
delay={0.25}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<ArtistTabs
|
||
bio={composer.bio}
|
||
hasReleases={!!composer.releases?.length}
|
||
releasesTab={<ArtistReleasesTab releases={composer.releases ?? []} />}
|
||
composedWorks={composer.composedWorks}
|
||
arrangedWorks={composer.arrangedWorks}
|
||
/>
|
||
</main>
|
||
</>
|
||
);
|
||
}
|