502 lines
16 KiB
TypeScript
502 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) {
|
|
const product = release.upc
|
|
? await getProductByEan(release.upc)
|
|
: await getProductByCatalogNo(release.catalogNo);
|
|
if (product) {
|
|
formatGroups = matchVariants(release.catalogNo, product.variants);
|
|
}
|
|
}
|
|
|
|
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">
|
|
“{review.quote}”
|
|
</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>
|
|
</>
|
|
);
|
|
}
|