Ctrl K
ComunidadGuías…

src/renderer/components/grid-carousel/grid-carousel-v2.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type { Variants } from 'motion/react';
import type { ReactNode } from 'react';

import { AnimatePresence, motion } from 'motion/react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';

import styles from './grid-carousel.module.css';

import { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
import { useContainerQuery } from '/@/renderer/hooks';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import { LibraryItem } from '/@/shared/types/domain-types';

export const useGridCarouselContainerQuery = () => {
    return useContainerQuery({
        '2xl': 1280,
        '3xl': 1440,
        lg: 960,
        md: 720,
        sm: 520,
        xl: 1152,
        xs: 360,
    });
};

interface Card {
    content: ReactNode;
    id: string;
}

interface GridCarouselProps {
    cards: Card[];
    containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;
    enableRefresh?: boolean;
    hasNextPage?: boolean;
    isFetchingNextPage?: boolean;
    loadNextPage?: () => void;
    onNextPage: (page: number) => void;
    onPrevPage: (page: number) => void;
    onRefresh?: () => void;
    placeholderItemType?: LibraryItem;
    placeholderRows?: DataRow[];
    rowCount?: number;
    title?: ReactNode | string;
}

const MemoizedCard = memo(({ content }: { content: ReactNode }) => (
    <div className={styles.card}>{content}</div>
));

MemoizedCard.displayName = 'MemoizedCard';

const pageVariants: Variants = {
    animate: { opacity: 1, transition: { duration: 0.3, ease: 'easeOut' }, x: 0 },
    exit: (custom: { isNext: boolean }) => ({
        opacity: 0,
        transition: { duration: 0.3, ease: 'easeIn' },
        x: custom.isNext ? -100 : 100,
    }),
    initial: (custom: { isNext: boolean }) => ({ opacity: 0, x: custom.isNext ? 100 : -100 }),
};

function BaseGridCarousel(props: GridCarouselProps) {
    const {
        cards,
        containerQuery: providedContainerQuery,
        enableRefresh = false,
        hasNextPage,
        isFetchingNextPage,
        loadNextPage,
        onNextPage,
        onPrevPage,
        onRefresh,
        placeholderItemType,
        placeholderRows,
        rowCount = 1,
        title,
    } = props;
    const defaultContainerQuery = useGridCarouselContainerQuery();
    const containerQuery = providedContainerQuery || defaultContainerQuery;
    const { ref, ...cq } = containerQuery;

    const [currentPage, setCurrentPage] = useState({
        isNext: false,
        page: 0,
    });

    const handlePrevPage = useCallback(() => {
        setCurrentPage((prev) => ({
            isNext: false,
            page: prev.page > 0 ? prev.page - 1 : 0,
        }));
        onPrevPage(currentPage.page);
    }, [currentPage, onPrevPage]);

    const handleNextPage = useCallback(() => {
        setCurrentPage((prev) => ({
            isNext: true,
            page: prev.page + 1,
        }));
        onNextPage(currentPage.page);
    }, [currentPage, onNextPage]);

    const cardsToShow = getCardsToShow({
        isLargerThan2xl: cq.is2xl,
        isLargerThan3xl: cq.is3xl,
        isLargerThanLg: cq.isLg,
        isLargerThanMd: cq.isMd,
        isLargerThanSm: cq.isSm,
        isLargerThanXl: cq.isXl,
    });

    const visibleCards = useMemo(() => {
        const startIndex = currentPage.page * cardsToShow * rowCount;
        const endIndex = (currentPage.page + 1) * cardsToShow * rowCount;
        const slicedCards = cards.slice(startIndex, endIndex);
        const expectedCardCount = cardsToShow * rowCount;
        const missingCardCount = expectedCardCount - slicedCards.length;

        // Add placeholder cards during loading state
        if (
            missingCardCount > 0 &&
            hasNextPage &&
            isFetchingNextPage &&
            placeholderItemType &&
            placeholderRows
        ) {
            const placeholderCards: Card[] = Array.from(
                { length: missingCardCount },
                (_, index) => ({
                    content: (
                        <MemoizedItemCard
                            data={undefined}
                            itemType={placeholderItemType}
                            rows={placeholderRows}
                            type="poster"
                        />
                    ),
                    id: `placeholder-${startIndex + slicedCards.length + index}`,
                }),
            );
            return [...slicedCards, ...placeholderCards];
        }

        return slicedCards;
    }, [
        currentPage.page,
        cardsToShow,
        rowCount,
        cards,
        hasNextPage,
        isFetchingNextPage,
        placeholderItemType,
        placeholderRows,
    ]);

    // Keep paging until the visible row is actually full of (unfiltered) cards,
    // not just once. Hiding items ("Remove from home") can filter out most of a
    // loaded page; with a large hidden set the row would otherwise stay short
    // forever. Gating on `!isFetchingNextPage` + re-running as `cards` grows
    // makes this cascade page-by-page on demand and stop the moment the row
    // fills or the server runs out (`hasNextPage` false) — no over-fetching.
    const shouldLoadNextPage =
        visibleCards.length < cardsToShow * rowCount && hasNextPage === true && !isFetchingNextPage;

    useEffect(() => {
        if (shouldLoadNextPage) {
            loadNextPage?.();
        }
    }, [loadNextPage, shouldLoadNextPage, cards.length]);

    const isPrevDisabled = currentPage.page === 0;
    const hasMoreCards = (currentPage.page + 1) * cardsToShow * rowCount < cards.length;
    const isNextDisabled = !hasMoreCards && (hasNextPage === false || hasNextPage === undefined);

    const wheelCooldownRef = useRef(0);
    const wheelThreshold = 10;
    const wheelCooldownMs = 250;

    const handleWheel = useCallback(
        (event: React.WheelEvent<HTMLDivElement>) => {
            if (!event.shiftKey) {
                return;
            }

            const now = Date.now();
            const elapsed = now - wheelCooldownRef.current;

            const horizontalDelta = Math.abs(event.deltaY);

            if (horizontalDelta < wheelThreshold || elapsed < wheelCooldownMs) {
                return;
            }

            if (event.deltaY > 0 && !isNextDisabled) {
                wheelCooldownRef.current = now;
                handleNextPage();
            } else if (event.deltaY < 0 && !isPrevDisabled) {
                wheelCooldownRef.current = now;
                handlePrevPage();
            }
        },
        [
            handleNextPage,
            handlePrevPage,
            isNextDisabled,
            isPrevDisabled,
            wheelCooldownMs,
            wheelThreshold,
        ],
    );

    const swipeCooldownRef = useRef(0);
    const dragStartTargetRef = useRef<HTMLElement | null>(null);
    const swipeCooldownMs = 300;
    const swipeThreshold = 50;
    const swipeVelocityThreshold = 500;

    const handleDragStart = useCallback((event: MouseEvent | PointerEvent | TouchEvent) => {
        dragStartTargetRef.current = (event.target as HTMLElement) || null;
    }, []);

    const handleDragEnd = useCallback(
        (
            _event: MouseEvent | PointerEvent | TouchEvent,
            info: { offset: { x: number }; velocity: { x: number } },
        ) => {
            const startTarget = dragStartTargetRef.current;
            if (startTarget) {
                if (startTarget.closest('button, a, input, select, textarea, [role="button"]')) {
                    dragStartTargetRef.current = null;
                    return;
                }
            }

            const now = Date.now();
            const elapsed = now - swipeCooldownRef.current;

            if (elapsed < swipeCooldownMs) {
                dragStartTargetRef.current = null;
                return;
            }

            const { offset, velocity } = info;
            const absOffset = Math.abs(offset.x);
            const absVelocity = Math.abs(velocity.x);

            if (absOffset > swipeThreshold || absVelocity > swipeVelocityThreshold) {
                swipeCooldownRef.current = now;

                if (offset.x > 0 && !isPrevDisabled) {
                    handlePrevPage();
                } else if (offset.x < 0 && !isNextDisabled) {
                    handleNextPage();
                }
            }

            dragStartTargetRef.current = null;
        },
        [handleNextPage, handlePrevPage, isNextDisabled, isPrevDisabled],
    );

    return (
        <div className={styles.gridCarousel} ref={ref}>
            {cq.isCalculated && (
                <>
                    <motion.div
                        className={styles.navigation}
                        drag="x"
                        dragConstraints={{ left: 0, right: 0 }}
                        dragElastic={0}
                        dragMomentum={false}
                        dragPropagation={false}
                        onDragEnd={handleDragEnd}
                        onDragStart={handleDragStart}
                    >
                        {typeof title === 'string' ? (
                            <Group gap="xs" justify="space-between" w="100%">
                                <Group gap="xs">
                                    <TextTitle fw={700} isNoSelect order={2}>
                                        {title}
                                    </TextTitle>
                                    {enableRefresh && onRefresh && (
                                        <ActionIcon
                                            icon="refresh"
                                            iconProps={{ size: 'xs' }}
                                            onClick={onRefresh}
                                            size="xs"
                                            tooltip={{ label: 'Refresh' }}
                                            variant="transparent"
                                        />
                                    )}
                                </Group>
                                <Group gap="xs" justify="end">
                                    <ActionIcon
                                        disabled={isPrevDisabled}
                                        icon="arrowLeftS"
                                        iconProps={{ size: 'lg' }}
                                        onClick={handlePrevPage}
                                        size="xs"
                                        variant="subtle"
                                    />
                                    <ActionIcon
                                        disabled={isNextDisabled}
                                        icon="arrowRightS"
                                        iconProps={{ size: 'lg' }}
                                        onClick={handleNextPage}
                                        size="xs"
                                        variant="subtle"
                                    />
                                </Group>
                            </Group>
                        ) : (
                            <div className={styles.customTitleContainer}>
                                <div className={styles.customTitleContent}>{title}</div>
                                <Group gap="xs" justify="end">
                                    <ActionIcon
                                        disabled={isPrevDisabled}
                                        icon="arrowLeftS"
                                        iconProps={{ size: 'lg' }}
                                        onClick={handlePrevPage}
                                        size="xs"
                                        variant="subtle"
                                    />
                                    <ActionIcon
                                        disabled={isNextDisabled}
                                        icon="arrowRightS"
                                        iconProps={{ size: 'lg' }}
                                        onClick={handleNextPage}
                                        size="xs"
                                        variant="subtle"
                                    />
                                </Group>
                            </div>
                        )}
                    </motion.div>
                    <AnimatePresence custom={currentPage} initial={false} mode="wait">
                        <motion.div
                            animate="animate"
                            className={styles.grid}
                            custom={currentPage}
                            exit="exit"
                            initial="initial"
                            key={currentPage.page}
                            onWheel={handleWheel}
                            style={
                                {
                                    '--cards-to-show': cardsToShow,
                                    '--row-count': rowCount,
                                } as React.CSSProperties
                            }
                            variants={pageVariants}
                        >
                            {visibleCards.map((card) => (
                                <MemoizedCard content={card.content} key={card.id} />
                            ))}
                        </motion.div>
                    </AnimatePresence>
                </>
            )}
        </div>
    );
}

export const GridCarousel = memo(BaseGridCarousel);

GridCarousel.displayName = 'GridCarousel';

interface GridCarouselSkeletonProps {
    containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;
    enableRefresh?: boolean;
    placeholderItemType: LibraryItem;
    placeholderRound?: boolean;
    placeholderRows: DataRow[];
    rowCount?: number;
    title?: ReactNode | string;
}

const GridCarouselSkeleton = (props: GridCarouselSkeletonProps) => {
    const {
        containerQuery: providedContainerQuery,
        enableRefresh = false,
        placeholderItemType,
        placeholderRound = false,
        placeholderRows,
        rowCount = 1,
        title,
    } = props;

    const defaultContainerQuery = useGridCarouselContainerQuery();
    const containerQuery = providedContainerQuery ?? defaultContainerQuery;
    const { ...cq } = containerQuery;

    const cardsToShow = cq.isCalculated
        ? getCardsToShow({
              isLargerThan2xl: cq.is2xl,
              isLargerThan3xl: cq.is3xl,
              isLargerThanLg: cq.isLg,
              isLargerThanMd: cq.isMd,
              isLargerThanSm: cq.isSm,
              isLargerThanXl: cq.isXl,
          })
        : 6;

    const placeholderCards = useMemo(() => {
        const cardCount = cardsToShow * rowCount;
        return Array.from({ length: cardCount }, (_, index) => ({
            content: (
                <MemoizedItemCard
                    data={undefined}
                    isRound={placeholderRound}
                    itemType={placeholderItemType}
                    rows={placeholderRows}
                    type="poster"
                />
            ),
            id: `skeleton-${index}`,
        }));
    }, [cardsToShow, placeholderRound, rowCount, placeholderItemType, placeholderRows]);

    return (
        <GridCarousel
            cards={placeholderCards}
            containerQuery={containerQuery}
            enableRefresh={enableRefresh}
            hasNextPage={false}
            isFetchingNextPage={false}
            onNextPage={() => {}}
            onPrevPage={() => {}}
            placeholderItemType={placeholderItemType}
            placeholderRows={placeholderRows}
            rowCount={rowCount}
            title={title}
        />
    );
};

export const GridCarouselSkeletonFallback = memo(GridCarouselSkeleton);

GridCarouselSkeletonFallback.displayName = 'GridCarouselSkeletonFallback';

function getCardsToShow(breakpoints: {
    isLargerThan2xl: boolean;
    isLargerThan3xl: boolean;
    isLargerThanLg: boolean;
    isLargerThanMd: boolean;
    isLargerThanSm: boolean;
    isLargerThanXl: boolean;
}) {
    if (breakpoints.isLargerThan3xl) {
        return 8;
    }

    if (breakpoints.isLargerThan2xl) {
        return 7;
    }

    if (breakpoints.isLargerThanXl) {
        return 6;
    }

    if (breakpoints.isLargerThanLg) {
        return 5;
    }

    if (breakpoints.isLargerThanMd) {
        return 4;
    }

    if (breakpoints.isLargerThanSm) {
        return 3;
    }

    return 2;
}