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:
parent
94820ed110
commit
f64aac707a
2 changed files with 230 additions and 12 deletions
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue