From 633c6416df8a6398dbcf7af2b004f28b801c8002 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 6 Nov 2022 00:08:22 -0700 Subject: [PATCH] Album list updates --- .../virtual-grid/grid-card/default-card.tsx | 451 ++++++++++++++++++ .../grid-card/grid-card-controls.tsx | 8 +- .../virtual-grid/grid-card/index.tsx | 7 +- .../virtual-grid/grid-card/poster-card.tsx | 12 +- .../virtual-grid/virtual-grid-wrapper.tsx | 5 +- .../virtual-grid/virtual-infinite-grid.tsx | 3 +- .../albums/components/advanced-filters.tsx | 8 +- .../albums/routes/album-list-route.tsx | 289 +++++++---- src/renderer/store/app.store.ts | 26 +- 9 files changed, 710 insertions(+), 99 deletions(-) create mode 100644 src/renderer/components/virtual-grid/grid-card/default-card.tsx diff --git a/src/renderer/components/virtual-grid/grid-card/default-card.tsx b/src/renderer/components/virtual-grid/grid-card/default-card.tsx new file mode 100644 index 000000000..ba5c7114c --- /dev/null +++ b/src/renderer/components/virtual-grid/grid-card/default-card.tsx @@ -0,0 +1,451 @@ +import styled from '@emotion/styled'; +import { Center, Skeleton } from '@mantine/core'; +import { RiAlbumFill } from 'react-icons/ri'; +import { generatePath, useNavigate } from 'react-router'; +import { Link } from 'react-router-dom'; +import { ListChildComponentProps } from 'react-window'; +import { Text } from '@/renderer/components/text'; +import { GridCardControls } from '@/renderer/components/virtual-grid/grid-card/grid-card-controls'; +import { fadeIn } from '@/renderer/styles'; +import { + PlayQueueAddOptions, + LibraryItem, + CardRow, + CardRoute, +} from '@/renderer/types'; + +const CardWrapper = styled.div<{ + itemGap: number; + itemHeight: number; + itemWidth: number; + link?: boolean; +}>` + flex: ${({ itemWidth }) => `0 0 ${itemWidth - 12}px`}; + width: ${({ itemWidth }) => `${itemWidth}px`}; + height: ${({ itemHeight, itemGap }) => `${itemHeight - 12 - itemGap}px`}; + margin: ${({ itemGap }) => `0 ${itemGap / 2}px`}; + padding: 12px 12px 0; + background: var(--card-default-bg); + border-radius: var(--card-default-radius); + cursor: ${({ link }) => link && 'pointer'}; + transition: border 0.2s ease-in-out, background 0.2s ease-in-out; + user-select: none; + pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682 + + &:hover { + background: var(--card-default-bg-hover); + } + + &:hover div { + opacity: 1; + } + + &:hover * { + &::before { + opacity: 0.5; + } + } + + &:focus-visible { + outline: 1px solid #fff; + } +`; + +const StyledCard = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + height: 100%; + padding: 0; + border-radius: var(--card-default-radius); +`; + +const ImageSection = styled.div<{ size?: number }>` + position: relative; + width: ${({ size }) => size && `${size - 24}px`}; + height: ${({ size }) => size && `${size - 24}px`}; + border-radius: var(--card-default-radius); + + &::before { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: 100%; + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 100%) 35%, + rgba(0, 0, 0, 0%) 100% + ); + opacity: 0; + transition: all 0.2s ease-in-out; + content: ''; + user-select: none; + } +`; + +interface ImageProps { + height: number; + isLoading?: boolean; +} + +const Image = styled.img` + width: ${({ height }) => `${height - 24}px`}; + height: ${({ height }) => `${height - 24}px`}; + object-fit: cover; + border: 0; + border-radius: var(--card-default-radius); + + ${fadeIn} + animation: fadein 0.3s ease-in-out; +`; + +const ControlsContainer = styled.div` + position: absolute; + bottom: 0; + z-index: 50; + width: 100%; + opacity: 0; + transition: all 0.2s ease-in-out; +`; + +const DetailSection = styled.div` + display: flex; + flex-direction: column; +`; + +const Row = styled.div<{ secondary?: boolean }>` + width: 100%; + max-width: 100%; + height: 25px; + padding: 0 0.2rem; + overflow: hidden; + color: ${({ secondary }) => + secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)'}; + white-space: nowrap; + text-overflow: ellipsis; +`; + +interface BaseGridCardProps { + columnIndex: number; + controls: { + cardControls: any[]; + cardRows: CardRow[]; + handlePlayQueueAdd: (options: PlayQueueAddOptions) => void; + itemType: LibraryItem; + route?: CardRoute; + }; + data: any; + listChildProps: Omit; + sizes: { + itemGap: number; + itemHeight: number; + itemWidth: number; + }; +} + +export const DefaultCard = ({ + listChildProps, + data, + columnIndex, + controls, + sizes, +}: BaseGridCardProps) => { + const navigate = useNavigate(); + const { isScrolling, index } = listChildProps; + const { itemGap, itemHeight, itemWidth } = sizes; + const { cardControls, handlePlayQueueAdd, itemType, cardRows, route } = + controls; + + if (data) { + if (route) { + return ( + + navigate( + generatePath( + route.route, + route.slugs?.reduce((acc, slug) => { + return { + ...acc, + [slug.slugProperty]: data[slug.idProperty], + }; + }, {}) + ) + ) + } + > + + + {data?.imageUrl ? ( + + ) : ( +
+ +
+ )} + + {!isScrolling && ( + + )} + +
+ + {cardRows.map((row: CardRow, index: number) => { + if (row.arrayProperty) { + if (row.route) { + return ( + 0}> + {data[row.property].map( + (item: any, itemIndex: number) => ( + <> + {itemIndex > 0 && ( + + , + + )}{' '} + 0} + to={generatePath( + row.route!.route, + row.route!.slugs?.reduce((acc, slug) => { + return { + ...acc, + [slug.slugProperty]: + data[slug.idProperty], + }; + }, {}) + )} + onClick={(e) => e.stopPropagation()} + > + {row.arrayProperty && item[row.arrayProperty]} + + + ) + )} + + ); + } + + return ( + + {data[row.property].map((item: any) => ( + 0}> + {row.arrayProperty && item[row.arrayProperty]} + + ))} + + ); + } + + return ( + + {row.route ? ( + { + return { + ...acc, + [slug.slugProperty]: data[slug.idProperty], + }; + }, {}) + )} + onClick={(e) => e.stopPropagation()} + > + {data && data[row.property]} + + ) : ( + 0}> + {data && data[row.property]} + + )} + + ); + })} + +
+
+ ); + } + return ( + + + + {data?.imageUrl ? ( + + ) : ( +
+ +
+ )} + + {!isScrolling && ( + + )} + +
+ + {cardRows.map((row: CardRow, index: number) => { + if (row.arrayProperty) { + if (row.route) { + return ( + 0}> + {data[row.property].map( + (item: any, itemIndex: number) => ( + <> + {itemIndex > 0 && ( + + , + + )}{' '} + 0} + to={generatePath( + row.route!.route, + row.route!.slugs?.reduce((acc, slug) => { + return { + ...acc, + [slug.slugProperty]: data[slug.idProperty], + }; + }, {}) + )} + onClick={(e) => e.stopPropagation()} + > + {row.arrayProperty && item[row.arrayProperty]} + + + ) + )} + + ); + } + + return ( + + {data[row.property].map((item: any) => ( + 0}> + {row.arrayProperty && item[row.arrayProperty]} + + ))} + + ); + } + + return ( + + {row.route ? ( + { + return { + ...acc, + [slug.slugProperty]: data[slug.idProperty], + }; + }, {}) + )} + onClick={(e) => e.stopPropagation()} + > + {data && data[row.property]} + + ) : ( + 0}> + {data && data[row.property]} + + )} + + ); + })} + +
+
+ ); + } + + return ( + + + + + + + {cardRows.map((row: CardRow, index: number) => ( + + + + ))} + + + + ); +}; diff --git a/src/renderer/components/virtual-grid/grid-card/grid-card-controls.tsx b/src/renderer/components/virtual-grid/grid-card/grid-card-controls.tsx index 76497e48b..c725eb51c 100644 --- a/src/renderer/components/virtual-grid/grid-card/grid-card-controls.tsx +++ b/src/renderer/components/virtual-grid/grid-card/grid-card-controls.tsx @@ -90,6 +90,7 @@ export const GridCardControls = ({ whileTap={{ scale: 1 }} onClick={(e) => { e.preventDefault(); + e.stopPropagation(); handlePlayQueueAdd({ byItemType: { id: itemData.id, @@ -116,7 +117,10 @@ export const GridCardControls = ({ @@ -125,6 +129,7 @@ export const GridCardControls = ({ { e.preventDefault(); + e.stopPropagation(); handlePlayQueueAdd({ byItemType: { id: itemData.id, @@ -139,6 +144,7 @@ export const GridCardControls = ({ { e.preventDefault(); + e.stopPropagation(); handlePlayQueueAdd({ byItemType: { id: itemData.id, diff --git a/src/renderer/components/virtual-grid/grid-card/index.tsx b/src/renderer/components/virtual-grid/grid-card/index.tsx index 27930ee90..79590268b 100644 --- a/src/renderer/components/virtual-grid/grid-card/index.tsx +++ b/src/renderer/components/virtual-grid/grid-card/index.tsx @@ -1,6 +1,7 @@ import { ListChildComponentProps } from 'react-window'; +import { DefaultCard } from '@/renderer/components/virtual-grid/grid-card/default-card'; import { PosterCard } from '@/renderer/components/virtual-grid/grid-card/poster-card'; -import { GridCardData } from '@/renderer/types'; +import { CardDisplayType, GridCardData } from '@/renderer/types'; export const GridCard = ({ data, @@ -27,9 +28,11 @@ export const GridCard = ({ const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1); const cards = []; + const View = display === CardDisplayType.CARD ? DefaultCard : PosterCard; + for (let i = startIndex; i <= stopIndex; i += 1) { cards.push( - ` flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`}; width: ${({ itemWidth }) => `${itemWidth}px`}; - height: ${({ itemHeight }) => `${itemHeight}px`}; + height: ${({ itemHeight, itemGap }) => `${itemHeight - itemGap}px`}; margin: ${({ itemGap }) => `0 ${itemGap / 2}px`}; transition: border 0.2s ease-in-out; user-select: none; @@ -49,13 +49,13 @@ const StyledCard = styled.div` width: 100%; height: 100%; padding: 0; - border-radius: 5px; + border-radius: var(--card-poster-radius); `; const ImageSection = styled.div` position: relative; width: 100%; - border-radius: 5px; + border-radius: var(--card-poster-radius); &::before { position: absolute; @@ -86,7 +86,7 @@ const Image = styled.img` height: ${({ height }) => `${height}px`}; object-fit: cover; border: 0; - border-radius: 2px; + border-radius: var(--card-poster-radius); ${fadeIn} animation: fadein 0.3s ease-in-out; @@ -177,7 +177,7 @@ export const PosterCard = ({
@@ -204,7 +204,7 @@ export const PosterCard = ({
diff --git a/src/renderer/components/virtual-grid/virtual-grid-wrapper.tsx b/src/renderer/components/virtual-grid/virtual-grid-wrapper.tsx index 546369dd5..6727f739a 100644 --- a/src/renderer/components/virtual-grid/virtual-grid-wrapper.tsx +++ b/src/renderer/components/virtual-grid/virtual-grid-wrapper.tsx @@ -16,6 +16,7 @@ export const VirtualGridWrapper = ({ itemGap, itemType, itemWidth, + display, itemHeight, itemCount, columnCount, @@ -42,6 +43,7 @@ export const VirtualGridWrapper = ({ () => ({ cardRows, columnCount, + display, handlePlayQueueAdd, itemCount, itemData, @@ -58,6 +60,7 @@ export const VirtualGridWrapper = ({ handlePlayQueueAdd, itemCount, itemData, + display, itemGap, itemHeight, route, @@ -72,7 +75,7 @@ export const VirtualGridWrapper = ({ useIsScrolling itemCount={rowCount} itemData={memo} - itemSize={itemHeight + itemGap} + itemSize={itemHeight} overscanCount={5} > {GridCard} diff --git a/src/renderer/components/virtual-grid/virtual-infinite-grid.tsx b/src/renderer/components/virtual-grid/virtual-infinite-grid.tsx index a990e6d1a..4844fed6c 100644 --- a/src/renderer/components/virtual-grid/virtual-infinite-grid.tsx +++ b/src/renderer/components/virtual-grid/virtual-infinite-grid.tsx @@ -52,7 +52,8 @@ export const VirtualInfiniteGrid = ({ return { columnCount: itemsPerRow, - itemHeight: itemSize! + cardRows.length * 25, + itemHeight: itemSize! + cardRows.length * 25 + itemGap, + itemWidth: itemSize! + itemGap, rowCount: Math.ceil(itemCount / itemsPerRow), }; }, [cardRows.length, itemCount, itemGap, itemSize, width]); diff --git a/src/renderer/features/albums/components/advanced-filters.tsx b/src/renderer/features/albums/components/advanced-filters.tsx index 8cbc5296b..a8de03936 100644 --- a/src/renderer/features/albums/components/advanced-filters.tsx +++ b/src/renderer/features/albums/components/advanced-filters.tsx @@ -79,7 +79,7 @@ const FILTER_GROUP_OPTIONS_DATA = [ const FILTER_OPTIONS_DATA = [ { default: '~', - label: 'Artist Title', + label: 'Artist Name', value: 'artists.name', }, { @@ -94,7 +94,7 @@ const FILTER_OPTIONS_DATA = [ }, { default: '~', - label: 'Album Artist Title', + label: 'Album Artist Name', value: 'albumArtists.name', }, { @@ -109,7 +109,7 @@ const FILTER_OPTIONS_DATA = [ }, { default: '~', - label: 'Album Title', + label: 'Album Name', value: 'albums.name', }, { @@ -145,7 +145,7 @@ const FILTER_OPTIONS_DATA = [ }, { default: '~', - label: 'Track Title', + label: 'Track Name', value: 'songs.name', }, { diff --git a/src/renderer/features/albums/routes/album-list-route.tsx b/src/renderer/features/albums/routes/album-list-route.tsx index 5ad173c38..3b206648f 100644 --- a/src/renderer/features/albums/routes/album-list-route.tsx +++ b/src/renderer/features/albums/routes/album-list-route.tsx @@ -1,10 +1,16 @@ /* eslint-disable no-plusplus */ import { useState, useCallback, useMemo } from 'react'; -import { Group, Checkbox } from '@mantine/core'; +import { Group, Checkbox, Box, Slider } from '@mantine/core'; import { useDebouncedValue, useSetState, useToggle } from '@mantine/hooks'; import { useQueryClient } from '@tanstack/react-query'; +import { AnimatePresence, motion } from 'framer-motion'; +import throttle from 'lodash/throttle'; import { nanoid } from 'nanoid'; -import { RiArrowDownSLine, RiArrowLeftLine } from 'react-icons/ri'; +import { + RiArrowDownSLine, + RiDeleteBack2Fill, + RiSettings2Fill, +} from 'react-icons/ri'; import AutoSizer from 'react-virtualized-auto-sizer'; import { api } from '@/renderer/api'; import { AlbumSort } from '@/renderer/api/albums.api'; @@ -25,18 +31,14 @@ import { AdvancedFilterGroup, AdvancedFilters, FilterGroupType, - formatAdvancedFiltersQuery, + encodeAdvancedFiltersQuery, } from '@/renderer/features/albums/components/advanced-filters'; import { useAlbumList } from '@/renderer/features/albums/queries/use-album-list'; import { useServerList } from '@/renderer/features/servers'; import { AnimatedPage, useServerCredential } from '@/renderer/features/shared'; import { AppRoute } from '@/renderer/router/routes'; -import { useAuthStore } from '@/renderer/store'; -import { LibraryItem } from '@/renderer/types'; -import { - ViewType, - ViewTypeButton, -} from '../../library/components/ViewTypeButton'; +import { useAppStore, useAuthStore } from '@/renderer/store'; +import { LibraryItem, CardDisplayType } from '@/renderer/types'; const FILTERS = [ { name: 'Title', value: AlbumSort.NAME }, @@ -57,12 +59,27 @@ const ORDER = [ { name: 'Descending', value: SortOrder.DESC }, ]; +const DEFAULT_ADVANCED_FILTERS = { + group: [], + rules: [ + { + field: '', + operator: '', + uniqueId: nanoid(), + value: '', + }, + ], + type: FilterGroupType.AND, + uniqueId: nanoid(), +}; + export const AlbumListRoute = () => { const queryClient = useQueryClient(); const { serverToken, isImageTokenRequired } = useServerCredential(); + const page = useAppStore((state) => state.albums); + const setPage = useAppStore((state) => state.setPage); const serverId = useAuthStore((state) => state.currentServer?.id) || ''; const { data: servers } = useServerList({ enabled: true }); - const [viewType, setViewType] = useState(ViewType.Grid); const [filters, setFilters] = useSetState({ orderBy: SortOrder.ASC, serverFolderId: [] as string[], @@ -70,19 +87,23 @@ export const AlbumListRoute = () => { }); const [isAdvFilter, toggleAdvFilter] = useToggle(); - const [rawAdvFilters, setRawAdvFilters] = useState({ - group: [], - rules: [{ field: null, operator: null, uniqueId: nanoid(), value: null }], - type: FilterGroupType.AND, - uniqueId: nanoid(), - }); + const [rawAdvFilters, setRawAdvFilters] = useState( + DEFAULT_ADVANCED_FILTERS + ); - const [debouncedAdvFilters] = useDebouncedValue(rawAdvFilters, 300); + const [debouncedAdvFilters] = useDebouncedValue(rawAdvFilters, 500); const advancedFilters = useMemo(() => { - const value = formatAdvancedFiltersQuery(debouncedAdvFilters); - return encodeURI(JSON.stringify(value)); - }, [debouncedAdvFilters]); + if (!isAdvFilter) { + return encodeAdvancedFiltersQuery(DEFAULT_ADVANCED_FILTERS); + } + + return encodeAdvancedFiltersQuery(debouncedAdvFilters); + }, [debouncedAdvFilters, isAdvFilter]); + + const handleResetAdvancedFilters = () => { + setRawAdvFilters(DEFAULT_ADVANCED_FILTERS); + }; const serverFolders = useMemo(() => { const server = servers?.data.find((server) => server.id === serverId); @@ -99,7 +120,7 @@ export const AlbumListRoute = () => { }); const fetch = useCallback( - async ({ skip, take }) => { + async ({ skip, take }: { skip: number; take: number }) => { const albums = await queryClient.fetchQuery( queryKeys.albums.list(serverId, { skip, @@ -138,6 +159,15 @@ export const AlbumListRoute = () => { ] ); + const setSize = throttle( + (e: number) => + setPage('albums', { + ...page, + list: { ...page.list, size: e }, + }), + 200 + ); + return ( @@ -159,16 +189,7 @@ export const AlbumListRoute = () => { {FILTERS.map((filter) => ( - ) : undefined - } + isActive={filter.value === filters.sortBy} onClick={() => setFilters({ sortBy: filter.value })} > {filter.name} @@ -176,8 +197,7 @@ export const AlbumListRoute = () => { ))} : undefined} + isActive={isAdvFilter} onClick={() => toggleAdvFilter()} > Advanced Filters @@ -197,16 +217,7 @@ export const AlbumListRoute = () => { {ORDER.map((sort) => ( - ) : undefined - } + isActive={sort.value === filters.orderBy} onClick={() => setFilters({ orderBy: sort.value })} > {sort.name} @@ -240,69 +251,181 @@ export const AlbumListRoute = () => { - + + + + + + + + + + + setPage('albums', { + ...page, + list: { + ...page.list, + display: CardDisplayType.CARD, + type: 'grid', + }, + }) + } + > + Card + + + setPage('albums', { + ...page, + list: { + ...page.list, + display: CardDisplayType.POSTER, + type: 'grid', + }, + }) + } + > + Poster + + + setPage('albums', { + ...page, + list: { + ...page.list, + type: 'list', + }, + }) + } + > + List + + + - {isAdvFilter && ( - <> - - + {isAdvFilter && ( + + - - - Advanced Filters - + + + + Advanced Filters + + + + - - - - - - - )} + + + + + + + )} + {({ height, width }) => ( )} diff --git a/src/renderer/store/app.store.ts b/src/renderer/store/app.store.ts index 3c75602fc..e6574a716 100644 --- a/src/renderer/store/app.store.ts +++ b/src/renderer/store/app.store.ts @@ -1,7 +1,7 @@ import create from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; -import { Platform } from '@/renderer/types'; +import { CardDisplayType, Platform } from '@/renderer/types'; type SidebarProps = { expanded: string[]; @@ -11,7 +11,18 @@ type SidebarProps = { rightWidth: string; }; +type LibraryPageProps = { + list: ListProps; +}; + +type ListProps = { + display: CardDisplayType; + size: number; + type: 'list' | 'grid'; +}; + export interface AppState { + albums: LibraryPageProps; platform: Platform; sidebar: { expanded: string[]; @@ -24,6 +35,7 @@ export interface AppState { export interface AppSlice extends AppState { setAppStore: (data: Partial) => void; + setPage: (page: 'albums', options: Partial) => void; setSidebar: (options: Partial) => void; } @@ -31,10 +43,22 @@ export const useAppStore = create()( persist( devtools( immer((set, get) => ({ + albums: { + list: { + display: CardDisplayType.CARD, + size: 50, + type: 'list', + }, + }, platform: Platform.WINDOWS, setAppStore: (data) => { set({ ...get(), ...data }); }, + setPage: (page: 'albums', data: any) => { + set((state) => { + state[page] = { ...state[page], ...data }; + }); + }, setSidebar: (options) => { set((state) => { state.sidebar = { ...state.sidebar, ...options };