Feature: Game Library Plugin - Steam Spielebibliothek
- Neues Plugin: game-library mit Steam OpenID 2.0 Login - Steam GetOwnedGames API zum Abrufen der Spielebibliothek - Gemeinsame Spiele finden (Schnittmenge mehrerer Bibliotheken) - Spielesuche ueber alle verbundenen User - User-Profil mit Spielzeit-Sortierung - JSON-basierte Persistenz in /data/game-library.json - Steam API Key als CI/CD Variable konfiguriert - Frontend: User Cards, Common Games Finder, Suchfunktion Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b2d7019b49
commit
87b4467995
6 changed files with 1420 additions and 0 deletions
|
|
@ -153,6 +153,7 @@ deploy:
|
||||||
-e PCM_CACHE_MAX_MB=2048 \
|
-e PCM_CACHE_MAX_MB=2048 \
|
||||||
-e DISCORD_TOKEN_JUKEBOX="$GAMING_HUB_DISCORD_JUKEBOX" \
|
-e DISCORD_TOKEN_JUKEBOX="$GAMING_HUB_DISCORD_JUKEBOX" \
|
||||||
-e DISCORD_TOKEN_RADIO="$GAMING_HUB_DISCORD_RADIO" \
|
-e DISCORD_TOKEN_RADIO="$GAMING_HUB_DISCORD_RADIO" \
|
||||||
|
-e STEAM_API_KEY="$STEAM_API_KEY" \
|
||||||
-v /mnt/cache/appdata/gaming-hub/data:/data:rw \
|
-v /mnt/cache/appdata/gaming-hub/data:/data:rw \
|
||||||
-v /mnt/cache/appdata/dockge/container/jukebox/sounds/:/data/sounds:rw \
|
-v /mnt/cache/appdata/dockge/container/jukebox/sounds/:/data/sounds:rw \
|
||||||
"$DEPLOY_IMAGE"
|
"$DEPLOY_IMAGE"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import soundboardPlugin from './plugins/soundboard/index.js';
|
||||||
import lolstatsPlugin from './plugins/lolstats/index.js';
|
import lolstatsPlugin from './plugins/lolstats/index.js';
|
||||||
import streamingPlugin, { attachWebSocket } from './plugins/streaming/index.js';
|
import streamingPlugin, { attachWebSocket } from './plugins/streaming/index.js';
|
||||||
import watchTogetherPlugin, { attachWatchTogetherWs } from './plugins/watch-together/index.js';
|
import watchTogetherPlugin, { attachWatchTogetherWs } from './plugins/watch-together/index.js';
|
||||||
|
import gameLibraryPlugin from './plugins/game-library/index.js';
|
||||||
|
|
||||||
// ── Config ──
|
// ── Config ──
|
||||||
const PORT = Number(process.env.PORT ?? 8080);
|
const PORT = Number(process.env.PORT ?? 8080);
|
||||||
|
|
@ -143,6 +144,11 @@ async function boot(): Promise<void> {
|
||||||
const ctxWatchTogether: PluginContext = { client: clientWatchTogether, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS };
|
const ctxWatchTogether: PluginContext = { client: clientWatchTogether, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS };
|
||||||
registerPlugin(watchTogetherPlugin, ctxWatchTogether);
|
registerPlugin(watchTogetherPlugin, ctxWatchTogether);
|
||||||
|
|
||||||
|
// game-library has no Discord bot — use a dummy client
|
||||||
|
const clientGameLibrary = createClient();
|
||||||
|
const ctxGameLibrary: PluginContext = { client: clientGameLibrary, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS };
|
||||||
|
registerPlugin(gameLibraryPlugin, ctxGameLibrary);
|
||||||
|
|
||||||
// Init all plugins
|
// Init all plugins
|
||||||
for (const p of getPlugins()) {
|
for (const p of getPlugins()) {
|
||||||
const pCtx = getPluginCtx(p.name)!;
|
const pCtx = getPluginCtx(p.name)!;
|
||||||
|
|
|
||||||
419
server/src/plugins/game-library/index.ts
Normal file
419
server/src/plugins/game-library/index.ts
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
import type express from 'express';
|
||||||
|
import type { Plugin, PluginContext } from '../../core/plugin.js';
|
||||||
|
import { sseBroadcast } from '../../core/sse.js';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
interface SteamGame {
|
||||||
|
appid: number;
|
||||||
|
name: string;
|
||||||
|
playtime_forever: number; // minutes
|
||||||
|
img_icon_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SteamUser {
|
||||||
|
steamId: string;
|
||||||
|
personaName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
profileUrl: string;
|
||||||
|
games: SteamGame[];
|
||||||
|
lastUpdated: string; // ISO date
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameLibraryData {
|
||||||
|
users: Record<string, SteamUser>; // keyed by steamId
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
const STEAM_API_KEY = process.env.STEAM_API_KEY || '2481D43449821AA4FF66502A9166DF32';
|
||||||
|
|
||||||
|
// ── Data Persistence ──
|
||||||
|
|
||||||
|
function getDataPath(ctx: PluginContext): string {
|
||||||
|
return `${ctx.dataDir}/game-library.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadData(ctx: PluginContext): GameLibraryData {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(getDataPath(ctx), 'utf-8');
|
||||||
|
return JSON.parse(raw) as GameLibraryData;
|
||||||
|
} catch {
|
||||||
|
const empty: GameLibraryData = { users: {} };
|
||||||
|
saveData(ctx, empty);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveData(ctx: PluginContext, data: GameLibraryData): void {
|
||||||
|
try {
|
||||||
|
const dir = path.dirname(getDataPath(ctx));
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(getDataPath(ctx), JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[GameLibrary] Failed to save data:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE Broadcast ──
|
||||||
|
|
||||||
|
function broadcastUpdate(data: GameLibraryData): void {
|
||||||
|
const users = Object.values(data.users).map(u => ({
|
||||||
|
steamId: u.steamId,
|
||||||
|
personaName: u.personaName,
|
||||||
|
avatarUrl: u.avatarUrl,
|
||||||
|
gameCount: u.games.length,
|
||||||
|
lastUpdated: u.lastUpdated,
|
||||||
|
}));
|
||||||
|
sseBroadcast({ type: 'game_library_update', plugin: 'game-library', users });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Steam API Helpers ──
|
||||||
|
|
||||||
|
async function fetchSteamProfile(steamId: string): Promise<{ personaName: string; avatarUrl: string; profileUrl: string }> {
|
||||||
|
const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${STEAM_API_KEY}&steamids=${steamId}`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Steam API error: ${resp.status} ${resp.statusText}`);
|
||||||
|
}
|
||||||
|
const json = await resp.json() as any;
|
||||||
|
const player = json?.response?.players?.[0];
|
||||||
|
if (!player) {
|
||||||
|
throw new Error(`Steam user not found: ${steamId}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
personaName: player.personaname || steamId,
|
||||||
|
avatarUrl: player.avatarfull || '',
|
||||||
|
profileUrl: player.profileurl || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSteamGames(steamId: string): Promise<SteamGame[]> {
|
||||||
|
const url = `https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?key=${STEAM_API_KEY}&steamid=${steamId}&include_appinfo=true&include_played_free_games=true&format=json`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Steam API error: ${resp.status} ${resp.statusText}`);
|
||||||
|
}
|
||||||
|
const json = await resp.json() as any;
|
||||||
|
const games: SteamGame[] = (json?.response?.games || []).map((g: any) => ({
|
||||||
|
appid: g.appid,
|
||||||
|
name: g.name || '',
|
||||||
|
playtime_forever: g.playtime_forever || 0,
|
||||||
|
img_icon_url: g.img_icon_url || '',
|
||||||
|
}));
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plugin ──
|
||||||
|
|
||||||
|
const gameLibraryPlugin: Plugin = {
|
||||||
|
name: 'game-library',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Steam Spielebibliothek',
|
||||||
|
|
||||||
|
async init(ctx) {
|
||||||
|
loadData(ctx); // ensure file exists
|
||||||
|
console.log('[GameLibrary] Initialized');
|
||||||
|
},
|
||||||
|
|
||||||
|
registerRoutes(app: express.Application, ctx: PluginContext) {
|
||||||
|
// ── Steam OpenID Login ──
|
||||||
|
app.get('/api/game-library/steam/login', (_req, res) => {
|
||||||
|
const proto = _req.headers['x-forwarded-proto'] || _req.protocol || 'http';
|
||||||
|
const host = _req.headers['x-forwarded-host'] || _req.headers.host || 'localhost';
|
||||||
|
const realm = `${proto}://${host}`;
|
||||||
|
const returnTo = `${realm}/api/game-library/steam/callback`;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
||||||
|
'openid.mode': 'checkid_setup',
|
||||||
|
'openid.return_to': returnTo,
|
||||||
|
'openid.realm': realm,
|
||||||
|
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||||
|
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Steam OpenID Callback ──
|
||||||
|
app.get('/api/game-library/steam/callback', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const claimedId = String(req.query['openid.claimed_id'] || '');
|
||||||
|
const steamIdMatch = claimedId.match(/\/id\/(\d+)$/);
|
||||||
|
if (!steamIdMatch) {
|
||||||
|
res.status(400).send(errorPage('Steam-Authentifizierung fehlgeschlagen', 'Keine gueltige Steam-ID erhalten.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const steamId = steamIdMatch[1];
|
||||||
|
|
||||||
|
// Verify authentication with Steam
|
||||||
|
const verifyParams = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(req.query)) {
|
||||||
|
verifyParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
verifyParams.set('openid.mode', 'check_authentication');
|
||||||
|
|
||||||
|
const verifyResp = await fetch(`https://steamcommunity.com/openid/login?${verifyParams.toString()}`);
|
||||||
|
const verifyText = await verifyResp.text();
|
||||||
|
|
||||||
|
if (!verifyText.includes('is_valid:true')) {
|
||||||
|
res.status(403).send(errorPage('Verifizierung fehlgeschlagen', 'Steam konnte die Anmeldung nicht verifizieren.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch profile and games in parallel
|
||||||
|
const [profile, games] = await Promise.all([
|
||||||
|
fetchSteamProfile(steamId),
|
||||||
|
fetchSteamGames(steamId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Store user data
|
||||||
|
const data = loadData(ctx);
|
||||||
|
data.users[steamId] = {
|
||||||
|
steamId,
|
||||||
|
personaName: profile.personaName,
|
||||||
|
avatarUrl: profile.avatarUrl,
|
||||||
|
profileUrl: profile.profileUrl,
|
||||||
|
games,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
saveData(ctx, data);
|
||||||
|
broadcastUpdate(data);
|
||||||
|
|
||||||
|
const personaName = profile.personaName;
|
||||||
|
console.log(`[GameLibrary] Steam-Benutzer verknuepft: ${personaName} (${steamId}) - ${games.length} Spiele`);
|
||||||
|
|
||||||
|
res.send(`<!DOCTYPE html><html><head><title>Steam verbunden</title><style>body{background:#1a1a2e;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}div{text-align:center}h2{color:#2ecc71}</style></head><body><div><h2>Steam verbunden!</h2><p>${personaName} wurde erfolgreich verknuepft.</p><p>${games.length} Spiele geladen.</p><p>Du kannst dieses Fenster schliessen.</p><script>setTimeout(()=>window.close(),3000)</script></div></body></html>`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[GameLibrary] Callback error:', err);
|
||||||
|
res.status(500).send(errorPage('Fehler', 'Ein unerwarteter Fehler ist aufgetreten.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/game-library/users ──
|
||||||
|
app.get('/api/game-library/users', (_req, res) => {
|
||||||
|
const data = loadData(ctx);
|
||||||
|
const users = Object.values(data.users).map(u => ({
|
||||||
|
steamId: u.steamId,
|
||||||
|
personaName: u.personaName,
|
||||||
|
avatarUrl: u.avatarUrl,
|
||||||
|
gameCount: u.games.length,
|
||||||
|
lastUpdated: u.lastUpdated,
|
||||||
|
}));
|
||||||
|
res.json({ users });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/game-library/user/:steamId ──
|
||||||
|
app.get('/api/game-library/user/:steamId', (req, res) => {
|
||||||
|
const data = loadData(ctx);
|
||||||
|
const user = data.users[req.params.steamId];
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: 'Benutzer nicht gefunden.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Sort games by playtime descending
|
||||||
|
const sortedGames = [...user.games].sort((a, b) => b.playtime_forever - a.playtime_forever);
|
||||||
|
res.json({
|
||||||
|
steamId: user.steamId,
|
||||||
|
personaName: user.personaName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
profileUrl: user.profileUrl,
|
||||||
|
gameCount: sortedGames.length,
|
||||||
|
lastUpdated: user.lastUpdated,
|
||||||
|
games: sortedGames,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/game-library/common-games?users=id1,id2,... ──
|
||||||
|
app.get('/api/game-library/common-games', (req, res) => {
|
||||||
|
const userIds = String(req.query.users || '').split(',').filter(Boolean);
|
||||||
|
if (userIds.length < 2) {
|
||||||
|
res.status(400).json({ error: 'Mindestens zwei Steam-IDs erforderlich (users=id1,id2).' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = loadData(ctx);
|
||||||
|
|
||||||
|
// Validate all users exist
|
||||||
|
for (const id of userIds) {
|
||||||
|
if (!data.users[id]) {
|
||||||
|
res.status(404).json({ error: `Benutzer ${id} nicht gefunden.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build game sets per user: Map<appid, SteamGame>
|
||||||
|
const userGameMaps = userIds.map(id => {
|
||||||
|
const map = new Map<number, SteamGame>();
|
||||||
|
for (const game of data.users[id].games) {
|
||||||
|
map.set(game.appid, game);
|
||||||
|
}
|
||||||
|
return { userId: id, gameMap: map };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find intersection: games present in ALL users
|
||||||
|
const firstUserGames = userGameMaps[0].gameMap;
|
||||||
|
const commonAppIds: number[] = [];
|
||||||
|
|
||||||
|
for (const appid of firstUserGames.keys()) {
|
||||||
|
const allOwn = userGameMaps.every(u => u.gameMap.has(appid));
|
||||||
|
if (allOwn) {
|
||||||
|
commonAppIds.push(appid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result with owner info
|
||||||
|
const games = commonAppIds.map(appid => {
|
||||||
|
const refGame = firstUserGames.get(appid)!;
|
||||||
|
const owners = userIds.map(id => {
|
||||||
|
const user = data.users[id];
|
||||||
|
const userGame = user.games.find(g => g.appid === appid);
|
||||||
|
return {
|
||||||
|
steamId: user.steamId,
|
||||||
|
personaName: user.personaName,
|
||||||
|
playtime_forever: userGame?.playtime_forever || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
appid: refGame.appid,
|
||||||
|
name: refGame.name,
|
||||||
|
img_icon_url: refGame.img_icon_url,
|
||||||
|
owners,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by name
|
||||||
|
games.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
res.json({ games, count: games.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/game-library/search?q=term ──
|
||||||
|
app.get('/api/game-library/search', (req, res) => {
|
||||||
|
const query = String(req.query.q || '').trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
res.status(400).json({ error: 'Suchbegriff erforderlich (q=...).' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = loadData(ctx);
|
||||||
|
const allUsers = Object.values(data.users);
|
||||||
|
|
||||||
|
// Collect all unique games matching the query
|
||||||
|
const gameMap = new Map<number, { appid: number; name: string; img_icon_url: string; owners: { steamId: string; personaName: string }[] }>();
|
||||||
|
|
||||||
|
for (const user of allUsers) {
|
||||||
|
for (const game of user.games) {
|
||||||
|
if (game.name.toLowerCase().includes(query)) {
|
||||||
|
if (!gameMap.has(game.appid)) {
|
||||||
|
gameMap.set(game.appid, {
|
||||||
|
appid: game.appid,
|
||||||
|
name: game.name,
|
||||||
|
img_icon_url: game.img_icon_url,
|
||||||
|
owners: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
gameMap.get(game.appid)!.owners.push({
|
||||||
|
steamId: user.steamId,
|
||||||
|
personaName: user.personaName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const games = [...gameMap.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
res.json({ games, count: games.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /api/game-library/refresh/:steamId ──
|
||||||
|
app.post('/api/game-library/refresh/:steamId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { steamId } = req.params;
|
||||||
|
const data = loadData(ctx);
|
||||||
|
const user = data.users[steamId];
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: 'Benutzer nicht gefunden.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch profile and games in parallel
|
||||||
|
const [profile, games] = await Promise.all([
|
||||||
|
fetchSteamProfile(steamId),
|
||||||
|
fetchSteamGames(steamId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
data.users[steamId] = {
|
||||||
|
steamId,
|
||||||
|
personaName: profile.personaName,
|
||||||
|
avatarUrl: profile.avatarUrl,
|
||||||
|
profileUrl: profile.profileUrl,
|
||||||
|
games,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
saveData(ctx, data);
|
||||||
|
broadcastUpdate(data);
|
||||||
|
|
||||||
|
console.log(`[GameLibrary] Aktualisiert: ${profile.personaName} (${steamId}) - ${games.length} Spiele`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
steamId,
|
||||||
|
personaName: profile.personaName,
|
||||||
|
avatarUrl: profile.avatarUrl,
|
||||||
|
profileUrl: profile.profileUrl,
|
||||||
|
gameCount: games.length,
|
||||||
|
lastUpdated: data.users[steamId].lastUpdated,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[GameLibrary] Refresh error:', err);
|
||||||
|
res.status(500).json({ error: 'Fehler beim Aktualisieren der Spielebibliothek.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DELETE /api/game-library/user/:steamId ──
|
||||||
|
app.delete('/api/game-library/user/:steamId', (req, res) => {
|
||||||
|
const { steamId } = req.params;
|
||||||
|
const data = loadData(ctx);
|
||||||
|
const user = data.users[steamId];
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: 'Benutzer nicht gefunden.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const personaName = user.personaName;
|
||||||
|
delete data.users[steamId];
|
||||||
|
saveData(ctx, data);
|
||||||
|
broadcastUpdate(data);
|
||||||
|
|
||||||
|
console.log(`[GameLibrary] Benutzer entfernt: ${personaName} (${steamId})`);
|
||||||
|
res.json({ success: true, message: `${personaName} wurde entfernt.` });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getSnapshot(ctx) {
|
||||||
|
const data = loadData(ctx);
|
||||||
|
return {
|
||||||
|
'game-library': {
|
||||||
|
users: Object.values(data.users).map(u => ({
|
||||||
|
steamId: u.steamId,
|
||||||
|
personaName: u.personaName,
|
||||||
|
avatarUrl: u.avatarUrl,
|
||||||
|
gameCount: u.games.length,
|
||||||
|
lastUpdated: u.lastUpdated,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helper: Error HTML Page ──
|
||||||
|
|
||||||
|
function errorPage(title: string, message: string): string {
|
||||||
|
return `<!DOCTYPE html><html><head><title>${title}</title><style>body{background:#1a1a2e;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}div{text-align:center}h2{color:#e74c3c}</style></head><body><div><h2>${title}</h2><p>${message}</p><p>Du kannst dieses Fenster schliessen.</p><script>setTimeout(()=>window.close(),5000)</script></div></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default gameLibraryPlugin;
|
||||||
|
|
@ -4,6 +4,7 @@ import SoundboardTab from './plugins/soundboard/SoundboardTab';
|
||||||
import LolstatsTab from './plugins/lolstats/LolstatsTab';
|
import LolstatsTab from './plugins/lolstats/LolstatsTab';
|
||||||
import StreamingTab from './plugins/streaming/StreamingTab';
|
import StreamingTab from './plugins/streaming/StreamingTab';
|
||||||
import WatchTogetherTab from './plugins/watch-together/WatchTogetherTab';
|
import WatchTogetherTab from './plugins/watch-together/WatchTogetherTab';
|
||||||
|
import GameLibraryTab from './plugins/game-library/GameLibraryTab';
|
||||||
|
|
||||||
interface PluginInfo {
|
interface PluginInfo {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -18,6 +19,7 @@ const tabComponents: Record<string, React.FC<{ data: any }>> = {
|
||||||
lolstats: LolstatsTab,
|
lolstats: LolstatsTab,
|
||||||
streaming: StreamingTab,
|
streaming: StreamingTab,
|
||||||
'watch-together': WatchTogetherTab,
|
'watch-together': WatchTogetherTab,
|
||||||
|
'game-library': GameLibraryTab,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerTab(pluginName: string, component: React.FC<{ data: any }>) {
|
export function registerTab(pluginName: string, component: React.FC<{ data: any }>) {
|
||||||
|
|
@ -122,6 +124,7 @@ export default function App() {
|
||||||
gamevote: '\u{1F3AE}',
|
gamevote: '\u{1F3AE}',
|
||||||
streaming: '\u{1F4FA}',
|
streaming: '\u{1F4FA}',
|
||||||
'watch-together': '\u{1F3AC}',
|
'watch-together': '\u{1F3AC}',
|
||||||
|
'game-library': '\u{1F3AE}',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
526
web/src/plugins/game-library/GameLibraryTab.tsx
Normal file
526
web/src/plugins/game-library/GameLibraryTab.tsx
Normal file
|
|
@ -0,0 +1,526 @@
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import './game-library.css';
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
TYPES
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
interface UserSummary {
|
||||||
|
steamId: string;
|
||||||
|
personaName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
gameCount: number;
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SteamGame {
|
||||||
|
appid: number;
|
||||||
|
name: string;
|
||||||
|
playtime_forever: number;
|
||||||
|
img_icon_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommonGame {
|
||||||
|
appid: number;
|
||||||
|
name: string;
|
||||||
|
img_icon_url: string;
|
||||||
|
owners: Array<{ steamId: string; personaName: string; playtime_forever: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
appid: number;
|
||||||
|
name: string;
|
||||||
|
img_icon_url: string;
|
||||||
|
owners: Array<{ steamId: string; personaName: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
HELPERS
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
function gameIconUrl(appid: number, hash: string): string {
|
||||||
|
return `https://media.steampowered.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPlaytime(minutes: number): string {
|
||||||
|
if (minutes < 60) return `${minutes} Min`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
COMPONENT
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
export default function GameLibraryTab({ data }: { data: any }) {
|
||||||
|
// ── State ──
|
||||||
|
const [users, setUsers] = useState<UserSummary[]>([]);
|
||||||
|
const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview');
|
||||||
|
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
|
||||||
|
const [userGames, setUserGames] = useState<SteamGame[] | null>(null);
|
||||||
|
const [commonGames, setCommonGames] = useState<CommonGame[] | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const filterInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [filterQuery, setFilterQuery] = useState('');
|
||||||
|
|
||||||
|
// ── SSE data sync ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.users) setUsers(data.users);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ── Refetch users ──
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/game-library/users');
|
||||||
|
if (resp.ok) {
|
||||||
|
const d = await resp.json();
|
||||||
|
setUsers(d.users || []);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Steam login ──
|
||||||
|
const connectSteam = useCallback(() => {
|
||||||
|
const w = window.open('/api/game-library/steam/login', '_blank', 'width=800,height=600');
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (w && w.closed) {
|
||||||
|
clearInterval(interval);
|
||||||
|
setTimeout(fetchUsers, 1000);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}, [fetchUsers]);
|
||||||
|
|
||||||
|
// ── Refetch on window focus (after login redirect) ──
|
||||||
|
useEffect(() => {
|
||||||
|
const onFocus = () => fetchUsers();
|
||||||
|
window.addEventListener('focus', onFocus);
|
||||||
|
return () => window.removeEventListener('focus', onFocus);
|
||||||
|
}, [fetchUsers]);
|
||||||
|
|
||||||
|
// ── View user library ──
|
||||||
|
const viewUser = useCallback(async (steamId: string) => {
|
||||||
|
setMode('user');
|
||||||
|
setSelectedUser(steamId);
|
||||||
|
setUserGames(null);
|
||||||
|
setFilterQuery('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/game-library/user/${steamId}`);
|
||||||
|
if (resp.ok) {
|
||||||
|
const d = await resp.json();
|
||||||
|
setUserGames(d.games || d);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Refresh single user ──
|
||||||
|
const refreshUser = useCallback(async (steamId: string, e?: React.MouseEvent) => {
|
||||||
|
if (e) e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await fetch(`/api/game-library/user/${steamId}?refresh=true`);
|
||||||
|
await fetchUsers();
|
||||||
|
if (mode === 'user' && selectedUser === steamId) {
|
||||||
|
viewUser(steamId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
}
|
||||||
|
}, [fetchUsers, mode, selectedUser, viewUser]);
|
||||||
|
|
||||||
|
// ── Toggle user selection for common games ──
|
||||||
|
const toggleCommonUser = useCallback((steamId: string) => {
|
||||||
|
setSelectedUsers(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(steamId)) next.delete(steamId);
|
||||||
|
else next.add(steamId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Find common games ──
|
||||||
|
const findCommonGames = useCallback(async () => {
|
||||||
|
if (selectedUsers.size < 2) return;
|
||||||
|
setMode('common');
|
||||||
|
setCommonGames(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const ids = Array.from(selectedUsers).join(',');
|
||||||
|
const resp = await fetch(`/api/game-library/common-games?users=${ids}`);
|
||||||
|
if (resp.ok) {
|
||||||
|
const d = await resp.json();
|
||||||
|
setCommonGames(d.games || d);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedUsers]);
|
||||||
|
|
||||||
|
// ── Search (debounced) ──
|
||||||
|
const handleSearch = useCallback((value: string) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||||
|
if (value.length < 2) {
|
||||||
|
setSearchResults(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/game-library/search?q=${encodeURIComponent(value)}`);
|
||||||
|
if (resp.ok) {
|
||||||
|
const d = await resp.json();
|
||||||
|
setSearchResults(d.results || d);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Back to overview ──
|
||||||
|
const goBack = useCallback(() => {
|
||||||
|
setMode('overview');
|
||||||
|
setSelectedUser(null);
|
||||||
|
setUserGames(null);
|
||||||
|
setCommonGames(null);
|
||||||
|
setFilterQuery('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Resolve user by steamId ──
|
||||||
|
const getUser = useCallback(
|
||||||
|
(steamId: string) => users.find(u => u.steamId === steamId),
|
||||||
|
[users],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
RENDER
|
||||||
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gl-container">
|
||||||
|
{/* ── Top bar ── */}
|
||||||
|
<div className="gl-topbar">
|
||||||
|
<button className="gl-connect-btn" onClick={connectSteam}>
|
||||||
|
Mit Steam verbinden
|
||||||
|
</button>
|
||||||
|
<div className="gl-user-chips">
|
||||||
|
{users.map(u => (
|
||||||
|
<div
|
||||||
|
key={u.steamId}
|
||||||
|
className={`gl-user-chip${selectedUser === u.steamId ? ' selected' : ''}`}
|
||||||
|
onClick={() => viewUser(u.steamId)}
|
||||||
|
>
|
||||||
|
<img className="gl-user-chip-avatar" src={u.avatarUrl} alt={u.personaName} />
|
||||||
|
<span className="gl-user-chip-name">{u.personaName}</span>
|
||||||
|
<span className="gl-user-chip-count">({u.gameCount})</span>
|
||||||
|
<button
|
||||||
|
className="gl-user-chip-refresh"
|
||||||
|
onClick={e => refreshUser(u.steamId, e)}
|
||||||
|
title="Aktualisieren"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Overview mode ── */}
|
||||||
|
{mode === 'overview' && (
|
||||||
|
<>
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<div className="gl-empty">
|
||||||
|
<div className="gl-empty-icon">🎮</div>
|
||||||
|
<h3>Keine Steam-Konten verbunden</h3>
|
||||||
|
<p>
|
||||||
|
Klicke oben auf “Mit Steam verbinden”, um deine Spielebibliothek
|
||||||
|
hinzuzufuegen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* User cards */}
|
||||||
|
<p className="gl-section-title">Verbundene Spieler</p>
|
||||||
|
<div className="gl-users-grid">
|
||||||
|
{users.map(u => (
|
||||||
|
<div key={u.steamId} className="gl-user-card" onClick={() => viewUser(u.steamId)}>
|
||||||
|
<img className="gl-user-card-avatar" src={u.avatarUrl} alt={u.personaName} />
|
||||||
|
<span className="gl-user-card-name">{u.personaName}</span>
|
||||||
|
<span className="gl-user-card-games">{u.gameCount} Spiele</span>
|
||||||
|
<span className="gl-user-card-updated">
|
||||||
|
Aktualisiert: {formatDate(u.lastUpdated)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Common games finder */}
|
||||||
|
{users.length >= 2 && (
|
||||||
|
<div className="gl-common-finder">
|
||||||
|
<h3>Gemeinsame Spiele finden</h3>
|
||||||
|
<div className="gl-common-users">
|
||||||
|
{users.map(u => (
|
||||||
|
<label
|
||||||
|
key={u.steamId}
|
||||||
|
className={`gl-common-check${selectedUsers.has(u.steamId) ? ' checked' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedUsers.has(u.steamId)}
|
||||||
|
onChange={() => toggleCommonUser(u.steamId)}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
className="gl-common-check-avatar"
|
||||||
|
src={u.avatarUrl}
|
||||||
|
alt={u.personaName}
|
||||||
|
/>
|
||||||
|
{u.personaName}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="gl-common-find-btn"
|
||||||
|
disabled={selectedUsers.size < 2}
|
||||||
|
onClick={findCommonGames}
|
||||||
|
>
|
||||||
|
Finden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="gl-search">
|
||||||
|
<input
|
||||||
|
className="gl-search-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Spiel suchen..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => handleSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search results */}
|
||||||
|
{searchResults && searchResults.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="gl-search-results-title">
|
||||||
|
{searchResults.length} Ergebnis{searchResults.length !== 1 ? 'se' : ''}
|
||||||
|
</p>
|
||||||
|
<div className="gl-game-list">
|
||||||
|
{searchResults.map(g => (
|
||||||
|
<div key={g.appid} className="gl-game-item">
|
||||||
|
{g.img_icon_url ? (
|
||||||
|
<img
|
||||||
|
className="gl-game-icon"
|
||||||
|
src={gameIconUrl(g.appid, g.img_icon_url)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="gl-game-icon" />
|
||||||
|
)}
|
||||||
|
<span className="gl-game-name">{g.name}</span>
|
||||||
|
<div className="gl-game-owners">
|
||||||
|
{g.owners.map(o => {
|
||||||
|
const u = getUser(o.steamId);
|
||||||
|
return u ? (
|
||||||
|
<img
|
||||||
|
key={o.steamId}
|
||||||
|
className="gl-game-owner-avatar"
|
||||||
|
src={u.avatarUrl}
|
||||||
|
alt={o.personaName}
|
||||||
|
title={o.personaName}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResults && searchResults.length === 0 && (
|
||||||
|
<p className="gl-search-results-title">Keine Ergebnisse gefunden.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── User mode ── */}
|
||||||
|
{mode === 'user' && (() => {
|
||||||
|
const user = selectedUser ? getUser(selectedUser) : null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="gl-detail-header">
|
||||||
|
<button className="gl-back-btn" onClick={goBack}>
|
||||||
|
← Zurueck
|
||||||
|
</button>
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
<img className="gl-detail-avatar" src={user.avatarUrl} alt={user.personaName} />
|
||||||
|
<div className="gl-detail-info">
|
||||||
|
<div className="gl-detail-name">
|
||||||
|
{user.personaName}
|
||||||
|
<span className="gl-game-count">{user.gameCount} Spiele</span>
|
||||||
|
</div>
|
||||||
|
<div className="gl-detail-sub">
|
||||||
|
Aktualisiert: {formatDate(user.lastUpdated)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="gl-refresh-btn"
|
||||||
|
onClick={() => refreshUser(user.steamId)}
|
||||||
|
title="Aktualisieren"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="gl-loading">Bibliothek wird geladen...</div>
|
||||||
|
) : filteredGames ? (
|
||||||
|
<>
|
||||||
|
<div className="gl-search">
|
||||||
|
<input
|
||||||
|
ref={filterInputRef}
|
||||||
|
className="gl-search-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Bibliothek durchsuchen..."
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={e => setFilterQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{filteredGames.length === 0 ? (
|
||||||
|
<p className="gl-search-results-title">Keine Spiele gefunden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="gl-game-list">
|
||||||
|
{filteredGames.map(g => (
|
||||||
|
<div key={g.appid} className="gl-game-item">
|
||||||
|
{g.img_icon_url ? (
|
||||||
|
<img
|
||||||
|
className="gl-game-icon"
|
||||||
|
src={gameIconUrl(g.appid, g.img_icon_url)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="gl-game-icon" />
|
||||||
|
)}
|
||||||
|
<span className="gl-game-name">{g.name}</span>
|
||||||
|
<span className="gl-game-playtime">
|
||||||
|
{formatPlaytime(g.playtime_forever)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* ── Common mode ── */}
|
||||||
|
{mode === 'common' && (() => {
|
||||||
|
const selected = Array.from(selectedUsers)
|
||||||
|
.map(id => getUser(id))
|
||||||
|
.filter(Boolean) as UserSummary[];
|
||||||
|
const names = selected.map(u => u.personaName).join(', ');
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="gl-detail-header">
|
||||||
|
<button className="gl-back-btn" onClick={goBack}>
|
||||||
|
← Zurueck
|
||||||
|
</button>
|
||||||
|
<div className="gl-detail-avatars">
|
||||||
|
{selected.map(u => (
|
||||||
|
<img key={u.steamId} src={u.avatarUrl} alt={u.personaName} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="gl-detail-info">
|
||||||
|
<div className="gl-detail-name">Gemeinsame Spiele</div>
|
||||||
|
<div className="gl-detail-sub">von {names}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="gl-loading">Gemeinsame Spiele werden gesucht...</div>
|
||||||
|
) : commonGames ? (
|
||||||
|
commonGames.length === 0 ? (
|
||||||
|
<div className="gl-empty">
|
||||||
|
<div className="gl-empty-icon">😔</div>
|
||||||
|
<h3>Keine gemeinsamen Spiele</h3>
|
||||||
|
<p>Die ausgewaehlten Spieler besitzen leider keine gemeinsamen Spiele.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="gl-section-title">
|
||||||
|
{commonGames.length} gemeinsame{commonGames.length !== 1 ? ' Spiele' : 's Spiel'}
|
||||||
|
</p>
|
||||||
|
<div className="gl-game-list">
|
||||||
|
{commonGames.map(g => (
|
||||||
|
<div key={g.appid} className="gl-game-item">
|
||||||
|
{g.img_icon_url ? (
|
||||||
|
<img
|
||||||
|
className="gl-game-icon"
|
||||||
|
src={gameIconUrl(g.appid, g.img_icon_url)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="gl-game-icon" />
|
||||||
|
)}
|
||||||
|
<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)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
465
web/src/plugins/game-library/game-library.css
Normal file
465
web/src/plugins/game-library/game-library.css
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
Game Library – Plugin Styles
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.gl-container {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top bar ── */
|
||||||
|
|
||||||
|
.gl-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-connect-btn {
|
||||||
|
background: #1b2838;
|
||||||
|
color: #c7d5e0;
|
||||||
|
border: 1px solid #2a475e;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-connect-btn:hover {
|
||||||
|
background: #2a475e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── User chips (top bar) ── */
|
||||||
|
|
||||||
|
.gl-user-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 4px 10px 4px 4px;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-chip.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(230, 126, 34, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-chip-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-chip-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-chip-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-chip-refresh {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 2px;
|
||||||
|
margin-left: 2px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-chip-refresh:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── User cards grid (overview) ── */
|
||||||
|
|
||||||
|
.gl-users-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
border: 1px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-card-avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-card-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-card-games {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-user-card-updated {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Common games finder ── */
|
||||||
|
|
||||||
|
.gl-common-finder {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-common-finder h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-common-users {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-common-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 6px 12px 6px 6px;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-common-check.checked {
|
||||||
|
background: rgba(230, 126, 34, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-common-check input {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-common-check-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-common-find-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-common-find-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-common-find-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Search ── */
|
||||||
|
|
||||||
|
.gl-search {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-search-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-search-input::placeholder {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game list ── */
|
||||||
|
|
||||||
|
.gl-game-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-game-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-game-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-game-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-game-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-game-playtime {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-game-owners {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-game-owner-avatar {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Detail header (user view / common view) ── */
|
||||||
|
|
||||||
|
.gl-detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-back-btn {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-normal);
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-back-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-detail-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-detail-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-detail-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-detail-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-refresh-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 4px;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-refresh-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading ── */
|
||||||
|
|
||||||
|
.gl-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ── */
|
||||||
|
|
||||||
|
.gl-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-empty h3 {
|
||||||
|
color: var(--text-normal);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-empty p {
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Common game playtime chips ── */
|
||||||
|
|
||||||
|
.gl-common-playtimes {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-common-pt {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section title ── */
|
||||||
|
|
||||||
|
.gl-section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-normal);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game count badge ── */
|
||||||
|
|
||||||
|
.gl-game-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Common view avatars in header ── */
|
||||||
|
|
||||||
|
.gl-detail-avatars {
|
||||||
|
display: flex;
|
||||||
|
gap: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-detail-avatars img {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--bg-primary);
|
||||||
|
margin-left: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-detail-avatars img:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Search results section title ── */
|
||||||
|
|
||||||
|
.gl-search-results-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.gl-container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-users-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue