From 241e265e021df4b869c382e461ce377c7b6b3fb6 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 10 Oct 2025 11:31:21 -0700 Subject: [PATCH] handle favorite/rating column mutations --- .../helpers/item-list-infinite-loader.ts | 114 +++++++++++++-- .../helpers/item-list-paginated-loader.ts | 131 ++++++++++++++++-- .../columns/album-artists-column.tsx | 17 +-- .../columns/artists-column.tsx | 17 +-- .../columns/favorite-column.tsx | 25 ++++ .../columns/genre-column.module.css | 3 +- .../item-table-list/columns/genre-column.tsx | 17 +-- .../item-table-list/columns/rating-column.tsx | 24 ++++ .../columns/row-index-column.tsx | 2 +- .../mutations/create-favorite-mutation.ts | 17 +++ .../mutations/delete-favorite-mutation.ts | 17 +++ .../shared/mutations/set-rating-mutation.ts | 7 + 12 files changed, 334 insertions(+), 57 deletions(-) diff --git a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts index 392761700..9f40fe160 100644 --- a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts +++ b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts @@ -1,17 +1,14 @@ import { useQueryClient, useSuspenseQuery, UseSuspenseQueryOptions } from '@tanstack/react-query'; import throttle from 'lodash/throttle'; -import { useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { queryKeys } from '/@/renderer/api/query-keys'; +import { eventEmitter } from '/@/renderer/events/event-emitter'; +import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events'; import { getServerById } from '/@/renderer/store'; -export interface InfiniteListProps { - itemsPerPage?: number; - query: Omit; - serverId: string; -} - interface UseItemListInfiniteLoaderProps { + eventKey: string; itemsPerPage: number; listCountQuery: UseSuspenseQueryOptions; listQueryFn: (args: { apiClientProps: any; query: any }) => Promise<{ items: unknown[] }>; @@ -24,6 +21,7 @@ function getInitialData(itemCount: number) { } export const useItemListInfiniteLoader = ({ + eventKey, itemsPerPage = 100, listCountQuery, listQueryFn, @@ -32,6 +30,8 @@ export const useItemListInfiniteLoader = ({ }: UseItemListInfiniteLoaderProps) => { const queryClient = useQueryClient(); + const currentPageRef = useRef(0); + const scrollStateRef = useRef({ direction: 'unknown', lastRange: null, @@ -56,6 +56,8 @@ export const useItemListInfiniteLoader = ({ return; } + currentPageRef.current = pageNumber; + const queryParams = { limit: fetchRange.limit, startIndex: fetchRange.startIndex, @@ -84,7 +86,103 @@ export const useItemListInfiniteLoader = ({ }, 500); }, [itemsPerPage, queryClient, serverId, listQueryFn, query]); - return { data, onRangeChanged }; + const refresh = useCallback( + async (force?: boolean) => { + await queryClient.invalidateQueries(); + pagesLoaded.current = {}; + + if (force) { + setData(getInitialData(totalItemCount)); + } + + await onRangeChanged({ + endIndex: currentPageRef.current * itemsPerPage, + startIndex: currentPageRef.current * itemsPerPage, + }); + }, + [itemsPerPage, onRangeChanged, queryClient, totalItemCount], + ); + + const updateItems = useCallback((indexes: number[], value: object) => { + setData((prev: any[]) => { + return prev.map((item: any, index) => { + if (!item) { + return item; + } + + if (!indexes.includes(index)) { + return item; + } + + return { + ...item, + ...value, + }; + }); + }); + }, []); + + useEffect(() => { + const handleRefresh = (payload: { key: string }) => { + if (!eventKey || eventKey !== payload.key) { + return; + } + + return refresh(true); + }; + + eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh); + + return () => { + eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh); + }; + }, [eventKey, refresh]); + + useEffect(() => { + const handleFavorite = (payload: UserFavoriteEventPayload) => { + const idToIndexMap = data + .filter(Boolean) + .reduce((acc: Record, item: any, index: number) => { + acc[item.id] = index; + return acc; + }, {}); + + const dataIndexes = payload.id.map((id: string) => idToIndexMap[id]); + + if (dataIndexes.length === 0) { + return; + } + + return updateItems(dataIndexes, { userFavorite: payload.favorite }); + }; + + const handleRating = (payload: UserRatingEventPayload) => { + const idToIndexMap = data + .filter(Boolean) + .reduce((acc: Record, item: any, index: number) => { + acc[item.id] = index; + return acc; + }, {}); + + const dataIndexes = payload.id.map((id: string) => idToIndexMap[id]); + + if (dataIndexes.length === 0) { + return; + } + + return updateItems(dataIndexes, { userRating: payload.rating }); + }; + + eventEmitter.on('USER_FAVORITE', handleFavorite); + eventEmitter.on('USER_RATING', handleRating); + + return () => { + eventEmitter.off('USER_FAVORITE', handleFavorite); + eventEmitter.off('USER_RATING', handleRating); + }; + }, [data, eventKey, updateItems]); + + return { data, onRangeChanged, refresh, updateItems }; }; export const parseListCountQuery = (query: any) => { diff --git a/src/renderer/components/item-list/helpers/item-list-paginated-loader.ts b/src/renderer/components/item-list/helpers/item-list-paginated-loader.ts index ace8c55b7..b3185c3e8 100644 --- a/src/renderer/components/item-list/helpers/item-list-paginated-loader.ts +++ b/src/renderer/components/item-list/helpers/item-list-paginated-loader.ts @@ -1,17 +1,19 @@ -import { useQuery, useSuspenseQuery, UseSuspenseQueryOptions } from '@tanstack/react-query'; +import { + useQuery, + useQueryClient, + useSuspenseQuery, + UseSuspenseQueryOptions, +} from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo } from 'react'; import { queryKeys } from '/@/renderer/api/query-keys'; +import { eventEmitter } from '/@/renderer/events/event-emitter'; +import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events'; import { getServerById } from '/@/renderer/store'; -export interface PaginatedListProps { - initialPage?: number; - itemsPerPage?: number; - query: Omit; - serverId: string; -} - interface UseItemListPaginatedLoaderProps { currentPage: number; + eventKey?: string; itemsPerPage: number; listCountQuery: UseSuspenseQueryOptions; listQueryFn: (args: { apiClientProps: any; query: any }) => Promise<{ items: unknown[] }>; @@ -25,12 +27,14 @@ function getInitialData(itemCount: number) { export const useItemListPaginatedLoader = ({ currentPage, + eventKey, itemsPerPage = 100, listCountQuery, listQueryFn, query = {}, serverId, }: UseItemListPaginatedLoaderProps) => { + const queryClient = useQueryClient(); const { data: totalItemCount } = useSuspenseQuery(listCountQuery); const pageCount = Math.ceil(totalItemCount / itemsPerPage); @@ -38,13 +42,16 @@ export const useItemListPaginatedLoader = ({ const fetchRange = getFetchRange(currentPage, itemsPerPage); const startIndex = fetchRange.startIndex; - const queryParams = { - limit: itemsPerPage, - startIndex: startIndex, - ...query, - }; + const queryParams = useMemo( + () => ({ + limit: itemsPerPage, + startIndex: startIndex, + ...query, + }), + [itemsPerPage, startIndex, query], + ); - const { data } = useQuery({ + const { data, refetch: queryRefetch } = useQuery({ gcTime: 1000 * 15, placeholderData: getInitialData(itemsPerPage), queryFn: async ({ signal }) => { @@ -59,6 +66,102 @@ export const useItemListPaginatedLoader = ({ staleTime: 1000 * 15, }); + const refresh = useCallback(() => { + return queryRefetch(); + }, [queryRefetch]); + + const updateItems = useCallback( + (indexes: number[], value: object) => { + return queryClient.setQueryData( + queryKeys.albums.list(serverId, queryParams), + (prev: undefined | unknown[]) => { + if (!prev) { + return prev; + } + + return prev.map((item: any, index) => { + if (!item) { + return item; + } + + if (!indexes.includes(index)) { + return item; + } + + return { + ...item, + ...value, + }; + }); + }, + ); + }, + [queryClient, queryParams, serverId], + ); + + useEffect(() => { + const handleRefresh = (payload: { key: string }) => { + if (!eventKey || eventKey !== payload.key) { + return; + } + + return refresh(); + }; + + const handleFavorite = (payload: UserFavoriteEventPayload) => { + if (!data) { + return; + } + + const idToIndexMap = data + .filter(Boolean) + .reduce((acc: Record, item: any, index: number) => { + acc[item.id] = index; + return acc; + }, {}); + + const dataIndexes = payload.id.map((id: string) => idToIndexMap[id]); + + if (dataIndexes.length === 0) { + return; + } + + return updateItems(dataIndexes, { userFavorite: payload.favorite }); + }; + + const handleRating = (payload: UserRatingEventPayload) => { + if (!data) { + return; + } + + const idToIndexMap = data.reduce( + (acc: Record, item: any, index: number) => { + acc[item.id] = index; + return acc; + }, + {}, + ); + + const dataIndexes = payload.id.map((id: string) => idToIndexMap[id]); + + if (dataIndexes.length === 0) { + return; + } + + return updateItems(dataIndexes, { userRating: payload.rating }); + }; + + eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh); + eventEmitter.on('USER_FAVORITE', handleFavorite); + eventEmitter.on('USER_RATING', handleRating); + + return () => { + eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh); + eventEmitter.off('USER_FAVORITE', handleFavorite); + eventEmitter.off('USER_RATING', handleRating); + }; + }, [data, eventKey, refresh, updateItems]); + return { data, pageCount, totalItemCount }; }; diff --git a/src/renderer/components/item-list/item-table-list/columns/album-artists-column.tsx b/src/renderer/components/item-list/item-table-list/columns/album-artists-column.tsx index 4466de82d..eb1df8e0f 100644 --- a/src/renderer/components/item-list/item-table-list/columns/album-artists-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/album-artists-column.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { memo, useMemo } from 'react'; +import { Fragment, memo, useMemo } from 'react'; import { generatePath, Link } from 'react-router-dom'; import styles from './album-artists-column.module.css'; @@ -39,17 +39,12 @@ const AlbumArtistsColumn = (props: ItemTableListInnerColumn) => { })} > {albumArtists.map((albumArtist, index) => ( - - {albumArtist.name} + + + {albumArtist.name} + {index < albumArtists.length - 1 && ', '} - + ))} diff --git a/src/renderer/components/item-list/item-table-list/columns/artists-column.tsx b/src/renderer/components/item-list/item-table-list/columns/artists-column.tsx index 66648c662..06473bf98 100644 --- a/src/renderer/components/item-list/item-table-list/columns/artists-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/artists-column.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { memo, useMemo } from 'react'; +import { Fragment, memo, useMemo } from 'react'; import { generatePath, Link } from 'react-router-dom'; import styles from './album-artists-column.module.css'; @@ -39,17 +39,12 @@ const ArtistsColumn = (props: ItemTableListInnerColumn) => { })} > {artists.map((artist, index) => ( - - {artist.name} + + + {artist.name} + {index < artists.length - 1 && ', '} - + ))} diff --git a/src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx b/src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx index e7ead4fcc..af4ddac45 100644 --- a/src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx @@ -2,13 +2,19 @@ import { ItemTableListInnerColumn, TableColumnContainer, } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; +import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { LibraryItem } from '/@/shared/types/domain-types'; export const FavoriteColumn = (props: ItemTableListInnerColumn) => { const row: boolean | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ props.columns[props.columnIndex].id ]; + const createFavorite = useCreateFavorite({}); + const deleteFavorite = useDeleteFavorite({}); + if (typeof row === 'boolean') { return ( @@ -20,6 +26,25 @@ export const FavoriteColumn = (props: ItemTableListInnerColumn) => { fill: row ? 'primary' : undefined, size: 'md', }} + onClick={() => { + if (row) { + deleteFavorite.mutate({ + query: { + id: [(props.data as any)?.[props.rowIndex]?.id as string], + type: (props.data as any)?.[props.rowIndex] + ?.itemType as LibraryItem, + }, + }); + } else { + createFavorite.mutate({ + query: { + id: [(props.data as any)?.[props.rowIndex]?.id as string], + type: (props.data as any)?.[props.rowIndex] + ?.itemType as LibraryItem, + }, + }); + } + }} size="xs" variant="subtle" /> diff --git a/src/renderer/components/item-list/item-table-list/columns/genre-column.module.css b/src/renderer/components/item-list/item-table-list/columns/genre-column.module.css index 4322fadff..9ad1642d7 100644 --- a/src/renderer/components/item-list/item-table-list/columns/genre-column.module.css +++ b/src/renderer/components/item-list/item-table-list/columns/genre-column.module.css @@ -2,8 +2,9 @@ display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; - -webkit-box-orient: vertical; color: var(--theme-colors-foreground-muted); + user-select: none; + -webkit-box-orient: vertical; } .genres-container.compact { diff --git a/src/renderer/components/item-list/item-table-list/columns/genre-column.tsx b/src/renderer/components/item-list/item-table-list/columns/genre-column.tsx index 6f6b8afd6..bb96ca4f3 100644 --- a/src/renderer/components/item-list/item-table-list/columns/genre-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/genre-column.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { memo, useMemo } from 'react'; +import { Fragment, memo, useMemo } from 'react'; import { generatePath, Link } from 'react-router-dom'; import styles from './genre-column.module.css'; @@ -39,17 +39,12 @@ const GenreColumn = (props: ItemTableListInnerColumn) => { })} > {genres.map((genre, index) => ( - - {genre.name} + + + {genre.name} + {index < genres.length - 1 && ', '} - + ))} diff --git a/src/renderer/components/item-list/item-table-list/columns/rating-column.tsx b/src/renderer/components/item-list/item-table-list/columns/rating-column.tsx index 49c83ea08..3487072a7 100644 --- a/src/renderer/components/item-list/item-table-list/columns/rating-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/rating-column.tsx @@ -2,6 +2,7 @@ import { ItemTableListInnerColumn, TableColumnContainer, } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation'; import { Rating } from '/@/shared/components/rating/rating'; export const RatingColumn = (props: ItemTableListInnerColumn) => { @@ -9,11 +10,34 @@ export const RatingColumn = (props: ItemTableListInnerColumn) => { props.columns[props.columnIndex].id ]; + const setRatingMutation = useSetRating({}); + + const handleChangeRating = (rating: number) => { + const previousRating = row || 0; + + let newRating = rating; + + if (previousRating === rating) { + newRating = 0; + } + + const item = props.data[props.rowIndex] as any; + + setRatingMutation.mutate({ + query: { + item: [item], + rating: newRating, + }, + serverId: item.serverId as string, + }); + }; + if (typeof row === 'number' || row === null) { return ( diff --git a/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx b/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx index d4e634b8f..2ac341678 100644 --- a/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx @@ -30,7 +30,7 @@ export const RowIndexColumn = (props: ItemTableListInnerColumn) => { size="xs" variant="subtle" /> - + {props.rowIndex} diff --git a/src/renderer/features/shared/mutations/create-favorite-mutation.ts b/src/renderer/features/shared/mutations/create-favorite-mutation.ts index 1be383204..e0823f44e 100644 --- a/src/renderer/features/shared/mutations/create-favorite-mutation.ts +++ b/src/renderer/features/shared/mutations/create-favorite-mutation.ts @@ -4,6 +4,7 @@ import isElectron from 'is-electron'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; +import { eventEmitter } from '/@/renderer/events/event-emitter'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { useSetAlbumListItemDataById } from '/@/renderer/store'; import { useFavoriteEvent } from '/@/renderer/store/event.store'; @@ -31,6 +32,22 @@ export const useCreateFavorite = (args: MutationHookArgs) => { apiClientProps: { serverId: args.apiClientProps.serverId }, }); }, + onError: (_error, variables) => { + eventEmitter.emit('USER_FAVORITE', { + favorite: false, + id: variables.query.id, + itemType: variables.query.type, + }); + }, + onMutate: (variables) => { + eventEmitter.emit('USER_FAVORITE', { + favorite: true, + id: variables.query.id, + itemType: variables.query.type, + }); + + return null; + }, onSuccess: (_data, variables) => { const { apiClientProps } = variables; const serverId = apiClientProps.serverId; diff --git a/src/renderer/features/shared/mutations/delete-favorite-mutation.ts b/src/renderer/features/shared/mutations/delete-favorite-mutation.ts index 34c630237..9f1280329 100644 --- a/src/renderer/features/shared/mutations/delete-favorite-mutation.ts +++ b/src/renderer/features/shared/mutations/delete-favorite-mutation.ts @@ -4,6 +4,7 @@ import isElectron from 'is-electron'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; +import { eventEmitter } from '/@/renderer/events/event-emitter'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { useSetAlbumListItemDataById } from '/@/renderer/store'; import { useFavoriteEvent } from '/@/renderer/store/event.store'; @@ -31,6 +32,22 @@ export const useDeleteFavorite = (args: MutationHookArgs) => { apiClientProps: { serverId: args.apiClientProps.serverId }, }); }, + onError: (_error, variables) => { + eventEmitter.emit('USER_FAVORITE', { + favorite: true, + id: variables.query.id, + itemType: variables.query.type, + }); + }, + onMutate: (variables) => { + eventEmitter.emit('USER_FAVORITE', { + favorite: false, + id: variables.query.id, + itemType: variables.query.type, + }); + + return null; + }, onSuccess: (_data, variables) => { const { apiClientProps } = variables; const serverId = apiClientProps.serverId; diff --git a/src/renderer/features/shared/mutations/set-rating-mutation.ts b/src/renderer/features/shared/mutations/set-rating-mutation.ts index eeb004378..3a05f4871 100644 --- a/src/renderer/features/shared/mutations/set-rating-mutation.ts +++ b/src/renderer/features/shared/mutations/set-rating-mutation.ts @@ -4,6 +4,7 @@ import isElectron from 'is-electron'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; +import { eventEmitter } from '/@/renderer/events/event-emitter'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { useSetAlbumListItemDataById } from '/@/renderer/store'; import { useRatingEvent } from '/@/renderer/store/event.store'; @@ -53,6 +54,12 @@ export const useSetRating = (args: MutationHookArgs) => { } }, onMutate: (variables) => { + eventEmitter.emit('USER_RATING', { + id: variables.query.item.map((item) => item.id), + itemType: variables.query.item[0].itemType, + rating: variables.query.rating, + }); + const songIds: string[] = []; for (const item of variables.query.item) { switch (item.itemType) {