add optimistic update for favorite/ratings mutations

This commit is contained in:
jeffvli
2025-11-23 03:40:53 -08:00
parent 0bc3ea51ec
commit 84419820b8
19 changed files with 975 additions and 324 deletions
@@ -7,6 +7,9 @@ import styles from './item-card-controls.module.css';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { useIsPlayerFetching } from '/@/renderer/features/player/context/player-context';
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { animationVariants } from '/@/shared/components/animations/animation-variants';
import { AppIcon, Icon, IconProps } from '/@/shared/components/icon/icon';
import { Rating } from '/@/shared/components/rating/rating';
@@ -127,16 +130,6 @@ const createRatingChangeHandler =
});
};
const ratingClickHandler = (e: MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
};
const ratingMouseDownHandler = (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
};
const moreDoubleClickHandler = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
@@ -225,14 +218,6 @@ export const ItemCardControls = ({
const isFavorite = (item as { userFavorite?: boolean })?.userFavorite ?? false;
const favoriteIconProps = useMemo<Partial<IconProps>>(
() => ({
color: isFavorite ? ('primary' as const) : ('default' as const),
fill: isFavorite ? ('primary' as const) : undefined,
}),
[isFavorite],
);
return (
<motion.div className={clsx(styles.container)} {...containerProps[type]}>
{controls?.onPlay && (
@@ -251,20 +236,12 @@ export const ItemCardControls = ({
</>
)}
{controls?.onFavorite && (
<SecondaryButton
className={styles.favorite}
icon="favorite"
iconProps={favoriteIconProps}
onClick={favoriteHandler}
/>
<FavoriteButton isFavorite={isFavorite} onClick={favoriteHandler} />
)}
{controls?.onRating && (
<Rating
className={styles.rating}
<RatingButton
onChange={ratingChangeHandler}
onClick={ratingClickHandler}
onMouseDown={ratingMouseDownHandler}
size="xs"
rating={(item as { userRating: number }).userRating}
/>
)}
{controls?.onMore && (
@@ -286,6 +263,67 @@ export const ItemCardControls = ({
);
};
const FavoriteButton = memo(
({
isFavorite,
onClick,
}: {
isFavorite: boolean;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
}) => {
const isMutatingCreate = useIsMutatingCreateFavorite();
const isMutatingDelete = useIsMutatingDeleteFavorite();
const isMutating = isMutatingCreate || isMutatingDelete;
const favoriteIconProps = useMemo<Partial<IconProps>>(
() => ({
color: isFavorite ? ('primary' as const) : ('default' as const),
fill: isFavorite ? ('primary' as const) : undefined,
}),
[isFavorite],
);
return (
<SecondaryButton
className={styles.favorite}
disabled={isMutating}
icon="favorite"
iconProps={favoriteIconProps}
onClick={onClick}
/>
);
},
(prev, next) => prev.isFavorite === next.isFavorite,
);
const RatingButton = memo(
({ onChange, rating }: { onChange: (rating: number) => void; rating: number }) => {
const ratingClickHandler = (e: MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
};
const ratingMouseDownHandler = (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
};
const isMutatingRating = useIsMutatingRating();
return (
<Rating
className={styles.rating}
onChange={onChange}
onClick={ratingClickHandler}
onMouseDown={ratingMouseDownHandler}
readOnly={isMutatingRating}
size="sm"
value={rating}
/>
);
},
(prev, next) => prev.rating === next.rating,
);
const PlayButton = memo(
({
disabled,
@@ -360,6 +398,7 @@ const SecondaryPlayButton = memo(
interface SecondaryButtonProps {
className?: string;
disabled?: boolean;
icon: keyof typeof AppIcon;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
}
@@ -367,6 +406,7 @@ interface SecondaryButtonProps {
const SecondaryButton = memo(
({
className,
disabled,
icon,
iconProps,
onClick,
@@ -395,6 +435,7 @@ const SecondaryButton = memo(
return (
<button
className={clsx(styles.secondaryButton, className)}
disabled={disabled}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseDown={handleMouseDown}
@@ -116,7 +116,7 @@ export const useItemListInfiniteLoader = ({
query: queryParams,
});
return result.items;
return result;
},
queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams),
staleTime: 1000 * 15,
@@ -130,7 +130,7 @@ export const useItemListInfiniteLoader = ({
(oldData: { data: unknown[]; pagesLoaded: Record<string, boolean> }) => {
const newData = [
...oldData.data.slice(0, startIndex),
...result,
...result.items,
...oldData.data.slice(endIndex),
];
const newPagesLoaded = {
@@ -3,6 +3,8 @@ import {
TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListItem } from '/@/renderer/components/item-list/types';
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
export const FavoriteColumn = (props: ItemTableListInnerColumn) => {
@@ -10,11 +12,16 @@ export const FavoriteColumn = (props: ItemTableListInnerColumn) => {
props.columns[props.columnIndex].id
];
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite;
if (typeof row === 'boolean') {
return (
<TableColumnContainer {...props}>
<ActionIcon
className={row ? undefined : 'hover-only'}
disabled={isMutatingFavorite}
icon="favorite"
iconProps={{
color: row ? 'primary' : 'muted',
@@ -3,6 +3,7 @@ import {
TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListItem } from '/@/renderer/components/item-list/types';
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { Rating } from '/@/shared/components/rating/rating';
export const RatingColumn = (props: ItemTableListInnerColumn) => {
@@ -10,6 +11,8 @@ export const RatingColumn = (props: ItemTableListInnerColumn) => {
props.columns[props.columnIndex].id
];
const isMutatingRating = useIsMutatingRating();
if (typeof row === 'number' || row === null) {
return (
<TableColumnContainer {...props}>
@@ -28,6 +31,7 @@ export const RatingColumn = (props: ItemTableListInnerColumn) => {
rating,
});
}}
readOnly={isMutatingRating}
size="xs"
value={row || 0}
/>