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

214 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
);
}