trptk/app/release/[slug]/page.tsx

506 lines
16 KiB
TypeScript

import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { cache } from "react";
import { PortableText } from "@portabletext/react";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { portableTextComponents } from "@/lib/portableTextComponents";
import { ReleaseCover } from "@/components/release/ReleaseCover";
import { AnimatedText } from "@/components/AnimatedText";
import { urlFor } from "@/lib/sanityImage";
import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants";
import { ArrowLink } from "@/components/ArrowLink";
import { BookletLink } from "@/components/release/BookletLink";
import { Breadcrumb } from "@/components/Breadcrumb";
import { formatYears, type ArtistCardData, type ComposerCardData } from "@/components/artist/types";
import { ArtistCardCompact } from "@/components/artist/ArtistCardCompact";
import { Tracklist, type TrackData } from "@/components/release/Tracklist";
import { DetailTable } from "@/components/release/DetailTable";
import type { SanityImageSource } from "@sanity/image-url";
import { getProductByEan, getProductByCatalogNo } from "@/lib/medusa";
import { matchVariants } from "@/lib/variants";
import { FormatSelector } from "@/components/release/FormatSelector";
import { parseDuration, toISO8601Duration } from "@/lib/duration";
export const dynamicParams = true;
export const revalidate = 86400;
type Review = {
quote?: string;
author?: string;
};
type Credit = {
role?: string;
name?: string;
};
type EquipmentItem = {
type?: string;
name?: string;
};
type ReleaseDetail = {
name?: string;
albumArtist?: string;
label?: string;
catalogNo?: string;
upc?: string;
slug?: string;
releaseDate?: string;
shortDescription?: string;
description?: any;
albumCover?: SanityImageSource;
format?: string;
genre?: string[];
spotifyUrl?: string;
appleMusicUrl?: string;
tidalUrl?: string;
artists?: ArtistCardData[];
composers?: ComposerCardData[];
arrangers?: ComposerCardData[];
tracks?: TrackData[];
reviews?: Review[];
credits?: Credit[];
recordingDate?: string;
recordingLocation?: string;
recordingFormat?: string;
masteringFormat?: string;
bookletPdfUrl?: string;
equipment?: EquipmentItem[];
};
const RELEASE_DETAIL_QUERY = defineQuery(`
*[_type == "release" && slug.current == $slug][0]{
name,
albumArtist,
label,
catalogNo,
upc,
"slug": slug.current,
releaseDate,
shortDescription,
description,
albumCover,
format,
genre[],
spotifyUrl,
appleMusicUrl,
tidalUrl,
"artists": artists[]->{
_id,
name,
role,
"slug": slug.current,
image
},
"composers": tracks[].work->composer->{
_id,
name,
sortKey,
"slug": slug.current,
birthYear,
deathYear,
image
},
"arrangers": tracks[].work->arranger->{
_id,
name,
sortKey,
"slug": slug.current,
birthYear,
deathYear,
image
},
tracks[]{
"workId": work->_id,
"workTitle": work->title,
"composerName": work->composer->name,
"arrangerName": work->arranger->name,
movement,
displayTitle,
duration,
artist,
"previewMp3Url": previewMp3.asset->url
},
reviews[]{ quote, author },
credits[]{ role, name },
recordingDate,
recordingLocation,
recordingFormat,
masteringFormat,
"bookletPdfUrl": bookletPdf.asset->url,
equipment[]{ type, name }
}
`);
const getRelease = cache(async (slug: string) => {
try {
const release = await sanity.fetch<ReleaseDetail>(RELEASE_DETAIL_QUERY, { slug });
return release;
} catch (error) {
console.error("Failed to fetch release:", error);
return null;
}
});
const RELEASE_SLUGS_QUERY = defineQuery(
`*[_type == "release" && defined(slug.current)]{ "slug": slug.current }`,
);
export async function generateStaticParams() {
const slugs = await sanity.fetch<Array<{ slug: string }>>(RELEASE_SLUGS_QUERY);
return slugs.map((r) => ({ slug: r.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug: rawSlug } = await params;
const slug = rawSlug.toLowerCase();
const release = await getRelease(slug);
if (!release) notFound();
const description =
release.shortDescription ??
`Listen to ${release.name}${release.albumArtist ? ` by ${release.albumArtist}` : ""} on TRPTK.`;
const ogImage = release.albumCover
? urlFor(release.albumCover).width(1200).height(1200).url()
: undefined;
return {
title: `${release.albumArtist ? `${release.albumArtist}` : ""}${release.name}`,
description,
alternates: { canonical: `/release/${slug}` },
openGraph: {
title: release.name,
description: `${release.albumArtist || "TRPTK"}${release.label ? ` - ${release.label}` : ""}`,
type: "music.album",
...(ogImage && {
images: [
{
url: ogImage,
width: 1200,
height: 1200,
alt: `${release.name} by ${release.albumArtist}`,
},
],
}),
},
twitter: {
card: "summary_large_image",
title: release.name,
description,
...(ogImage && { images: [ogImage] }),
},
};
}
function formatReleaseDate(dateString?: string) {
if (!dateString) return null;
return new Intl.DateTimeFormat("en-US", {
day: "numeric",
month: "long",
year: "numeric",
}).format(new Date(dateString));
}
function dedupeById<T extends { _id: string }>(items: (T | null)[]): T[] {
const seen = new Set<string>();
return items.filter((item): item is T => {
if (!item || !item._id || seen.has(item._id)) return false;
seen.add(item._id);
return true;
});
}
export default async function ReleasePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!slug) notFound();
const normalizedSlug = slug.toLowerCase();
if (slug !== normalizedSlug) {
redirect(`/release/${normalizedSlug}`);
}
const release = await getRelease(slug);
if (!release) notFound();
const displayName = release.name ?? "";
const displayArtist = release.albumArtist ?? "";
const displayDate = formatReleaseDate(release.releaseDate);
const artists = release.artists
? dedupeById(release.artists).sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
: [];
const composers = dedupeById([...(release.composers ?? []), ...(release.arrangers ?? [])]).sort(
(a, b) => (a.sortKey ?? "").localeCompare(b.sortKey ?? ""),
);
const hasCreditsInfo =
(release.credits && release.credits.length > 0) ||
release.recordingDate ||
release.recordingLocation ||
release.recordingFormat ||
release.masteringFormat ||
release.releaseDate ||
release.bookletPdfUrl;
const hasEquipment = release.equipment && release.equipment.length > 0;
// Medusa product lookup — match by UPC/EAN, fallback to catalogue number
let formatGroups: Awaited<ReturnType<typeof matchVariants>> = [];
if (release.catalogNo) {
console.log("[Medusa] Looking up product for catalogNo:", release.catalogNo, "upc:", release.upc);
console.log("[Medusa] MEDUSA_URL:", process.env.MEDUSA_URL ?? process.env.NEXT_PUBLIC_MEDUSA_URL ?? "(not set)");
const product = release.upc
? await getProductByEan(release.upc)
: await getProductByCatalogNo(release.catalogNo);
console.log("[Medusa] Product found:", product ? product.id : "null");
if (product) {
formatGroups = matchVariants(release.catalogNo, product.variants);
console.log("[Medusa] Format groups:", formatGroups.length);
}
}
const albumUrl = `https://trptk.com/release/${slug}`;
const formatMap: Record<string, string> = {
album: "AlbumRelease",
ep: "EPRelease",
single: "SingleRelease",
};
const genreLabelMap: Record<string, string> = {
earlyMusic: "Early Music",
baroque: "Baroque",
classical: "Classical",
romantic: "Romantic",
contemporary: "Contemporary",
worldMusic: "World Music",
jazz: "Jazz",
crossover: "Crossover",
electronic: "Electronic",
minimal: "Minimal",
popRock: "Pop / Rock",
};
const sameAs = [release.spotifyUrl, release.appleMusicUrl, release.tidalUrl].filter(
Boolean,
) as string[];
const jsonLd = {
"@context": "https://schema.org",
"@type": "MusicAlbum",
"@id": albumUrl,
name: release.name,
url: albumUrl,
...(release.shortDescription && { description: release.shortDescription }),
...(release.albumArtist && {
byArtist: { "@type": "MusicGroup", name: release.albumArtist },
}),
...(release.albumCover && {
image: urlFor(release.albumCover).width(800).url(),
}),
...(release.releaseDate && { datePublished: release.releaseDate }),
...(release.catalogNo && { catalogNumber: release.catalogNo }),
...(release.label === "TRPTK" && {
recordLabel: { "@type": "Organization", name: "TRPTK" },
}),
...(release.format &&
formatMap[release.format] && {
albumReleaseType: `https://schema.org/${formatMap[release.format]}`,
}),
...(release.genre?.length && {
genre: release.genre.map((g) => genreLabelMap[g] || g),
}),
...(sameAs.length > 0 && { sameAs }),
...(release.tracks?.length && {
numTracks: release.tracks.length,
track: release.tracks.map((t, i) => {
const seconds = parseDuration(t.duration);
const iso = toISO8601Duration(seconds);
return {
"@type": "MusicRecording",
name: t.displayTitle || t.workTitle,
position: i + 1,
...(iso && { duration: iso }),
inAlbum: { "@id": albumUrl },
};
}),
}),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<main className="mx-auto my-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
{/* Hero */}
<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={release.albumCover ? urlFor(release.albumCover).url() : ARTIST_PLACEHOLDER_SRC}
alt={`Album cover for ${displayName} by ${displayArtist}`}
/>
</div>
<div className="flex-1 content-center">
<Breadcrumb crumbs={[{ label: "Releases", href: "/releases" }]} />
<AnimatedText
text={displayName}
as="h1"
className="mb-2 font-argesta text-3xl break-words"
/>
<AnimatedText
text={displayArtist}
as="h2"
className="font-silka text-base break-words text-lightsec dark:text-darksec"
delay={0.25}
/>
{formatGroups.length > 0 && (
<FormatSelector groups={formatGroups} releaseDate={release.releaseDate} />
)}
</div>
</div>
{/* Description & Tracklist */}
{(release.description || true) && (
<section className="mt-16 grid grid-cols-1 gap-10 sm:mt-20 sm:gap-12 md:gap-16 lg:grid-cols-2 lg:gap-20">
<div>
<AnimatedText text="About the album" as="h2" className="mb-4 font-argesta text-2xl" />
{release.description ? (
<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 prose-strong:font-silkasb">
<PortableText value={release.description} components={portableTextComponents} />
</article>
) : (
<p className="text-lightsec dark:text-darksec">No description available yet.</p>
)}
</div>
<div>
<AnimatedText text="Tracklist" as="h2" className="mb-4 font-argesta text-2xl" />
<Tracklist
tracks={release.tracks ?? []}
albumCover={release.albumCover}
albumArtist={displayArtist}
releaseSlug={slug}
/>
</div>
</section>
)}
<section className="mt-16 grid grid-cols-1 gap-16 sm:mt-20 md:grid-cols-2 md:gap-12 lg:gap-20">
{/* Artists */}
{artists.length > 0 && (
<div className="">
<AnimatedText text="Artists" as="h2" className="mb-4 font-argesta text-2xl" />
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2">
{artists.map((artist) => (
<ArtistCardCompact
key={artist._id}
name={artist.name}
subtitle={artist.role}
image={artist.image}
href={artist.slug ? `/artist/${artist.slug}` : "/artists/"}
/>
))}
</ul>
</div>
)}
{/* Composers */}
{composers.length > 0 && (
<div className="">
<AnimatedText text="Composers" as="h2" className="mb-4 font-argesta text-2xl" />
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2">
{composers.map((composer) => (
<ArtistCardCompact
key={composer._id}
name={composer.name}
subtitle={formatYears(composer.birthYear, composer.deathYear)}
image={composer.image}
href={composer.slug ? `/composer/${composer.slug}` : "/composers/"}
label="Composer"
/>
))}
</ul>
</div>
)}
</section>
{/* Reviews */}
{release.reviews && release.reviews.length > 0 && (
<section className="mx-auto my-32 max-w-150 sm:my-40">
<div className="grid grid-cols-1 gap-12">
{release.reviews.map((review, i) => (
<blockquote key={i} className="rounded-xl text-center">
{review.quote && (
<p className="leading-relaxed text-lighttext dark:text-darktext">
&ldquo;{review.quote}&rdquo;
</p>
)}
{review.author && (
<footer className="mt-2 text-sm text-lightsec dark:text-darksec">
{review.author}
</footer>
)}
</blockquote>
))}
</div>
</section>
)}
{/* Credits & Equipment */}
{(hasCreditsInfo || hasEquipment) && (
<section className="mt-16 grid grid-cols-1 gap-16 sm:mt-20 md:grid-cols-2 md:gap-12 lg:gap-20">
{/* Credits */}
{hasCreditsInfo && (
<div>
<AnimatedText text="Credits" as="h2" className="mb-4 font-argesta text-2xl" />
<DetailTable
rows={[
...(release.credits?.map((c) => ({ label: c.role ?? "", value: c.name })) ??
[]),
{ label: "Recording date", value: release.recordingDate },
{ label: "Recording location", value: release.recordingLocation },
{ label: "Recording format", value: release.recordingFormat },
{ label: "Mastering format", value: release.masteringFormat },
{ label: "Release date", value: displayDate ?? undefined },
{
label: "Booklet",
value: release.bookletPdfUrl ? (
<BookletLink slug={release.slug!} />
) : undefined,
},
]}
/>
</div>
)}
{/* Equipment */}
{hasEquipment && (
<div>
<AnimatedText
text="Technical specifications"
as="h2"
className="mb-4 font-argesta text-2xl"
/>
<DetailTable
rows={release.equipment!.map((item) => ({
label: item.type ?? "",
value: item.name,
}))}
/>
</div>
)}
</section>
)}
</main>
</>
);
}