Files
silo-server/web/src/components/MediaCarousel.tsx
T
88aa769fe6 feat(collections): surface server collections on the user Collections tab (#156)
* 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>
2026-06-15 10:51:12 -04:00

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