Game Library: Genre-Filter + Sortierung (Spielzeit/Bewertung/Name)

- Genre-Chips als Filter-Bar in User- und Common-Games-Ansicht
- Sortierung umschaltbar: Spielzeit, Bewertung, Name
- Multi-Genre-Auswahl mit "Alle"-Reset
- Common Games zeigen jetzt auch Genres und Ratings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-08 10:42:18 +01:00
parent 94820ed110
commit f64aac707a
2 changed files with 230 additions and 12 deletions

View file

@ -94,6 +94,9 @@ export default function GameLibraryTab({ data }: { data: any }) {
const [loading, setLoading] = useState(false);
const [enriching, setEnriching] = useState<string | null>(null);
const [activeGenres, setActiveGenres] = useState<Set<string>>(new Set());
const [sortBy, setSortBy] = useState<'playtime' | 'rating' | 'name'>('playtime');
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const filterInputRef = useRef<HTMLInputElement>(null);
const [filterQuery, setFilterQuery] = useState('');
@ -250,6 +253,16 @@ export default function GameLibraryTab({ data }: { data: any }) {
}, 300);
}, []);
// ── Genre toggle ──
const toggleGenre = useCallback((genre: string) => {
setActiveGenres(prev => {
const next = new Set(prev);
if (next.has(genre)) next.delete(genre);
else next.add(genre);
return next;
});
}, []);
// ── Back to overview ──
const goBack = useCallback(() => {
setMode('overview');
@ -257,6 +270,8 @@ export default function GameLibraryTab({ data }: { data: any }) {
setUserGames(null);
setCommonGames(null);
setFilterQuery('');
setActiveGenres(new Set());
setSortBy('playtime');
}, []);
// ── Resolve user by steamId ──
@ -265,11 +280,61 @@ export default function GameLibraryTab({ data }: { data: any }) {
[users],
);
// ── Sort helper ──
const getPlaytime = useCallback((g: any): number => {
if (typeof g.playtime_forever === 'number') return g.playtime_forever;
if (Array.isArray(g.owners)) return Math.max(...g.owners.map((o: any) => o.playtime_forever || 0));
return 0;
}, []);
const sortGames = useCallback(<T extends { name: string; igdb?: IgdbData }>(games: T[]): T[] => {
return [...games].sort((a, b) => {
if (sortBy === 'rating') {
const ra = a.igdb?.rating ?? -1;
const rb = b.igdb?.rating ?? -1;
return rb - ra;
}
if (sortBy === 'name') return a.name.localeCompare(b.name);
return getPlaytime(b) - getPlaytime(a);
});
}, [sortBy, getPlaytime]);
// ── Genre filter helper ──
const matchesGenres = useCallback((g: { igdb?: IgdbData }): boolean => {
if (activeGenres.size === 0) return true;
if (!g.igdb?.genres?.length) return false;
return g.igdb.genres.some(genre => activeGenres.has(genre));
}, [activeGenres]);
// ── Extract unique genres from game list ──
const extractGenres = useCallback((games: Array<{ igdb?: IgdbData }>): string[] => {
const counts = new Map<string, number>();
for (const g of games) {
if (g.igdb?.genres) {
for (const genre of g.igdb.genres) {
counts.set(genre, (counts.get(genre) || 0) + 1);
}
}
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([g]) => g);
}, []);
// ── Filtered user games ──
const filteredGames = userGames
? userGames
.filter(g => !filterQuery || g.name.toLowerCase().includes(filterQuery.toLowerCase()))
.sort((a, b) => b.playtime_forever - a.playtime_forever)
? sortGames(
userGames
.filter(g => !filterQuery || g.name.toLowerCase().includes(filterQuery.toLowerCase()))
.filter(matchesGenres)
)
: null;
// ── Available genres for current view ──
const availableGenres = userGames ? extractGenres(userGames) : [];
const availableCommonGenres = commonGames ? extractGenres(commonGames) : [];
// ── Filtered common games ──
const filteredCommonGames = commonGames
? sortGames(commonGames.filter(matchesGenres))
: null;
/*
@ -470,7 +535,7 @@ export default function GameLibraryTab({ data }: { data: any }) {
<div className="gl-loading">Bibliothek wird geladen...</div>
) : filteredGames ? (
<>
<div className="gl-search">
<div className="gl-filter-bar">
<input
ref={filterInputRef}
className="gl-search-input"
@ -479,7 +544,35 @@ export default function GameLibraryTab({ data }: { data: any }) {
value={filterQuery}
onChange={e => setFilterQuery(e.target.value)}
/>
<select
className="gl-sort-select"
value={sortBy}
onChange={e => setSortBy(e.target.value as any)}
>
<option value="playtime">Spielzeit</option>
<option value="rating">Bewertung</option>
<option value="name">Name</option>
</select>
</div>
{availableGenres.length > 0 && (
<div className="gl-genre-filters">
{activeGenres.size > 0 && (
<button className="gl-genre-chip active clear" onClick={() => setActiveGenres(new Set())}>
Alle
</button>
)}
{availableGenres.map(genre => (
<button
key={genre}
className={`gl-genre-chip${activeGenres.has(genre) ? ' active' : ''}`}
onClick={() => toggleGenre(genre)}
>
{genre}
</button>
))}
</div>
)}
<p className="gl-filter-count">{filteredGames.length} Spiele</p>
{filteredGames.length === 0 ? (
<p className="gl-search-results-title">Keine Spiele gefunden.</p>
) : (
@ -560,11 +653,41 @@ export default function GameLibraryTab({ data }: { data: any }) {
</div>
) : (
<>
<div className="gl-filter-bar">
<select
className="gl-sort-select"
value={sortBy}
onChange={e => setSortBy(e.target.value as any)}
>
<option value="playtime">Spielzeit</option>
<option value="rating">Bewertung</option>
<option value="name">Name</option>
</select>
</div>
{availableCommonGenres.length > 0 && (
<div className="gl-genre-filters">
{activeGenres.size > 0 && (
<button className="gl-genre-chip active clear" onClick={() => setActiveGenres(new Set())}>
Alle
</button>
)}
{availableCommonGenres.map(genre => (
<button
key={genre}
className={`gl-genre-chip${activeGenres.has(genre) ? ' active' : ''}`}
onClick={() => toggleGenre(genre)}
>
{genre}
</button>
))}
</div>
)}
<p className="gl-section-title">
{commonGames.length} gemeinsame{commonGames.length !== 1 ? ' Spiele' : 's Spiel'}
{filteredCommonGames!.length} gemeinsame{filteredCommonGames!.length !== 1 ? ' Spiele' : 's Spiel'}
{activeGenres.size > 0 ? ` (von ${commonGames.length})` : ''}
</p>
<div className="gl-game-list">
{commonGames.map(g => (
{filteredCommonGames!.map(g => (
<div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
<div className="gl-game-visual">
{g.igdb?.coverUrl ? (
@ -575,13 +698,29 @@ export default function GameLibraryTab({ data }: { data: any }) {
<div className="gl-game-icon" />
)}
</div>
<span className="gl-game-name">{g.name}</span>
<div className="gl-common-playtimes">
{g.owners.map(o => (
<span key={o.steamId} className="gl-common-pt">
{o.personaName}: {formatPlaytime(o.playtime_forever)}
<div className="gl-game-info">
<span className="gl-game-name">{g.name}</span>
{g.igdb?.genres && g.igdb.genres.length > 0 && (
<div className="gl-game-genres">
{g.igdb.genres.slice(0, 3).map(genre => (
<span key={genre} className="gl-genre-tag">{genre}</span>
))}
</div>
)}
</div>
<div className="gl-game-meta">
{g.igdb?.rating != null && (
<span className={`gl-game-rating ${g.igdb.rating >= 75 ? 'high' : g.igdb.rating >= 50 ? 'mid' : 'low'}`}>
{Math.round(g.igdb.rating)}
</span>
))}
)}
<div className="gl-common-playtimes">
{g.owners.map(o => (
<span key={o.steamId} className="gl-common-pt">
{o.personaName}: {formatPlaytime(o.playtime_forever)}
</span>
))}
</div>
</div>
</div>
))}

View file

@ -562,6 +562,85 @@
50% { opacity: 1; }
}
/* ── Filter Bar ── */
.gl-filter-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.gl-filter-bar .gl-search-input {
flex: 1;
}
.gl-sort-select {
background: rgba(255, 255, 255, 0.06);
color: #c7d5e0;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 10px 12px;
border-radius: var(--radius);
font-size: 13px;
cursor: pointer;
min-width: 120px;
}
.gl-sort-select:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.25);
}
.gl-sort-select option {
background: #1a1a2e;
color: #c7d5e0;
}
/* ── Genre Filter Chips ── */
.gl-genre-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
padding: 8px 0;
}
.gl-genre-chip {
background: rgba(255, 255, 255, 0.06);
color: #8899a6;
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.gl-genre-chip:hover {
background: rgba(255, 255, 255, 0.1);
color: #c7d5e0;
}
.gl-genre-chip.active {
background: rgba(230, 126, 34, 0.2);
color: #e67e22;
border-color: rgba(230, 126, 34, 0.4);
}
.gl-genre-chip.active.clear {
background: rgba(52, 152, 219, 0.2);
color: #3498db;
border-color: rgba(52, 152, 219, 0.4);
}
.gl-filter-count {
color: #556;
font-size: 12px;
margin: 0 0 8px 0;
}
/* ── Responsive ── */
@media (max-width: 768px) {