* feat(audiobooks): redesign audiobook library around resume and series progression Audiobook libraries previously reused the video-shaped library page: a backdrop carousel hero (audiobooks have square covers and no backdrops), movie-style default sections, and a browse grid whose primary audiobook axes (author, narrator, series) were buried as filters. Backend: - New next_in_series section type: surfaces the next unstarted book, by series_index, in series the profile has finished a book of, ordered by most recent finish. Registered as a library-staple recipe. - New GET /api/v1/catalog/audiobook-groups endpoint: grouped browse by author/narrator/series with book count, total duration, per-profile progress counts, and poster URLs for cover stacks. - Audiobook library defaults: continue-listening is featured (renders as the Now Listening hero) with next-in-series directly after it. A data migration upgrades existing audiobook libraries, skipping layouts where an admin already featured a section. Frontend: - NowListeningHero replaces HeroBanner for audiobook libraries: resume deck with chapter position, hours left, ambient color from the cover, and one-click resume; remaining in-progress books render as the Continue Listening row. - Library tab gains Books/Series/Authors/Narrators browse axes persisted via the type param; selecting a group drops into the Books grid with the matching filter applied. - "Recommended" tab is labeled "Home" for audiobook libraries; audiobook continue cards use square covers and hr/min time-left formatting. - Shared audiobook chapter/file/duration helpers extracted to web/src/lib/audiobooks (deduplicated from AudiobookContent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(audiobooks): address review feedback on library redesign - Push library scoping into the next-in-series candidate SQL so finished series whose next book lives in another library can't consume the candidate limit and starve a library-scoped section (Codex P2). - Paginate the audiobook groups fetch until the server-reported total is reached (500/page, 20-page bound) so client-side filtering sees the complete author/narrator/series list (Codex P2, CodeRabbit). - Make the redesign migration rollback-safe: rows the Up touches carry config markers (featured_by_migration / seeded_by_migration) and the Down reverts only marked rows, leaving admin-set featured state and hand-created next_in_series sections alone (Codex P2, CodeRabbit). - Gate NowListeningHero's detail-derived files/credits on the detail matching the deck item, so Resume can't start the new book with the previous book's files while keepPreviousData shows stale detail (Codex P2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
84 lines
2.8 KiB
TypeScript
84 lines
2.8 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Tabs as TabsPrimitive } from "radix-ui";
|
|
import { cn } from "@/lib/utils";
|
|
import { isAudiobookLibraryType } from "@/pages/libraryPageSearchParams";
|
|
|
|
type LibraryTab = "recommended" | "library" | "collections";
|
|
|
|
interface LibraryHeaderProps {
|
|
libraryName: string;
|
|
/** Library type ("movies", "series", "audiobooks", ...); drives tab labels. */
|
|
libraryType?: string;
|
|
/**
|
|
* When true, the header renders transparently to sit over a hero backdrop,
|
|
* and switches to a glass surface once the user scrolls past a threshold.
|
|
*/
|
|
overlay?: boolean;
|
|
availableTabs?: readonly LibraryTab[];
|
|
}
|
|
|
|
const DEFAULT_TABS: readonly LibraryTab[] = ["recommended", "library", "collections"];
|
|
|
|
const TAB_LABELS: Record<LibraryTab, string> = {
|
|
recommended: "Recommended",
|
|
library: "Library",
|
|
collections: "Collections",
|
|
};
|
|
|
|
// Audiobook libraries open on a resume-first deck rather than a discovery
|
|
// feed, so "Recommended" would mislabel what the tab actually shows.
|
|
const AUDIOBOOK_TAB_LABELS: Record<LibraryTab, string> = {
|
|
...TAB_LABELS,
|
|
recommended: "Home",
|
|
};
|
|
|
|
/** Scroll distance (in px) at which an overlay header switches to glass. */
|
|
const GLASS_THRESHOLD_PX = 160;
|
|
|
|
export default function LibraryHeader({
|
|
libraryName,
|
|
libraryType = "",
|
|
overlay = false,
|
|
availableTabs = DEFAULT_TABS,
|
|
}: LibraryHeaderProps) {
|
|
const tabLabels = isAudiobookLibraryType(libraryType) ? AUDIOBOOK_TAB_LABELS : TAB_LABELS;
|
|
const [pastThreshold, setPastThreshold] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!overlay) return;
|
|
const update = () => {
|
|
setPastThreshold(window.scrollY > GLASS_THRESHOLD_PX);
|
|
};
|
|
update();
|
|
window.addEventListener("scroll", update, { passive: true });
|
|
return () => window.removeEventListener("scroll", update);
|
|
}, [overlay]);
|
|
|
|
const scrolled = overlay && pastThreshold;
|
|
|
|
return (
|
|
<header
|
|
className={cn("library-marquee-header", overlay && "is-overlay")}
|
|
data-scrolled={overlay ? (scrolled ? "true" : "false") : undefined}
|
|
>
|
|
{/* Eyebrow is hidden on mobile — the top nav already identifies the
|
|
current page, and at narrow widths it would either crowd out the
|
|
tab pills or truncate to "LIBRAR…". */}
|
|
<div className="hidden min-w-0 items-center gap-4 sm:flex">
|
|
<p className="hero-eyebrow truncate">
|
|
<span>Library</span>
|
|
<span className="hero-eyebrow-divider">/</span>
|
|
<span className="hero-eyebrow-strong">{libraryName}</span>
|
|
</p>
|
|
</div>
|
|
<TabsPrimitive.List className="marquee-tab-bar" aria-label="Library view">
|
|
{availableTabs.map((tab) => (
|
|
<TabsPrimitive.Trigger key={tab} value={tab} className="marquee-tab-trigger">
|
|
{tabLabels[tab]}
|
|
</TabsPrimitive.Trigger>
|
|
))}
|
|
</TabsPrimitive.List>
|
|
</header>
|
|
);
|
|
}
|