import { useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import type { HomeSectionItemsResponse, ResolvedSection } from "@/api/types"; import { useLibraryCollectionItems } from "@/hooks/queries/libraryCollections"; import { fetchLibrarySectionItems, useLibraryLayout } from "@/hooks/queries/sections"; import { useSidebarPins } from "@/hooks/queries/sidebarPins"; import MediaCarousel from "@/components/MediaCarousel"; import ItemCard from "@/components/ItemCard"; import { useOverlayPrefs } from "@/hooks/useOverlayPrefs"; import HeroBanner from "@/components/HeroBanner"; import SectionRow from "@/components/SectionRow"; import { Skeleton } from "@/components/ui/skeleton"; import { HERO_BANNER_SIZE_TALL } from "@/lib/design-system"; import { sectionKeys } from "@/hooks/queries/keys"; import { planNextHomeSectionBatch } from "./homeSectionQueue"; import { buildHomeSectionViewModel, type HomeSectionSlot } from "./homeSectionState"; import { collectCachedHomeSections } from "./homeSectionCache"; interface LibraryRecommendedProps { libraryId: number; /** * Reports whether the page is currently rendering a hero banner (i.e. a * featured section resolved with items). Parent uses this to decide whether * to apply transparent-overlay styling to the sticky marquee header. */ onHeroStateChange?: (hasRenderedHero: boolean) => void; } const SECTION_STALE_TIME = 5 * 60 * 1000; const MAX_CONCURRENT_SECTION_REQUESTS = 4; export default function LibraryRecommended({ libraryId, onHeroStateChange, }: LibraryRecommendedProps) { const queryClient = useQueryClient(); const { data, isLoading } = useLibraryLayout(libraryId); const [loadedSections, setLoadedSections] = useState>(new Map()); const [failedIds, setFailedIds] = useState>(new Set()); const [inFlightIds, setInFlightIds] = useState>(new Set()); const [completedIds, setCompletedIds] = useState>(new Set()); const activeSectionIdsRef = useRef>(new Set()); const layout = useMemo(() => data?.sections ?? [], [data?.sections]); const layoutResetKey = layout .map( (section) => `${section.id}:${section.section_type}:${section.title}:${section.featured ? "featured" : "row"}:${section.item_limit}:${section.is_custom ? "custom" : "default"}:${section.customized ? "customized" : "clean"}`, ) .join("|"); useEffect(() => { const activeIds = layout.map((section) => section.id); activeSectionIdsRef.current = new Set(activeIds); setLoadedSections( collectCachedHomeSections(layout, (sectionId) => queryClient.getQueryData( sectionKeys.libraryItems(libraryId, sectionId), ), ), ); setFailedIds(new Set()); setInFlightIds(new Set()); setCompletedIds(new Set()); return () => { activeSectionIdsRef.current = new Set(); activeIds.forEach((sectionId) => { void queryClient.cancelQueries({ queryKey: sectionKeys.libraryItems(libraryId, sectionId), }); }); }; }, [libraryId, layout, layoutResetKey, queryClient]); useEffect(() => { if (layout.length === 0) return; const nextIds = planNextHomeSectionBatch({ layout, loadedIds: completedIds, inFlightIds, maxConcurrentRequests: MAX_CONCURRENT_SECTION_REQUESTS, }); if (nextIds.length === 0) return; setInFlightIds((prev) => { const next = new Set(prev); nextIds.forEach((id) => next.add(id)); return next; }); nextIds.forEach((sectionId) => { void queryClient .fetchQuery({ queryKey: sectionKeys.libraryItems(libraryId, sectionId), queryFn: ({ signal }) => fetchLibrarySectionItems(libraryId, sectionId, { signal }), staleTime: SECTION_STALE_TIME, }) .then((response) => { if (!activeSectionIdsRef.current.has(sectionId)) return; setLoadedSections((prev) => { const next = new Map(prev); next.set(sectionId, response.section); return next; }); setFailedIds((prev) => { if (!prev.has(sectionId)) return prev; const next = new Set(prev); next.delete(sectionId); return next; }); setCompletedIds((prev) => { const next = new Set(prev); next.add(sectionId); return next; }); }) .catch(() => { if (!activeSectionIdsRef.current.has(sectionId)) return; setFailedIds((prev) => { const next = new Set(prev); next.add(sectionId); return next; }); setCompletedIds((prev) => { const next = new Set(prev); next.add(sectionId); return next; }); }) .finally(() => { if (!activeSectionIdsRef.current.has(sectionId)) return; setInFlightIds((prev) => { if (!prev.has(sectionId)) return prev; const next = new Set(prev); next.delete(sectionId); return next; }); }); }); }, [completedIds, inFlightIds, layout, libraryId, queryClient]); const viewModel = buildHomeSectionViewModel({ layout, loadedSections, failedIds, }); // Tell the parent whether a hero is actually rendered — i.e. a featured // section resolved with at least one item. Error and empty states are NOT // considered a rendered hero, so the marquee header can drop back to its // glass variant (which is legible on any theme). const hasRenderedHero = viewModel.hero?.state === "ready" && (viewModel.hero.section?.items.length ?? 0) > 0; useEffect(() => { onHeroStateChange?.(hasRenderedHero); }, [hasRenderedHero, onHeroStateChange]); const retrySection = (sectionId: string) => { queryClient.removeQueries({ queryKey: sectionKeys.libraryItems(libraryId, sectionId) }); setFailedIds((prev) => { if (!prev.has(sectionId)) return prev; const next = new Set(prev); next.delete(sectionId); return next; }); setCompletedIds((prev) => { if (!prev.has(sectionId)) return prev; const next = new Set(prev); next.delete(sectionId); return next; }); }; if (isLoading && layout.length === 0) { return null; } return (
{renderHeroSlot(viewModel.hero, retrySection, libraryId)} {viewModel.rows.map((slot) => { if (slot.state === "empty") { return null; } if (slot.state === "ready" && slot.section) { return ; } if (slot.state === "error") { return ( retrySection(slot.layout.id)} /> ); } return ; })} {/* Pinned Collections */}
); } function renderHeroSlot( hero: HomeSectionSlot | null, retrySection: (sectionId: string) => void, libraryId: number, ) { if (!hero) return null; if (hero.state === "ready" && hero.section) { return ( ); } if (hero.state === "error") { return (

The featured section could not be loaded right now.

); } if (hero.state === "empty") { return null; } return ; } function SectionLoadingRow({ title }: { title: string }) { return ( {null} ); } function SectionErrorRow({ title, onRetry }: { title: string; onRetry: () => void }) { return (

{title}

This section could not be loaded right now.

); } function PinnedCollections({ libraryId }: { libraryId: number }) { const { pins } = useSidebarPins(); const pinnedCollections = (pins[String(libraryId)] ?? []).filter( (pin) => pin.type === "collection", ); return ( <> {pinnedCollections.map((collection) => ( ))} ); } function PinnedCollectionCarousel({ libraryId, collectionId, name, }: { libraryId: number; collectionId: string; name: string; }) { const { data: items, isLoading } = useLibraryCollectionItems(libraryId, collectionId); const { prefs: overlayPrefs } = useOverlayPrefs(); if (!isLoading && (!items || items.length === 0)) return null; return ( {(items ?? []).map((item) => (
))}
); }