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 [loading, setLoading] = useState(false);
|
||||||
const [enriching, setEnriching] = useState<string | null>(null);
|
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 searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const filterInputRef = useRef<HTMLInputElement>(null);
|
const filterInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [filterQuery, setFilterQuery] = useState('');
|
const [filterQuery, setFilterQuery] = useState('');
|
||||||
|
|
@ -250,6 +253,16 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
||||||
}, 300);
|
}, 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 ──
|
// ── Back to overview ──
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
setMode('overview');
|
setMode('overview');
|
||||||
|
|
@ -257,6 +270,8 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
||||||
setUserGames(null);
|
setUserGames(null);
|
||||||
setCommonGames(null);
|
setCommonGames(null);
|
||||||
setFilterQuery('');
|
setFilterQuery('');
|
||||||
|
setActiveGenres(new Set());
|
||||||
|
setSortBy('playtime');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Resolve user by steamId ──
|
// ── Resolve user by steamId ──
|
||||||
|
|
@ -265,11 +280,61 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
||||||
[users],
|
[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 ──
|
// ── Filtered user games ──
|
||||||
const filteredGames = userGames
|
const filteredGames = userGames
|
||||||
? userGames
|
? sortGames(
|
||||||
|
userGames
|
||||||
.filter(g => !filterQuery || g.name.toLowerCase().includes(filterQuery.toLowerCase()))
|
.filter(g => !filterQuery || g.name.toLowerCase().includes(filterQuery.toLowerCase()))
|
||||||
.sort((a, b) => b.playtime_forever - a.playtime_forever)
|
.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;
|
: null;
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -470,7 +535,7 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
||||||
<div className="gl-loading">Bibliothek wird geladen...</div>
|
<div className="gl-loading">Bibliothek wird geladen...</div>
|
||||||
) : filteredGames ? (
|
) : filteredGames ? (
|
||||||
<>
|
<>
|
||||||
<div className="gl-search">
|
<div className="gl-filter-bar">
|
||||||
<input
|
<input
|
||||||
ref={filterInputRef}
|
ref={filterInputRef}
|
||||||
className="gl-search-input"
|
className="gl-search-input"
|
||||||
|
|
@ -479,7 +544,35 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
||||||
value={filterQuery}
|
value={filterQuery}
|
||||||
onChange={e => setFilterQuery(e.target.value)}
|
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>
|
</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 ? (
|
{filteredGames.length === 0 ? (
|
||||||
<p className="gl-search-results-title">Keine Spiele gefunden.</p>
|
<p className="gl-search-results-title">Keine Spiele gefunden.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -560,11 +653,41 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
<div className="gl-game-list">
|
<div className="gl-game-list">
|
||||||
{commonGames.map(g => (
|
{filteredCommonGames!.map(g => (
|
||||||
<div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
|
<div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
|
||||||
<div className="gl-game-visual">
|
<div className="gl-game-visual">
|
||||||
{g.igdb?.coverUrl ? (
|
{g.igdb?.coverUrl ? (
|
||||||
|
|
@ -575,7 +698,22 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
||||||
<div className="gl-game-icon" />
|
<div className="gl-game-icon" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="gl-game-info">
|
||||||
<span className="gl-game-name">{g.name}</span>
|
<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">
|
<div className="gl-common-playtimes">
|
||||||
{g.owners.map(o => (
|
{g.owners.map(o => (
|
||||||
<span key={o.steamId} className="gl-common-pt">
|
<span key={o.steamId} className="gl-common-pt">
|
||||||
|
|
@ -584,6 +722,7 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -562,6 +562,85 @@
|
||||||
50% { opacity: 1; }
|
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 ── */
|
/* ── Responsive ── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue