feat(audiobooks): render Also-by-author / In-series rails when API exposes them
Frontend gains a feature-detected display path for two relationship rails. Detail responses may now carry also_by_author and in_series. When present, the detail page mounts Also-by-author followed by In-series, with the current book highlighted inside the series strip. When the backend omits them — which is the case today — the rails are not rendered, so this lands as zero visible change until the backend ships the data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
07da12f8f4
commit
eeb22e47d3
@@ -43,10 +43,29 @@ export interface AudiobookDetailItem {
|
||||
genres?: string[];
|
||||
}
|
||||
|
||||
export interface AudiobookRelatedItem {
|
||||
content_id: string;
|
||||
title: string;
|
||||
poster_url?: string;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export interface AudiobookSeriesEntry {
|
||||
content_id: string;
|
||||
title: string;
|
||||
poster_url?: string;
|
||||
series_index?: number;
|
||||
}
|
||||
|
||||
export interface AudiobookDetailResponse {
|
||||
audiobook: AudiobookDetailItem;
|
||||
author?: string;
|
||||
narrator?: string;
|
||||
files: AudiobookFile[];
|
||||
progress?: AudiobookProgress;
|
||||
also_by_author?: AudiobookRelatedItem[];
|
||||
in_series?: {
|
||||
name?: string;
|
||||
entries: AudiobookSeriesEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Play } from "lucide-react";
|
||||
import AudiobookPlayer from "./player/AudiobookPlayer";
|
||||
import { ChaptersSection } from "./components/ChaptersSection";
|
||||
import { NarratorCard } from "./components/NarratorCard";
|
||||
import { RelatedRail } from "./components/RelatedRail";
|
||||
import DetailHero from "@/pages/ItemDetail/DetailHero";
|
||||
import MetadataBadges from "@/pages/ItemDetail/components/MetadataBadges";
|
||||
import type { AudiobookChapter, AudiobookFile } from "@/lib/audiobooks/types";
|
||||
@@ -231,6 +232,31 @@ export default function AudiobookDetail() {
|
||||
onSelect={(s) => openPlayer(s)}
|
||||
/>
|
||||
{narrator && <NarratorCard narrator={narrator} />}
|
||||
|
||||
{data.also_by_author && data.also_by_author.length > 0 && (
|
||||
<RelatedRail
|
||||
heading={`Also by ${author ?? "this author"}`}
|
||||
items={data.also_by_author.map((it) => ({
|
||||
content_id: it.content_id,
|
||||
title: it.title,
|
||||
poster_url: it.poster_url,
|
||||
subtitle: it.year ? String(it.year) : undefined,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.in_series && data.in_series.entries.length > 0 && (
|
||||
<RelatedRail
|
||||
heading={data.in_series.name ? `In ${data.in_series.name}` : "In this series"}
|
||||
items={data.in_series.entries.map((it) => ({
|
||||
content_id: it.content_id,
|
||||
title: it.title,
|
||||
poster_url: it.poster_url,
|
||||
subtitle: typeof it.series_index === "number" ? `Book ${it.series_index}` : undefined,
|
||||
highlight: it.content_id === contentId,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import ViewTransitionLink from "@/components/ViewTransitionLink";
|
||||
|
||||
interface RelatedRailItem {
|
||||
content_id: string;
|
||||
title: string;
|
||||
poster_url?: string;
|
||||
subtitle?: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
interface RelatedRailProps {
|
||||
heading: string;
|
||||
items: RelatedRailItem[];
|
||||
}
|
||||
|
||||
export function RelatedRail({ heading, items }: RelatedRailProps) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-4 text-xl font-semibold tracking-tight">{heading}</h2>
|
||||
<div className="-mx-2 flex gap-3 overflow-x-auto px-2 pb-2">
|
||||
{items.map((item) => (
|
||||
<ViewTransitionLink
|
||||
key={item.content_id}
|
||||
to={`/audiobooks/book/${item.content_id}`}
|
||||
className={`block w-[112px] shrink-0 ${
|
||||
item.highlight ? "ring-primary rounded-lg ring-2 ring-offset-2" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="bg-muted relative aspect-[2/3] overflow-hidden rounded-lg">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={item.poster_url}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 truncate text-[13px] font-medium">{item.title}</div>
|
||||
{item.subtitle && (
|
||||
<div className="text-muted-foreground truncate text-[11px]">{item.subtitle}</div>
|
||||
)}
|
||||
</ViewTransitionLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user