* feat(collections): surface server collections on the user Collections tab The user-facing Collections tab only showed personal collections, which are usually empty — leaving most users with a confusingly blank page. Server (admin-curated) collections were reachable only inside each individual library's tab. Add a new GET /collections/server endpoint that aggregates visible library collections across every accessible library (honoring access scope, capped per library with a total_count for a See all link), and restructure Collections.tsx into two titled sections: Your collections (personal) and Server collections (horizontal teaser rows per library, linking into each library's Collections tab). Extract the shared CollectionPosterCard so the per-library grid and the new rows share one implementation. * fix(collections): match server-collections loading skeleton to row layout The Server collections section renders as one horizontal teaser row per library, but the loading skeleton showed a poster grid — so data arriving visibly reflowed the page from a grid into rows. Mirror the final layout (section header + per-library rows of poster cards) in the skeleton, and drop the now-unused COLLECTION_POSTER_GRID_CLASSES import. Addresses CodeRabbit review comment on PR #156. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Align server collections with shared carousel behavior - Add opt-out edge padding to reusable media carousels - Render server collection rows with shared carousel controls and spacing --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
141 lines
5.2 KiB
TypeScript
141 lines
5.2 KiB
TypeScript
import { Children } from "react";
|
|
import type { ReactNode } from "react";
|
|
import { Link } from "react-router";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useCarouselEmbla } from "@/hooks/useCarouselEmbla";
|
|
|
|
interface MediaCarouselProps {
|
|
title: string;
|
|
titleHref?: string;
|
|
loading?: boolean;
|
|
children: ReactNode;
|
|
skeletonCount?: number;
|
|
skeletonAspect?: string;
|
|
onViewAll?: () => void;
|
|
/** Optional actions rendered next to the title (e.g. pin button) */
|
|
headerActions?: ReactNode;
|
|
/**
|
|
* When true (default) the carousel applies its own page-edge horizontal
|
|
* padding, so it sits correctly on full-bleed pages (Home, Library). Set to
|
|
* false when embedding inside an already-padded container (e.g. a
|
|
* `page-shell` page) so the header and cards align to the parent's content
|
|
* box instead of picking up a second layer of padding.
|
|
*/
|
|
edgePadding?: boolean;
|
|
}
|
|
|
|
export default function MediaCarousel({
|
|
title,
|
|
titleHref,
|
|
loading = false,
|
|
children,
|
|
skeletonCount = 7,
|
|
skeletonAspect = "aspect-[2/3]",
|
|
onViewAll,
|
|
headerActions,
|
|
edgePadding = true,
|
|
}: MediaCarouselProps) {
|
|
const { emblaRef, canScrollPrev, canScrollNext, scrollPrev, scrollNext } = useCarouselEmbla();
|
|
// Page-edge padding is opt-out so the carousel can also be embedded in an
|
|
// already-padded container without double-padding the header and cards.
|
|
const headerPadX = edgePadding ? " px-4 sm:px-6 lg:px-10 xl:px-12" : "";
|
|
const viewportPadX = edgePadding ? " pr-4 sm:pr-6 lg:pr-10 xl:pr-12" : "";
|
|
const containerPadX = edgePadding ? " pl-4 sm:pl-6 lg:pl-10 xl:pl-12" : "";
|
|
const slideChildren = loading
|
|
? Array.from({ length: skeletonCount }).map((_, i) => (
|
|
<div key={i} className="w-[130px] sm:w-[150px] lg:w-[178px]">
|
|
<Skeleton className={`w-full ${skeletonAspect} rounded-lg`} />
|
|
<Skeleton className="mt-2 h-4 w-3/4 rounded" />
|
|
<Skeleton className="mt-1 h-3 w-1/2 rounded" />
|
|
</div>
|
|
))
|
|
: Children.toArray(children);
|
|
|
|
return (
|
|
<section className="section-row group/carousel relative isolate">
|
|
<div className={`mb-5 flex items-end justify-between gap-4${headerPadX}`}>
|
|
<div className="flex items-center gap-2">
|
|
{titleHref ? (
|
|
<Link to={titleHref} className="group/title hover:text-primary transition-colors">
|
|
<h2 className="text-foreground text-xl font-semibold tracking-tight">
|
|
{title}
|
|
<span className="text-muted-foreground group-hover/title:text-primary ml-2 text-sm transition-colors">
|
|
View
|
|
</span>
|
|
</h2>
|
|
</Link>
|
|
) : (
|
|
<h2 className="text-foreground text-xl font-semibold tracking-tight">{title}</h2>
|
|
)}
|
|
{headerActions}
|
|
</div>
|
|
{onViewAll && (
|
|
<button
|
|
onClick={onViewAll}
|
|
className="text-muted-foreground hover:text-primary text-[12px] font-semibold tracking-[0.16em] uppercase transition-all active:scale-[0.98]"
|
|
>
|
|
Explore all
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative">
|
|
{/* Left edge gradient */}
|
|
{canScrollPrev && (
|
|
<div className="from-background/80 pointer-events-none absolute top-0 bottom-0 left-0 z-[5] w-10 bg-gradient-to-r to-transparent" />
|
|
)}
|
|
|
|
{/* Left arrow */}
|
|
{canScrollPrev && (
|
|
<button
|
|
type="button"
|
|
onClick={scrollPrev}
|
|
className="from-background/80 absolute top-0 bottom-0 left-0 z-10 flex h-11 w-11 items-center justify-center self-center bg-gradient-to-r to-transparent opacity-0 transition-opacity duration-[--duration-fast] group-hover/carousel:opacity-100 focus-visible:opacity-100"
|
|
aria-label="Scroll left"
|
|
>
|
|
<ChevronLeft className="text-foreground h-6 w-6" />
|
|
</button>
|
|
)}
|
|
|
|
<div
|
|
ref={emblaRef}
|
|
className={`embla__viewport overflow-hidden${viewportPadX}`}
|
|
tabIndex={0}
|
|
aria-label="Media carousel"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "ArrowLeft") {
|
|
scrollPrev();
|
|
} else if (e.key === "ArrowRight") {
|
|
scrollNext();
|
|
}
|
|
}}
|
|
>
|
|
<ul
|
|
role="list"
|
|
className={`embla__container flex cursor-grab list-none gap-4 lg:gap-5${containerPadX}`}
|
|
>
|
|
{slideChildren.map((child, index) => (
|
|
<li key={index} className="embla__slide shrink-0">
|
|
{child}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Right arrow */}
|
|
{canScrollNext && (
|
|
<button
|
|
type="button"
|
|
onClick={scrollNext}
|
|
className="from-background/80 absolute top-0 right-0 bottom-0 z-10 flex h-11 w-11 items-center justify-center self-center bg-gradient-to-l to-transparent opacity-0 transition-opacity duration-[--duration-fast] group-hover/carousel:opacity-100 focus-visible:opacity-100"
|
|
aria-label="Scroll right"
|
|
>
|
|
<ChevronRight className="text-foreground h-6 w-6" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|