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})
+ : }
+ onClick={() => setAreFavoritesExpanded((expanded) => !expanded)}
+ size="small"
+ variant="text"
+ >
+ {areFavoritesExpanded ? 'Hide' : 'Show'}
+
+
+
+ {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)',
+ },
+ }}
+ >
+
+
+
+
+ ))}
+
+ ) : (
+
+ 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) ? : }
+
{
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();