Files
dotcom-arcade/frontend/src/components/library/GameListItem.tsx
T
branderson cd1d7fbb35 chore: remove leftovers from upstream
Drops the /flash and /html5 sections (old URLs redirect to /browse),
the GitHub star count endpoint, the unused HOME_RECENT_HOURS setting,
upstream's design notes and code review docs, and the issue template's
link to upstream discussions.
2026-10-13 21:19:34 -07:00

229 lines
8.6 KiB
TypeScript

import { Link, useNavigate } from 'react-router-dom';
import { useState, memo } from 'react';
import { Game } from '@/types/game';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { ImageIcon, Play, ListPlus, Heart } from 'lucide-react';
import { FavoriteButton } from '@/components/common/FavoriteButton';
import { LazyImage } from '@/components/common/LazyImage';
import { RemoveFavoriteButton } from '@/components/common/RemoveFavoriteButton';
import { AddToPlaylistModal } from '@/components/playlist/AddToPlaylistModal';
import { useAuthStore } from '@/store/auth';
import { toast } from 'sonner';
import { buildSharedGameUrl } from '@/hooks/useSharedPlaylistAccess';
import { BreadcrumbContext } from '@/components/common/Breadcrumbs';
import { getGameLogoUrl } from '@/utils/gameUtils';
import { buildSectionGameUrl } from '@/lib/sectionRoutes';
import { StarRating } from '@/components/common/StarRating';
import type { BatchRatingAggregate } from '@/types/rating';
interface GameListItemProps {
game: Game;
showFavoriteButton?: boolean;
showRemoveButton?: boolean;
showFavoriteIndicator?: boolean;
showAddToPlaylistButton?: boolean;
favoriteGameIds?: Set<string>;
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Rating aggregate data for this game */
ratingData?: BatchRatingAggregate;
}
export const GameListItem = memo(
function GameListItem({
game,
showFavoriteButton = true,
showRemoveButton = false,
showFavoriteIndicator = false,
showAddToPlaylistButton = true,
favoriteGameIds,
isFavoritePage = false,
shareToken = null,
breadcrumbContext,
sectionKey = null,
ratingData,
}: GameListItemProps) {
const [imageError, setImageError] = useState(false);
const [isPlaylistModalOpen, setIsPlaylistModalOpen] = useState(false);
const { isAuthenticated } = useAuthStore();
const navigate = useNavigate();
const imageUrl = getGameLogoUrl(game.id) || null;
const tags = game.tagsStr
? game.tagsStr
.split(';')
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 3)
: [];
const isFavorited = favoriteGameIds?.has(game.id) ?? false;
const isPlayable = game.platformName === 'Flash' || game.platformName === 'HTML5';
const gameDetailUrl = shareToken
? buildSharedGameUrl(`/games/${game.id}`, shareToken)
: buildSectionGameUrl(game.id, sectionKey);
const gamePlayUrl = shareToken
? buildSharedGameUrl(`/games/${game.id}/play`, shareToken)
: buildSectionGameUrl(game.id, sectionKey, true);
const handleNavigateToDetails = () => {
navigate(gameDetailUrl, {
state: breadcrumbContext ? { breadcrumbContext } : undefined,
});
};
return (
<Card className="group overflow-hidden hover:bg-accent hover:shadow-md hover:border-primary/20 hover:z-10 relative transition-all">
<CardContent className="p-3">
<div className="flex items-center gap-3">
<div
onClick={handleNavigateToDetails}
className="relative flex-shrink-0 w-24 h-16 bg-muted rounded overflow-hidden border shadow-sm cursor-pointer"
>
{imageUrl && !imageError ? (
<LazyImage
src={imageUrl}
alt={game.title}
className="w-full h-full object-contain"
onError={() => setImageError(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon size={20} className="text-muted-foreground opacity-50" />
</div>
)}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div onClick={handleNavigateToDetails} className="cursor-pointer">
<h3
className="font-semibold text-sm truncate hover:text-primary transition-colors"
title={game.title}
>
{game.title}
</h3>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="secondary">{game.platformName}</Badge>
{tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{game.developer ? (
<span className="truncate" title={game.developer}>
{game.developer}
</span>
) : null}
{ratingData ? (
<StarRating
size="sm"
averageRating={ratingData.averageRating}
totalRatings={ratingData.totalRatings}
showCount
/>
) : null}
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{showFavoriteIndicator && isFavorited && !isFavoritePage ? (
<div className="h-9 w-9 flex items-center justify-center">
<Heart size={18} fill="currentColor" className="text-primary" />
</div>
) : null}
{showRemoveButton && isFavoritePage ? (
<RemoveFavoriteButton gameId={game.id} size="sm" className="h-9 w-9 p-0" />
) : null}
{showFavoriteButton && !isFavoritePage && !isFavorited ? (
<FavoriteButton
gameId={game.id}
isFavorited={false}
size="sm"
variant="ghost"
className="h-9 w-9 p-0"
showOnHoverOnly={false}
/>
) : null}
{showAddToPlaylistButton ? (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!isAuthenticated) {
toast.error('Please log in to add games to playlists');
return;
}
setIsPlaylistModalOpen(true);
}}
className="h-9 w-9 p-0"
title="Add to Playlist"
aria-label="Add to Playlist"
>
<ListPlus size={18} />
</Button>
) : null}
{isPlayable ? (
<Button size="sm" asChild>
<Link to={gamePlayUrl} title="Play Game" aria-label="Play Game">
<Play size={18} />
</Link>
</Button>
) : null}
</div>
</div>
</CardContent>
{/* Add to Playlist Modal — conditionally rendered to avoid hundreds of hidden instances */}
{isPlaylistModalOpen && (
<AddToPlaylistModal
isOpen={isPlaylistModalOpen}
onClose={() => setIsPlaylistModalOpen(false)}
gameId={game.id}
gameTitle={game.title}
/>
)}
</Card>
);
},
(prevProps, nextProps) => {
return (
prevProps.game.id === nextProps.game.id &&
prevProps.game.title === nextProps.game.title &&
prevProps.showFavoriteButton === nextProps.showFavoriteButton &&
prevProps.showRemoveButton === nextProps.showRemoveButton &&
prevProps.showFavoriteIndicator === nextProps.showFavoriteIndicator &&
prevProps.showAddToPlaylistButton === nextProps.showAddToPlaylistButton &&
prevProps.isFavoritePage === nextProps.isFavoritePage &&
prevProps.shareToken === nextProps.shareToken &&
prevProps.breadcrumbContext?.label === nextProps.breadcrumbContext?.label &&
prevProps.breadcrumbContext?.href === nextProps.breadcrumbContext?.href &&
prevProps.sectionKey === nextProps.sectionKey &&
(prevProps.favoriteGameIds?.has(prevProps.game.id) ?? false) ===
(nextProps.favoriteGameIds?.has(nextProps.game.id) ?? false) &&
prevProps.ratingData?.averageRating === nextProps.ratingData?.averageRating &&
prevProps.ratingData?.totalRatings === nextProps.ratingData?.totalRatings
);
}
);
GameListItem.displayName = 'GameListItem';