Files
silo-server/web/src/components/LibraryHeader.tsx
T
162e0cc449 feat(audiobooks): redesign audiobook library around resume and series progression (#116)
* 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>
2026-06-09 19:28:53 -04:00

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>
);
}