Skip to content

Commit 4e06e33

Browse files
feat(frontend): add gallery page with filters, pagination and type extraction (#58)
- Add GalleryPage at /galerie with gender, author (debounced), sort filters - Add GalleryCard component with React.memo and relative date display - Add useDebounce generic hook and formatRelativeDate utility - Add getGallery service calling GET /api/characters with query params - Extract types into dedicated types/ directory (SRP: requests, responses, gallery, common) - Migrate all consumers to import types from types/ instead of characterService - Add race condition protection with request ID ref pattern - Sanitize LIKE patterns for author search - Add 445 passing tests including gallery, debounce and formatRelativeDate
1 parent 9dee05f commit 4e06e33

26 files changed

+792
-75
lines changed

src/frontend/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Routes, Route } from 'react-router-dom';
2-
import { HomePage, RegisterPage, LoginPage, ForgotPasswordPage, DashboardPage, ContactPage, UnauthorizedPage, CreateCharacterPage, EditCharacterPage, CharacterDetailPage, LegalPage } from './pages';
2+
import { HomePage, RegisterPage, LoginPage, ForgotPasswordPage, DashboardPage, ContactPage, UnauthorizedPage, CreateCharacterPage, EditCharacterPage, CharacterDetailPage, GalleryPage, LegalPage } from './pages';
33
import { ProtectedRoute } from './components/auth';
44

55
function App() {
@@ -13,6 +13,7 @@ function App() {
1313
<Route path="/characters/create" element={<ProtectedRoute requiredRole="User"><CreateCharacterPage /></ProtectedRoute>} />
1414
<Route path="/characters/:id/edit" element={<ProtectedRoute requiredRole="User"><EditCharacterPage /></ProtectedRoute>} />
1515
<Route path="/characters/:id" element={<CharacterDetailPage />} />
16+
<Route path="/galerie" element={<GalleryPage />} />
1617
<Route path="/contact" element={<ContactPage />} />
1718
<Route path="/mentions-legales" element={<LegalPage slug="mentions-legales" />} />
1819
<Route path="/cgu" element={<LegalPage slug="cgu" />} />

src/frontend/src/components/character/CharacterCard.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { MemoryRouter } from 'react-router-dom';
33
import { describe, it, expect, vi } from 'vitest';
44
import { axe, toHaveNoViolations } from 'jest-axe';
55
import { CharacterCard } from './CharacterCard';
6-
import type { CharacterSummary } from '../../services/characterService';
6+
import type { CharacterSummary } from '../../types';
77

88
expect.extend(toHaveNoViolations);
99

src/frontend/src/components/character/CharacterCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Link } from 'react-router-dom';
22
import { Button, Badge } from '../ui';
33
import { EditIcon, DuplicateIcon, DeleteIcon, ShareIcon } from '../ui/icons';
44
import { MiniPreview } from './MiniPreview';
5-
import type { CharacterSummary } from '../../services/characterService';
5+
import type { CharacterSummary } from '../../types';
66

77
interface CharacterCardProps {
88
character: CharacterSummary;

src/frontend/src/components/character/CharacterForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
FormStepper,
1616
} from '../ui';
1717
import { fetchCharacterClasses, type CharacterClass } from '../../services/referenceDataService';
18-
import { type CreateCharacterData } from '../../services/characterService';
18+
import type { CreateCharacterData } from '../../types';
1919
import { useAuth } from '../../context/AuthContext';
2020
import { useNameAvailability, validateCharacterName } from '../../hooks';
2121
import {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { memo } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import type { GalleryCharacter } from '../../types';
4+
import { MiniPreview } from './MiniPreview';
5+
import { CLASS_ICONS } from '../ui/icons';
6+
import { formatRelativeDate } from '../../utils/formatRelativeDate';
7+
8+
interface GalleryCardProps {
9+
character: GalleryCharacter;
10+
}
11+
12+
export const GalleryCard = memo(function GalleryCard({ character }: GalleryCardProps) {
13+
return (
14+
<Link
15+
to={`/characters/${character.id}`}
16+
className="group block bg-dark-800 border border-dark-700 rounded-xl p-4 transition-all duration-200 hover:border-gold-500/50 hover:-translate-y-1 hover:shadow-[0_8px_24px_rgba(0,0,0,0.4)]"
17+
aria-label={`Voir ${character.name} par ${character.authorPseudo}`}
18+
>
19+
<div className="flex items-start gap-4">
20+
<div className="flex-shrink-0">
21+
<MiniPreview
22+
skinColor={character.skinColor}
23+
hairColor={character.hairColor}
24+
eyeColor={character.eyeColor}
25+
faceShape={character.faceShape}
26+
hairStyle={character.hairStyle}
27+
/>
28+
</div>
29+
30+
<div className="flex-1 min-w-0">
31+
<p className="text-cream-100 font-semibold truncate group-hover:text-gold-300 transition-colors">
32+
{character.name}
33+
</p>
34+
35+
<div className="flex items-center gap-1.5 mt-1">
36+
{CLASS_ICONS[character.className] && (
37+
<span className="w-4 h-4 text-gold-400 flex-shrink-0" aria-hidden="true">
38+
{CLASS_ICONS[character.className]}
39+
</span>
40+
)}
41+
<span className="text-sm text-cream-400">{character.className}</span>
42+
</div>
43+
44+
<p className="text-xs text-cream-500 mt-2 truncate">
45+
par <span className="text-cream-300">{character.authorPseudo}</span>
46+
</p>
47+
48+
<time
49+
dateTime={character.createdAt}
50+
className="text-xs text-cream-600 mt-1 block"
51+
>
52+
{formatRelativeDate(character.createdAt)}
53+
</time>
54+
</div>
55+
</div>
56+
</Link>
57+
);
58+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { CharacterForm } from './CharacterForm';
22
export { CharacterPreview } from './CharacterPreview';
33
export { CharacterCard } from './CharacterCard';
4+
export { GalleryCard } from './GalleryCard';
45
export { MiniPreview } from './MiniPreview';
56
export { DeleteConfirmModal } from './DeleteConfirmModal';
67
export { DuplicateModal } from './DuplicateModal';

src/frontend/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from './useDebounce';
12
export * from './useNameAvailability';
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useState, useEffect } from 'react';
2+
3+
/**
4+
* Delays updating a value until a specified period of inactivity has passed.
5+
* Useful for limiting API calls triggered by rapid user input (e.g. search fields).
6+
*
7+
* The returned value only updates once the caller stops changing {@link value}
8+
* for at least {@link delay} milliseconds. Each new change resets the timer.
9+
*
10+
* @param value - The raw value to debounce.
11+
* @param delay - The debounce delay in milliseconds.
12+
* @returns The debounced value.
13+
*
14+
* @example
15+
* const debouncedSearch = useDebounce(searchTerm, 300);
16+
* // debouncedSearch updates 300 ms after the user stops typing.
17+
*/
18+
export function useDebounce<T>(value: T, delay: number): T {
19+
const [debouncedValue, setDebouncedValue] = useState(value);
20+
21+
useEffect(() => {
22+
const timer = setTimeout(() => setDebouncedValue(value), delay);
23+
return () => clearTimeout(timer);
24+
}, [value, delay]);
25+
26+
return debouncedValue;
27+
}

src/frontend/src/pages/CharacterDetailPage.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
44
import { axe, toHaveNoViolations } from 'jest-axe';
55
import CharacterDetailPage from './CharacterDetailPage';
66
import * as characterService from '../services/characterService';
7+
import type { CharacterResponse } from '../types';
78

89
expect.extend(toHaveNoViolations);
910

@@ -20,7 +21,7 @@ vi.mock('../services/characterService', () => ({
2021
getCharacterPublic: vi.fn(),
2122
}));
2223

23-
const mockCharacter: characterService.CharacterResponse = {
24+
const mockCharacter: CharacterResponse = {
2425
id: 1,
2526
name: 'Arthas',
2627
classId: 1,

src/frontend/src/pages/CharacterDetailPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
22
import { useParams, useNavigate } from 'react-router-dom';
33
import { useAuth } from '../context/AuthContext';
44
import { getCharacterPublic } from '../services/characterService';
5-
import type { CharacterResponse } from '../services/characterService';
5+
import type { CharacterResponse } from '../types';
66
import { Header, Footer } from '../components/layout';
77
import { Button, Badge, Alert } from '../components/ui';
88
import { EditIcon, CLASS_ICONS } from '../components/ui/icons';

0 commit comments

Comments
 (0)