From 2fc16e9bd1bf703c614ecf22f28e274fe86a4425 Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Sat, 18 Jul 2026 13:26:51 -0700 Subject: [PATCH] Add support for persistent favorite GIFs in GiphyVotingPanel Introduces a favorite system for Giphy results that persists selections via cookies. Adds a collapsible favorites section to the UI, allowing users to quickly access, view, and manage their saved GIFs. Also updates the Hashids salt in the backend configuration. --- .../src/components/GiphyVotingPanel.test.tsx | 58 +++++++ .../src/components/GiphyVotingPanel.tsx | 141 +++++++++++++++++- .../ClientApp/src/services/cookies.test.ts | 27 ++++ PointerStar/ClientApp/src/services/cookies.ts | 23 +++ PointerStar/Server/Program.cs | 2 +- 5 files changed, 249 insertions(+), 2 deletions(-) diff --git a/PointerStar/ClientApp/src/components/GiphyVotingPanel.test.tsx b/PointerStar/ClientApp/src/components/GiphyVotingPanel.test.tsx index c588e7d..e863b56 100644 --- a/PointerStar/ClientApp/src/components/GiphyVotingPanel.test.tsx +++ b/PointerStar/ClientApp/src/components/GiphyVotingPanel.test.tsx @@ -2,13 +2,21 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { GiphyVotingPanel } from './GiphyVotingPanel' import { GIPHY_PAGE_SIZE, searchGiphy } from '../services/giphyApi' +import { getStoredFavoriteGifIds, setStoredFavoriteGifIds } from '../services/cookies' vi.mock('../services/giphyApi', () => ({ GIPHY_PAGE_SIZE: 20, searchGiphy: vi.fn(), })) +vi.mock('../services/cookies', () => ({ + getStoredFavoriteGifIds: vi.fn(() => []), + setStoredFavoriteGifIds: vi.fn(), +})) + const searchGiphyMock = vi.mocked(searchGiphy) +const getStoredFavoriteGifIdsMock = vi.mocked(getStoredFavoriteGifIds) +const setStoredFavoriteGifIdsMock = vi.mocked(setStoredFavoriteGifIds) const createGif = (index: number) => ({ id: `gif-${index}`, @@ -19,6 +27,9 @@ const createGif = (index: number) => ({ describe('GiphyVotingPanel', () => { beforeEach(() => { searchGiphyMock.mockReset() + getStoredFavoriteGifIdsMock.mockReset() + getStoredFavoriteGifIdsMock.mockReturnValue([]) + setStoredFavoriteGifIdsMock.mockReset() }) it('tracks the searched GIF query for recent chips', async () => { @@ -111,4 +122,51 @@ describe('GiphyVotingPanel', () => { expect(await screen.findByAltText('GIF 21')).toBeInTheDocument() }) + + it('toggles GIF favorites from the search results', async () => { + searchGiphyMock.mockResolvedValue({ + data: [createGif(1)], + pagination: { + count: 1, + offset: 0, + totalCount: 1, + }, + }) + + render() + + fireEvent.change(screen.getByPlaceholderText('Search for a GIF...'), { + target: { value: 'cat' }, + }) + + await waitFor(() => { + expect(searchGiphyMock).toHaveBeenCalledWith({ + query: 'cat', + offset: 0, + }) + }, { timeout: 3000 }) + + await screen.findByAltText('GIF 1') + const favoriteButton = await screen.findByRole('button', { + name: 'Add GIF 1 to favorites', + }) + fireEvent.click(favoriteButton) + + await waitFor(() => { + expect(setStoredFavoriteGifIdsMock).toHaveBeenLastCalledWith(['gif-1']) + }) + }) + + it('shows and collapses the favorite GIF section', () => { + getStoredFavoriteGifIdsMock.mockReturnValue(['favorite-1']) + + render() + + expect(screen.getByAltText('Favorite GIF favorite-1')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Hide' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Hide' })) + expect(screen.getByRole('button', { name: 'Show' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Show' })) + expect(screen.getByRole('button', { name: 'Hide' })).toBeInTheDocument() + }) }) diff --git a/PointerStar/ClientApp/src/components/GiphyVotingPanel.tsx b/PointerStar/ClientApp/src/components/GiphyVotingPanel.tsx index 95d183a..35d6ac1 100644 --- a/PointerStar/ClientApp/src/components/GiphyVotingPanel.tsx +++ b/PointerStar/ClientApp/src/components/GiphyVotingPanel.tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Alert, Box, Button, + Collapse, CircularProgress, IconButton, ImageList, @@ -15,8 +16,13 @@ import { useTheme, } from '@mui/material' import CloseIcon from '@mui/icons-material/Close' +import ExpandLessIcon from '@mui/icons-material/ExpandLess' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import FavoriteIcon from '@mui/icons-material/Favorite' +import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder' import SearchIcon from '@mui/icons-material/Search' import type { GiphyItem } from '../types/contracts' +import { getStoredFavoriteGifIds, setStoredFavoriteGifIds } from '../services/cookies' import { GIPHY_PAGE_SIZE, searchGiphy } from '../services/giphyApi' interface GiphyVotingPanelProps { @@ -53,7 +59,22 @@ export const GiphyVotingPanel: React.FC = ({ const [activeQuery, setActiveQuery] = useState('') const [nextOffset, setNextOffset] = useState(0) const [hasMoreResults, setHasMoreResults] = useState(false) + const [favoriteGifIds, setFavoriteGifIds] = useState(() => getStoredFavoriteGifIds()) + const [areFavoritesExpanded, setAreFavoritesExpanded] = useState(true) const requestIdRef = useRef(0) + const favoriteGifSet = useMemo(() => new Set(favoriteGifIds), [favoriteGifIds]) + const favoriteGifs = useMemo( + () => favoriteGifIds.map((gifId) => ({ + id: gifId, + title: `Favorite GIF ${gifId}`, + imageUrl: `https://media.giphy.com/media/${gifId}/giphy.gif`, + })), + [favoriteGifIds], + ) + + useEffect(() => { + setStoredFavoriteGifIds(favoriteGifIds) + }, [favoriteGifIds]) const handleSearch = useCallback( async (query: string, offset = 0, append = false) => { @@ -190,6 +211,14 @@ export const GiphyVotingPanel: React.FC = ({ void handleSearch(activeQuery, nextOffset, true) }, [activeQuery, handleSearch, hasMoreResults, loading, loadingMore, nextOffset]) + const handleToggleFavoriteGif = useCallback((gifId: string) => { + setFavoriteGifIds((currentIds) => ( + currentIds.includes(gifId) + ? currentIds.filter((currentId) => currentId !== gifId) + : [gifId, ...currentIds] + )) + }, []) + const giphyColumns = isMdUp ? 4 : isSmUp ? 3 : 2 return ( @@ -208,6 +237,93 @@ export const GiphyVotingPanel: React.FC = ({ ) : ( <> + + + Favorite GIFs ({favoriteGifIds.length}) + + + + {favoriteGifs.length > 0 ? ( + + {favoriteGifs.map((gif) => ( + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + handleSelectGif(gif.id) + } + }} + sx={{ + '&:focus-visible': { + outline: '2px solid', + outlineColor: 'primary.main', + outlineOffset: 2, + }, + border: selectedId === gif.id ? '3px solid' : '1px solid', + borderColor: selectedId === gif.id ? 'primary.main' : 'divider', + cursor: 'pointer', + opacity: selectedId === gif.id ? 1 : 0.9, + position: 'relative', + transition: 'all 0.2s ease', + '&:hover': { + border: '3px solid', + borderColor: 'primary.main', + opacity: 1, + }, + }} + onClick={() => handleSelectGif(gif.id)} + role="button" + tabIndex={0} + > + { + event.preventDefault() + event.stopPropagation() + handleToggleFavoriteGif(gif.id) + }} + size="small" + sx={{ + backgroundColor: 'rgba(0, 0, 0, 0.45)', + color: 'common.white', + position: 'absolute', + right: 4, + top: 4, + zIndex: 1, + '&:hover': { + backgroundColor: 'rgba(0, 0, 0, 0.6)', + }, + }} + > + + + {gif.title} + + ))} + + ) : ( + + No favorite GIFs yet. + + )} + + + = ({ opacity: selectedId === gif.id ? 1 : 0.8, border: selectedId === gif.id ? '3px solid' : '1px solid', borderColor: selectedId === gif.id ? 'primary.main' : 'divider', + position: 'relative', transition: 'all 0.2s ease', '&:hover': { opacity: 1, @@ -284,6 +401,28 @@ export const GiphyVotingPanel: React.FC = ({ role="button" tabIndex={0} > + { + event.preventDefault() + event.stopPropagation() + handleToggleFavoriteGif(gif.id) + }} + size="small" + sx={{ + backgroundColor: 'rgba(0, 0, 0, 0.45)', + color: favoriteGifSet.has(gif.id) ? 'error.light' : 'common.white', + position: 'absolute', + right: 4, + top: 4, + zIndex: 1, + '&:hover': { + backgroundColor: 'rgba(0, 0, 0, 0.6)', + }, + }} + > + {favoriteGifSet.has(gif.id) ? : } + {gif.title} { expect(getStoredRecentGifSearches()).toEqual([]) }) + + it('returns an empty array for favorite GIF IDs when nothing is stored', () => { + expect(getStoredFavoriteGifIds()).toEqual([]) + }) + + it('blocks favorite GIF ID writes until consent is accepted', () => { + setStoredFavoriteGifIds(['gif-1']) + + expect(getStoredFavoriteGifIds()).toEqual([]) + }) + + it('persists and restores favorite GIF IDs after consent is accepted', () => { + acceptCookies() + setStoredFavoriteGifIds(['gif-1', 'gif-2']) + + expect(getStoredFavoriteGifIds()).toEqual(['gif-1', 'gif-2']) + }) + + it('clears favorite GIF IDs when an empty array is stored', () => { + acceptCookies() + setStoredFavoriteGifIds(['gif-1']) + setStoredFavoriteGifIds([]) + + expect(getStoredFavoriteGifIds()).toEqual([]) + }) }) diff --git a/PointerStar/ClientApp/src/services/cookies.ts b/PointerStar/ClientApp/src/services/cookies.ts index b146a80..34f68ed 100644 --- a/PointerStar/ClientApp/src/services/cookies.ts +++ b/PointerStar/ClientApp/src/services/cookies.ts @@ -1,6 +1,7 @@ const acceptedValue = 'accepted' const consentCookieKey = 'CookieConsent' const defaultExpirationDays = 30 +const favoriteGifIdsKey = 'FavoriteGifIds' const nameKey = 'Name' const recentGifSearchesKey = 'RecentGifSearches' const rejectedValue = 'rejected' @@ -137,3 +138,25 @@ export function getStoredRecentGifSearches(): string[] { export function setStoredRecentGifSearches(value: string[]) { setCookieValue(recentGifSearchesKey, value.length > 0 ? JSON.stringify(value) : '') } + +export function getStoredFavoriteGifIds(): string[] { + const value = getCookieValue(favoriteGifIdsKey) + if (!value) { + return [] + } + + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) { + return parsed + } + } catch (error) { + console.warn('Unable to parse stored favorite GIF IDs.', error) + } + + return [] +} + +export function setStoredFavoriteGifIds(value: string[]) { + setCookieValue(favoriteGifIdsKey, value.length > 0 ? JSON.stringify(value) : '') +} diff --git a/PointerStar/Server/Program.cs b/PointerStar/Server/Program.cs index 478e76d..2c6f5f2 100644 --- a/PointerStar/Server/Program.cs +++ b/PointerStar/Server/Program.cs @@ -51,7 +51,7 @@ options.ClientTimeoutInterval = TimeSpan.FromMinutes(3); }); -builder.Services.AddScoped(_ => new Hashids("TODO: Environment Salt")); +builder.Services.AddScoped(_ => new Hashids("Pointer*")); builder.Services.AddSingleton(); var app = builder.Build();