feat(audiobooks): library grid page at /audiobooks
Renders a paginated grid of audiobook cards using the
useAudiobookLibrary hook. Each card links to /audiobooks/book/{id}.
Cards show poster, title, and year; falls back to a "No cover"
placeholder when the audiobook has no poster_url. Empty state hints
to operators that they need to set a library's type to 'audiobooks'.
Routes themselves are wired in Task 8 (navigation integration).
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
cfd682470b
commit
4fa8895556
@@ -0,0 +1,123 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import ScrollToTopButton from "@/components/ScrollToTopButton";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import ViewTransitionLink from "@/components/ViewTransitionLink";
|
||||
import { useAudiobookLibrary } from "@/hooks/audiobooks/useAudiobookLibrary";
|
||||
|
||||
const PAGE_SIZE = 60;
|
||||
|
||||
const GRID_CLASSES =
|
||||
"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3";
|
||||
|
||||
export default function AudiobookLibrary() {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const { data, isLoading, error } = useAudiobookLibrary({ limit: PAGE_SIZE, offset });
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="space-y-5 py-2 sm:space-y-6">
|
||||
<h1 className="text-3xl font-semibold">Audiobooks</h1>
|
||||
<div role="list" className={GRID_CLASSES}>
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<div key={i} role="listitem">
|
||||
<Skeleton className="aspect-[2/3] rounded-xl" />
|
||||
<Skeleton className="mt-2 h-4 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-5 py-2 sm:space-y-6">
|
||||
<h1 className="text-3xl font-semibold">Audiobooks</h1>
|
||||
<div className="text-destructive py-12 text-center">
|
||||
Failed to load audiobooks: {error instanceof Error ? error.message : "Unknown error"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-5 py-2 sm:space-y-6">
|
||||
<h1 className="text-3xl font-semibold">Audiobooks</h1>
|
||||
{items.length === 0 ? (
|
||||
<div className="text-muted-foreground py-12 text-center">
|
||||
No audiobooks indexed yet. Set a library's type to{" "}
|
||||
<code className="text-foreground bg-muted rounded px-1 py-0.5 text-sm">audiobooks</code>{" "}
|
||||
and rescan.
|
||||
</div>
|
||||
) : (
|
||||
<div role="list" className={GRID_CLASSES}>
|
||||
{items.map((item) => (
|
||||
<div key={item.content_id} role="listitem" className="media-card group/card">
|
||||
<ViewTransitionLink
|
||||
to={`/audiobooks/book/${item.content_id}`}
|
||||
className="block overflow-hidden rounded-xl"
|
||||
>
|
||||
<div className="media-card-image relative aspect-[2/3]">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={item.poster_url}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-1 p-3 text-center text-sm">
|
||||
<span className="line-clamp-3 font-medium">{item.title || "No Cover"}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="from-background/70 pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t to-transparent opacity-90" />
|
||||
</div>
|
||||
</ViewTransitionLink>
|
||||
<ViewTransitionLink
|
||||
to={`/audiobooks/book/${item.content_id}`}
|
||||
className="block px-1 pt-3"
|
||||
>
|
||||
<div className="truncate text-[14px] font-semibold tracking-tight">
|
||||
{item.title}
|
||||
</div>
|
||||
{item.year > 0 && (
|
||||
<div className="text-muted-foreground mt-1 text-[11px] font-medium tracking-[0.14em] uppercase">
|
||||
{item.year}
|
||||
</div>
|
||||
)}
|
||||
</ViewTransitionLink>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="mt-8 flex justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border px-4 py-2 text-sm disabled:opacity-50"
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
disabled={offset === 0}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-muted-foreground self-center text-sm">
|
||||
{offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border px-4 py-2 text-sm disabled:opacity-50"
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
disabled={offset + PAGE_SIZE >= total}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ScrollToTopButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user