add new grid carousels

This commit is contained in:
jeffvli
2025-11-15 19:24:31 -08:00
parent 60cc564743
commit 2fc14ecd0e
18 changed files with 843 additions and 1130 deletions
@@ -0,0 +1,223 @@
import type { Variants } from 'motion/react';
import type { ReactNode } from 'react';
import { AnimatePresence, motion, useMotionValue } from 'motion/react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import styles from './grid-carousel.module.css';
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';
interface Card {
content: ReactNode;
id: string;
}
interface GridCarouselProps {
cards: Card[];
hasNextPage?: boolean;
loadNextPage?: () => void;
onNextPage: (page: number) => void;
onPrevPage: (page: number) => void;
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 }),
};
export function GridCarousel(props: GridCarouselProps) {
const { cards, hasNextPage, loadNextPage, onNextPage, onPrevPage, rowCount = 1, title } = props;
const cq = useContainerQuery({
lg: 900,
md: 600,
sm: 360,
});
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({
isLargerThanLg: cq.isLg,
isLargerThanMd: cq.isMd,
isLargerThanSm: cq.isSm,
isLargerThanXl: cq.isXl,
isLargerThanXxl: cq.is2xl,
isLargerThanXxxl: cq.is3xl,
});
const visibleCards = useMemo(() => {
return cards.slice(
currentPage.page * cardsToShow * rowCount,
(currentPage.page + 1) * cardsToShow * rowCount,
);
}, [cards, currentPage, cardsToShow, rowCount]);
const shouldLoadNextPage = visibleCards.length < cardsToShow * rowCount;
useEffect(() => {
if (shouldLoadNextPage) {
loadNextPage?.();
}
}, [loadNextPage, shouldLoadNextPage]);
const isPrevDisabled = currentPage.page === 0;
const hasMoreCards = (currentPage.page + 1) * cardsToShow * rowCount < cards.length;
const isNextDisabled = !hasMoreCards && (hasNextPage === false || hasNextPage === undefined);
const indicatorRef = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
const dragThreshold = 1;
const handleDragEnd = useCallback(() => {
const dragDistance = x.get();
if (Math.abs(dragDistance) > dragThreshold) {
if (dragDistance > 0 && !isPrevDisabled) {
// Dragged right, go to previous page
handlePrevPage();
} else if (dragDistance < 0 && !isNextDisabled) {
// Dragged left, go to next page
handleNextPage();
}
}
x.set(0);
}, [handleNextPage, handlePrevPage, isNextDisabled, isPrevDisabled, x]);
return (
<div className={styles.gridCarousel} ref={cq.ref}>
{cq.isCalculated && (
<>
<div className={styles.navigation}>
{typeof title === 'string' ? (
<TextTitle order={4}>{title}</TextTitle>
) : (
title
)}
<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>
<AnimatePresence custom={currentPage} initial={false} mode="wait">
<motion.div
animate="animate"
className={styles.grid}
custom={currentPage}
exit="exit"
initial="initial"
key={currentPage.page}
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>
<motion.div
className={styles.pageIndicator}
drag="x"
dragConstraints={{ left: -20, right: 20 }}
dragElastic={0.3}
dragSnapToOrigin={true}
onDragEnd={handleDragEnd}
ref={indicatorRef}
style={{ x }}
>
<motion.div className={styles.indicatorTrack} />
</motion.div>
</>
)}
</div>
);
}
function getCardsToShow(breakpoints: {
isLargerThanLg: boolean;
isLargerThanMd: boolean;
isLargerThanSm: boolean;
isLargerThanXl: boolean;
isLargerThanXxl: boolean;
isLargerThanXxxl: boolean;
}) {
if (breakpoints.isLargerThanXxxl) {
return 14;
}
if (breakpoints.isLargerThanXxl) {
return 10;
}
if (breakpoints.isLargerThanXl) {
return 8;
}
if (breakpoints.isLargerThanLg) {
return 6;
}
if (breakpoints.isLargerThanMd) {
return 4;
}
if (breakpoints.isLargerThanSm) {
return 3;
}
return 2;
}
@@ -0,0 +1,56 @@
.grid-carousel {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-md);
width: 100%;
margin: 0 auto;
container-name: grid-carousel;
container-type: inline-size;
}
.navigation {
display: flex;
align-items: center;
justify-content: space-between;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr));
gap: var(--theme-spacing-md);
height: calc(var(--row-count) * (100cqw / var(--cards-to-show, 2) + 3rem));
overflow: hidden;
}
.page-indicator {
display: flex;
align-items: center;
justify-content: center;
padding: var(--theme-spacing-sm) 0;
cursor: grab;
user-select: none;
}
.page-indicator:active {
cursor: grabbing;
}
.indicator-track {
width: 20px;
height: 4px;
touch-action: none;
cursor: grab;
border-radius: 2px;
@mixin light {
background-color: darken(var(--theme-colors-background), 10%);
}
@mixin dark {
background-color: lighten(var(--theme-colors-background), 15%);
}
}
.indicator-track:active {
cursor: grabbing;
}
@@ -1,330 +0,0 @@
import throttle from 'lodash/throttle';
import {
isValidElement,
memo,
ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { SwiperOptions, Virtual } from 'swiper';
import 'swiper/css';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Swiper as SwiperCore } from 'swiper/types';
import { PosterCard } from '/@/renderer/components/card/poster-card';
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { usePlayButtonBehavior } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Stack } from '/@/shared/components/stack/stack';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
RelatedArtist,
} from '/@/shared/types/domain-types';
import { CardRoute, CardRow } from '/@/shared/types/types';
const getSlidesPerView = (windowWidth: number) => {
if (windowWidth < 400) return 2;
if (windowWidth < 700) return 3;
if (windowWidth < 900) return 4;
if (windowWidth < 1100) return 5;
if (windowWidth < 1300) return 6;
if (windowWidth < 1500) return 7;
if (windowWidth < 1920) return 8;
return 10;
};
interface TitleProps {
handleNext?: () => void;
handlePrev?: () => void;
label?: ReactNode | string;
pagination: {
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
const Title = ({ handleNext, handlePrev, label, pagination }: TitleProps) => {
return (
<Group justify="space-between">
{isValidElement(label) ? (
label
) : (
<TextTitle order={3} weight={700}>
{label}
</TextTitle>
)}
<Group gap="sm">
<Button
disabled={!pagination.hasPreviousPage}
onClick={handlePrev}
size="compact-md"
variant="subtle"
>
<Icon icon="arrowLeftS" />
</Button>
<Button
disabled={!pagination.hasNextPage}
onClick={handleNext}
size="compact-md"
variant="subtle"
>
<Icon icon="arrowRightS" />
</Button>
</Group>
</Group>
);
};
export interface SwiperGridCarouselProps {
cardRows: CardRow<Album>[] | CardRow<AlbumArtist>[] | CardRow<Artist>[];
data: Album[] | AlbumArtist[] | Artist[] | RelatedArtist[] | undefined;
isLoading?: boolean;
itemType: LibraryItem;
route: CardRoute;
swiperProps?: SwiperOptions;
title?: {
children?: ReactNode;
hasPagination?: boolean;
icon?: ReactNode;
label: ReactNode | string;
};
uniqueId: string;
}
export const SwiperGridCarousel = ({
cardRows,
data,
isLoading,
itemType,
route,
swiperProps,
title,
uniqueId,
}: SwiperGridCarouselProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const swiperRef = useRef<any | SwiperCore>(null);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd();
const [slideCount, setSlideCount] = useState(4);
useEffect(() => {
swiperRef.current?.slideTo(0, 0);
}, [data]);
const [pagination, setPagination] = useState({
hasNextPage: (data?.length || 0) > Math.round(3),
hasPreviousPage: false,
});
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = useCallback(
(options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
serverId: string;
}) => {
const { id, isFavorite, itemType, serverId } = options;
if (isFavorite) {
deleteFavoriteMutation.mutate({
apiClientProps: { serverId },
query: {
id,
type: itemType,
},
});
} else {
createFavoriteMutation.mutate({
apiClientProps: { serverId },
query: {
id,
type: itemType,
},
});
}
},
[createFavoriteMutation, deleteFavoriteMutation],
);
const slides = useMemo(() => {
if (!data) return [];
return data.map((el) => (
<PosterCard
controls={{
cardRows,
handleFavorite,
handlePlayQueueAdd,
itemType,
playButtonBehavior,
route,
}}
data={el}
isLoading={isLoading}
key={`${uniqueId}-${el.id}`}
uniqueId={uniqueId}
/>
));
}, [
cardRows,
data,
handleFavorite,
handlePlayQueueAdd,
isLoading,
itemType,
playButtonBehavior,
route,
uniqueId,
]);
const handleNext = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount));
swiperRef?.current?.slideTo(activeIndex + slidesPerView);
}, [slideCount, swiperProps?.slidesPerView]);
const handlePrev = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount));
swiperRef?.current?.slideTo(activeIndex - slidesPerView);
}, [slideCount, swiperProps?.slidesPerView]);
const handleOnSlideChange = useCallback((e: SwiperCore) => {
const { isBeginning, isEnd, params, slides } = e;
if (isEnd || isBeginning) return;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: slideCount < slides.length,
hasPreviousPage: slideCount < slides.length,
});
}, []);
const handleOnZoomChange = useCallback((e: SwiperCore) => {
const { isBeginning, isEnd, params, slides } = e;
if (isEnd || isBeginning) return;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: slideCount < slides.length,
hasPreviousPage: slideCount < slides.length,
});
}, []);
const handleOnReachEnd = useCallback((e: SwiperCore) => {
const { params, slides } = e;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: false,
hasPreviousPage: slideCount < slides.length,
});
}, []);
const handleOnReachBeginning = useCallback((e: SwiperCore) => {
const { params, slides } = e;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: slideCount < slides.length,
hasPreviousPage: false,
});
}, []);
useLayoutEffect(() => {
const handleResize = () => {
// Use the container div ref and not swiper width, as this value is more accurate
const width = containerRef.current?.clientWidth;
const { activeIndex, params, slides } =
(swiperRef.current as SwiperCore | undefined) ?? {};
if (width) {
const slidesPerView = getSlidesPerView(width);
setSlideCount(slidesPerView);
}
if (activeIndex !== undefined && slides && params?.slidesPerView) {
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: activeIndex + slideCount < slides.length,
hasPreviousPage: activeIndex > 0,
});
}
};
handleResize();
const throttledResize = throttle(handleResize, 200);
window.addEventListener('resize', throttledResize);
return () => {
window.removeEventListener('resize', throttledResize);
};
}, []);
return (
<Stack className="grid-carousel" gap="md" ref={containerRef as any}>
{title ? (
<Title
{...title}
handleNext={handleNext}
handlePrev={handlePrev}
pagination={pagination}
/>
) : null}
<Swiper
modules={[Virtual]}
onBeforeInit={(swiper) => {
swiperRef.current = swiper;
}}
onReachBeginning={handleOnReachBeginning}
onReachEnd={handleOnReachEnd}
onSlideChange={handleOnSlideChange}
onZoomChange={handleOnZoomChange}
ref={swiperRef}
resizeObserver
slidesPerView={slideCount}
spaceBetween={20}
style={{ height: '100%', width: '100%' }}
{...swiperProps}
>
{slides.map((slideContent, index) => {
return (
<SwiperSlide
key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`}
virtualIndex={index}
>
{slideContent}
</SwiperSlide>
);
})}
</Swiper>
</Stack>
);
};
export const MemoizedSwiperGridCarousel = memo(
function Carousel(props: SwiperGridCarouselProps) {
return <SwiperGridCarousel {...props} />;
},
(oldProps, newProps) => {
const uniqueIdIsEqual = oldProps.uniqueId === newProps.uniqueId;
const dataIsEqual = oldProps.data === newProps.data;
return uniqueIdIsEqual && dataIsEqual;
},
);
@@ -460,12 +460,14 @@ const PosterItemCard = ({
},
itemType,
onDragStart: () => {
if (!data || !internalState) {
if (!data) {
return;
}
const draggedItems = getDraggedItems(data, internalState);
internalState.setDragging(draggedItems);
if (internalState) {
internalState.setDragging(draggedItems);
}
},
onDrop: () => {
if (internalState) {
@@ -26,9 +26,9 @@ const hasRequiredDragProperties = (
* Gets the items that should be dragged based on the current data and selection state.
* If the current item is already selected, drag all selected items.
* Otherwise, select and drag only the current item.
* If internalState is not provided, returns the single item wrapped in an array.
*
* @param data - The item data to drag (Album, AlbumArtist, Artist, Playlist, or Song)
* @param itemType - The type of library item
* @param internalState - The item list state actions (optional)
* @param updateSelection - Whether to update the selection state (default: true)
* @returns Array of items that should be dragged (with original values, asserting id, itemType, and _serverId)
@@ -38,7 +38,7 @@ export const getDraggedItems = (
internalState?: ItemListStateActions,
updateSelection: boolean = true,
): ItemListStateItemWithRequiredProperties[] => {
if (!data || !internalState) {
if (!data) {
return [];
}
@@ -46,14 +46,18 @@ export const getDraggedItems = (
return [];
}
const draggedItem = data as ItemListStateItemWithRequiredProperties;
if (!internalState) {
return [draggedItem];
}
const rowId = internalState.extractRowId(data);
if (!rowId) {
return [];
return [draggedItem];
}
const draggedItem = data as ItemListStateItemWithRequiredProperties;
const previouslySelected = internalState.getSelected();
const isDraggingSelectedItem = previouslySelected.some((selected) => {
if (hasRequiredDragProperties(selected)) {
@@ -102,13 +102,14 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
},
itemType: props.itemType,
onDragStart: () => {
if (!item || !isDataRow || !props.internalState) {
if (!item || !isDataRow) {
return;
}
const draggedItems = getDraggedItems(item as any, props.internalState);
props.internalState.setDragging(draggedItems);
if (props.internalState) {
props.internalState.setDragging(draggedItems);
}
},
onDrop: () => {
if (props.internalState) {