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

203 lines
5.8 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 { ArtistConcertsTab } from "@/components/artist/ArtistConcertsTab";
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";
import type { ConcertData } from "@/components/concert/ConcertTable";
export const dynamicParams = true;
export const revalidate = 86400;
type Artist = {
name?: string;
slug?: string;
role?: string;
bio?: any;
image?: any;
releases?: ArtistRelease[];
upcomingConcerts?: ConcertData[];
pastConcerts?: ConcertData[];
};
const CONCERT_PROJECTION = `{
_id,
title,
subtitle,
date,
time,
locationName,
city,
country,
"artists": artists[]->{ _id, name, "slug": slug.current },
ticketUrl
}`;
const ARTIST_DETAIL_QUERY = defineQuery(`
*[_type == "artist" && slug.current == $slug][0]{
name,
role,
"slug": slug.current,
bio,
image,
"releases": *[
_type == "release" &&
references(^._id)
]
| order(releaseDate desc, catalogNo desc) {
_id,
name,
albumArtist,
catalogNo,
"slug": slug.current,
releaseDate,
albumCover,
genre,
instrumentation
},
"upcomingConcerts": *[
_type == "concert" &&
references(^._id) &&
date >= $today
] | order(date asc, time asc) ${CONCERT_PROJECTION},
"pastConcerts": *[
_type == "concert" &&
references(^._id) &&
date < $today
] | order(date desc, time desc) ${CONCERT_PROJECTION}
}
`);
const getArtist = cache(async (slug: string) => {
const today = new Date().toISOString().slice(0, 10);
try {
return await sanity.fetch<Artist>(ARTIST_DETAIL_QUERY, { slug, today });
} catch (error) {
console.error("Failed to fetch artist:", error);
return null;
}
});
const ARTIST_SLUGS_QUERY = defineQuery(
`*[_type == "artist" && defined(slug.current)]{ "slug": slug.current }`,
);
export async function generateStaticParams() {
const slugs = await sanity.fetch<Array<{ slug: string }>>(ARTIST_SLUGS_QUERY);
return slugs.map((a) => ({ slug: a.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug: rawSlug } = await params;
const slug = rawSlug.toLowerCase();
const artist = await getArtist(slug);
if (!artist) notFound();
const description = `Explore ${artist.name}'s releases${artist.role ? `, ${artist.role}` : ""} on TRPTK.`;
const ogImage = artist.image ? urlFor(artist.image).width(1200).height(630).url() : undefined;
return {
title: `${artist.name} ${artist.role ? `${artist.role}` : ""}`,
description,
alternates: { canonical: `/artist/${slug}` },
openGraph: {
title: artist.name,
description: `${artist.role || "Artist"}${artist.releases?.length ? ` - ${artist.releases.length} releases` : ""}`,
type: "profile",
...(ogImage && { images: [{ url: ogImage, width: 1200, height: 630, alt: artist.name }] }),
},
twitter: {
card: "summary_large_image",
title: artist.name,
description,
...(ogImage && { images: [ogImage] }),
},
};
}
export default async function ArtistPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!slug) notFound();
const normalizedSlug = slug.toLowerCase();
if (slug !== normalizedSlug) {
redirect(`/artist/${normalizedSlug}`);
}
const artist = await getArtist(slug);
if (!artist) notFound();
const displayName = artist.name ?? "";
const displayRole = artist.role ?? "";
const jsonLd = {
"@context": "https://schema.org",
"@type": "Person",
name: artist.name,
url: `https://trptk.com/artist/${slug}`,
...(artist.role && { jobTitle: artist.role }),
...(artist.image && { image: urlFor(artist.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={artist.image ? urlFor(artist.image).url() : ARTIST_PLACEHOLDER_SRC}
alt={`Photo of ${artist.name}`}
/>
</div>
<div className="flex-1 content-center">
<Breadcrumb crumbs={[{ label: "Artists", href: "/artists" }]} />
<AnimatedText
text={displayName}
as="h1"
className="mb-2 font-argesta text-3xl break-words"
/>
<AnimatedText
text={displayRole}
as="h2"
className="font-silka text-base break-words text-lightsec dark:text-darksec"
delay={0.25}
/>
</div>
</div>
<ArtistTabs
bio={artist.bio}
concerts={
artist.upcomingConcerts?.length || artist.pastConcerts?.length ? (
<ArtistConcertsTab
upcoming={artist.upcomingConcerts ?? []}
past={artist.pastConcerts ?? []}
/>
) : undefined
}
hasReleases={!!artist.releases?.length}
releasesTab={<ArtistReleasesTab releases={artist.releases ?? []} />}
/>
</main>
</>
);
}