Game Library: Multi-Platform Profile System + GOG Integration

- Neues Profile-System: User können Steam und GOG verknüpfen
- GOG OAuth2 Login-Flow (auth.gog.com)
- GOG API Service (gog.ts): Token-Management, Spieleliste, User-Info
- Server: Profile-Datenmodell, Migration bestehender Steam-Users
- Frontend: Login-Bar (Steam + GOG), Profile-Chips mit Platform-Badges
- Cross-Platform Game-Merging mit Deduplizierung
- Profile-Detail mit "GOG verknüpfen" Option

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-08 12:34:43 +01:00
parent 89e655482b
commit ee5c29dd7b
4 changed files with 859 additions and 189 deletions

View file

@ -0,0 +1,288 @@
// ──────────────────────────────────────────────────────────────────────────────
// GOG (Good Old Games) API service
// Uses GOG OAuth2 for user authentication. All paginated requests include a
// 500 ms pause between pages to respect rate-limits.
// ──────────────────────────────────────────────────────────────────────────────
const GOG_CLIENT_ID = '46899977096215655';
const GOG_CLIENT_SECRET =
'9d85c43b1482497dbbce61f6e4aa173a433796eebd2c1f0f7f015c4c2e57571';
const GOG_AUTH_URL = 'https://auth.gog.com/auth';
const GOG_TOKEN_URL = 'https://auth.gog.com/token';
const GOG_EMBED_URL = 'https://embed.gog.com';
// ── Types ────────────────────────────────────────────────────────────────────
export interface GogTokens {
accessToken: string;
refreshToken: string;
expiresAt: number; // unix-ms
userId: string;
}
export interface GogUserInfo {
userId: string;
username: string;
avatarUrl: string;
}
export interface GogGame {
gogId: number;
title: string;
image: string; // cover URL from GOG
slug: string;
}
// ── OAuth helpers ────────────────────────────────────────────────────────────
/**
* Build the GOG OAuth authorization URL that the user should visit to grant
* access. After approval, GOG will redirect to `redirectUri` with a `code`
* query parameter.
*/
export function getGogAuthUrl(redirectUri: string): string {
const params = new URLSearchParams({
client_id: GOG_CLIENT_ID,
redirect_uri: redirectUri,
response_type: 'code',
layout: 'client2',
});
return `${GOG_AUTH_URL}?${params.toString()}`;
}
/**
* Exchange an authorization code for GOG access + refresh tokens.
*/
export async function exchangeGogCode(
code: string,
redirectUri: string,
): Promise<GogTokens> {
console.log('[GOG] Exchanging authorization code for tokens...');
const params = new URLSearchParams({
client_id: GOG_CLIENT_ID,
client_secret: GOG_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
});
const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`);
if (!res.ok) {
const text = await res.text();
console.error(`[GOG] Token exchange failed (${res.status}): ${text}`);
throw new Error(`[GOG] Token exchange failed (${res.status}): ${text}`);
}
const data = (await res.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
user_id: string;
token_type: string;
};
const tokens: GogTokens = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + data.expires_in * 1000,
userId: data.user_id,
};
console.log(
`[GOG] Tokens acquired for user ${tokens.userId}, expires in ${Math.round(data.expires_in / 3600)} hours`,
);
return tokens;
}
/**
* Obtain a fresh set of tokens using an existing refresh token.
*/
export async function refreshGogToken(
refreshToken: string,
): Promise<GogTokens> {
console.log('[GOG] Refreshing access token...');
const params = new URLSearchParams({
client_id: GOG_CLIENT_ID,
client_secret: GOG_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`);
if (!res.ok) {
const text = await res.text();
console.error(`[GOG] Token refresh failed (${res.status}): ${text}`);
throw new Error(`[GOG] Token refresh failed (${res.status}): ${text}`);
}
const data = (await res.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
user_id: string;
token_type: string;
};
const tokens: GogTokens = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + data.expires_in * 1000,
userId: data.user_id,
};
console.log(
`[GOG] Token refreshed for user ${tokens.userId}, expires in ${Math.round(data.expires_in / 3600)} hours`,
);
return tokens;
}
// ── User info ────────────────────────────────────────────────────────────────
/**
* Fetch basic user profile information from the GOG embed API.
*/
export async function fetchGogUserInfo(
accessToken: string,
): Promise<GogUserInfo> {
console.log('[GOG] Fetching user info...');
const res = await fetch(`${GOG_EMBED_URL}/userData.json`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
const text = await res.text();
console.error(`[GOG] User info request failed (${res.status}): ${text}`);
throw new Error(`[GOG] User info request failed (${res.status}): ${text}`);
}
const data = (await res.json()) as {
userId: string;
username: string;
galaxyUserId: string;
avatar: string;
};
const userInfo: GogUserInfo = {
userId: data.galaxyUserId || data.userId,
username: data.username,
avatarUrl: data.avatar ? `https:${data.avatar}` : '',
};
console.log(`[GOG] User info fetched: ${userInfo.username}`);
return userInfo;
}
// ── Game library ─────────────────────────────────────────────────────────────
/**
* Fetch the complete list of owned GOG games for the authenticated user.
*
* The GOG embed API paginates results (one page at a time). We iterate
* through all pages with a 500 ms pause between requests to stay friendly.
*/
export async function fetchGogGames(
accessToken: string,
): Promise<GogGame[]> {
console.log('[GOG] Fetching game library...');
const allGames: GogGame[] = [];
let currentPage = 1;
let totalPages = 1;
do {
const url = `${GOG_EMBED_URL}/account/getFilteredProducts?mediaType=1&page=${currentPage}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
const text = await res.text();
console.error(
`[GOG] Game library request failed on page ${currentPage} (${res.status}): ${text}`,
);
throw new Error(
`[GOG] Game library request failed (${res.status}): ${text}`,
);
}
const data = (await res.json()) as {
totalPages: number;
products: Array<{
id: number;
title: string;
image: string;
slug: string;
}>;
};
totalPages = data.totalPages;
for (const product of data.products) {
allGames.push({
gogId: product.id,
title: product.title,
image: product.image ? `https:${product.image}` : '',
slug: product.slug,
});
}
console.log(
`[GOG] Page ${currentPage}/${totalPages} fetched (${data.products.length} games)`,
);
currentPage++;
// Rate-limit: 500 ms between page requests
if (currentPage <= totalPages) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
} while (currentPage <= totalPages);
console.log(`[GOG] Library complete: ${allGames.length} games total`);
return allGames;
}
// ── Cover URL helper ─────────────────────────────────────────────────────────
/**
* Convert a GOG image base path into a full HTTPS cover URL.
*
* GOG images are returned as protocol-relative paths like
* `//images-1.gog.com/xxx`. This helper ensures HTTPS and optionally
* appends a size suffix (e.g. `_196.jpg`).
*
* @param imageBase The raw image path returned by the GOG API
* @param size Optional size suffix to append (e.g. `_196.jpg`)
*/
export function gogCoverUrl(imageBase: string, size?: string): string {
let url = imageBase;
// Ensure HTTPS prefix
if (url.startsWith('//')) {
url = `https:${url}`;
} else if (!url.startsWith('http')) {
url = `https://${url}`;
}
// Append size suffix if provided
if (size) {
url = `${url}${size}`;
}
return url;
}

View file

@ -3,7 +3,9 @@ import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js'; import { sseBroadcast } from '../../core/sse.js';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import crypto from 'node:crypto';
import { lookupGame, enrichGames, type IgdbGameInfo } from './igdb.js'; import { lookupGame, enrichGames, type IgdbGameInfo } from './igdb.js';
import { getGogAuthUrl, exchangeGogCode, refreshGogToken, fetchGogUserInfo, fetchGogGames, type GogGame } from './gog.js';
// ── Types ── // ── Types ──
@ -24,8 +26,30 @@ interface SteamUser {
lastUpdated: string; // ISO date lastUpdated: string; // ISO date
} }
interface GogUserData {
gogUserId: string;
username: string;
avatarUrl: string;
games: Array<{ gogId: number; title: string; image: string; slug: string; igdb?: IgdbGameInfo }>;
accessToken: string;
refreshToken: string;
tokenExpiresAt: number;
lastUpdated: string;
}
interface UserProfile {
id: string;
displayName: string;
avatarUrl: string;
steamId?: string;
gogUserId?: string;
lastUpdated: string;
}
interface GameLibraryData { interface GameLibraryData {
users: Record<string, SteamUser>; // keyed by steamId users: Record<string, SteamUser>; // keyed by steamId
gogUsers: Record<string, GogUserData>; // keyed by gogUserId
profiles: Record<string, UserProfile>; // keyed by profileId
} }
// ── Constants ── // ── Constants ──
@ -43,7 +67,7 @@ function loadData(ctx: PluginContext): GameLibraryData {
const raw = fs.readFileSync(getDataPath(ctx), 'utf-8'); const raw = fs.readFileSync(getDataPath(ctx), 'utf-8');
return JSON.parse(raw) as GameLibraryData; return JSON.parse(raw) as GameLibraryData;
} catch { } catch {
const empty: GameLibraryData = { users: {} }; const empty: GameLibraryData = { users: {}, gogUsers: {}, profiles: {} };
saveData(ctx, empty); saveData(ctx, empty);
return empty; return empty;
} }
@ -71,7 +95,16 @@ function broadcastUpdate(data: GameLibraryData): void {
gameCount: u.games.length, gameCount: u.games.length,
lastUpdated: u.lastUpdated, lastUpdated: u.lastUpdated,
})); }));
sseBroadcast({ type: 'game_library_update', plugin: 'game-library', users }); const profiles = Object.values(data.profiles || {}).map(p => ({
id: p.id,
displayName: p.displayName,
avatarUrl: p.avatarUrl,
steamId: p.steamId,
gogUserId: p.gogUserId,
totalGames: (p.steamId && data.users[p.steamId] ? data.users[p.steamId].games.length : 0) +
(p.gogUserId && data.gogUsers?.[p.gogUserId] ? data.gogUsers[p.gogUserId].games.length : 0),
}));
sseBroadcast({ type: 'game_library_update', plugin: 'game-library', users, profiles });
} }
// ── Steam API Helpers ── // ── Steam API Helpers ──
@ -119,6 +152,27 @@ const gameLibraryPlugin: Plugin = {
async init(ctx) { async init(ctx) {
const data = loadData(ctx); // ensure file exists const data = loadData(ctx); // ensure file exists
// Migrate: ensure gogUsers and profiles exist
if (!data.profiles) data.profiles = {};
if (!data.gogUsers) data.gogUsers = {};
// Migrate existing Steam users to profiles
const existingProfileSteamIds = new Set(Object.values(data.profiles).map(p => p.steamId).filter(Boolean));
for (const user of Object.values(data.users)) {
if (!existingProfileSteamIds.has(user.steamId)) {
const profileId = crypto.randomUUID();
data.profiles[profileId] = {
id: profileId,
displayName: user.personaName,
avatarUrl: user.avatarUrl,
steamId: user.steamId,
lastUpdated: user.lastUpdated,
};
}
}
saveData(ctx, data);
console.log('[GameLibrary] Initialized'); console.log('[GameLibrary] Initialized');
// Fire-and-forget: auto-enrich all existing users with IGDB data // Fire-and-forget: auto-enrich all existing users with IGDB data
@ -217,6 +271,21 @@ const gameLibraryPlugin: Plugin = {
games, games,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
// Create or update profile for this Steam user
if (!data.profiles) data.profiles = {};
const existingProfile = Object.values(data.profiles).find(p => p.steamId === steamId);
if (!existingProfile) {
const profileId = crypto.randomUUID();
data.profiles[profileId] = {
id: profileId,
displayName: profile.personaName,
avatarUrl: profile.avatarUrl,
steamId,
lastUpdated: new Date().toISOString(),
};
}
saveData(ctx, data); saveData(ctx, data);
broadcastUpdate(data); broadcastUpdate(data);
@ -523,6 +592,173 @@ const gameLibraryPlugin: Plugin = {
} }
}); });
// ── GET /api/game-library/profiles ──
app.get('/api/game-library/profiles', (_req, res) => {
const data = loadData(ctx);
const profiles = Object.values(data.profiles).map(p => {
const steam = p.steamId ? data.users[p.steamId] : null;
const gog = p.gogUserId ? data.gogUsers[p.gogUserId] : null;
return {
id: p.id,
displayName: p.displayName,
avatarUrl: p.avatarUrl,
platforms: {
steam: steam ? { steamId: steam.steamId, personaName: steam.personaName, gameCount: steam.games.length } : null,
gog: gog ? { gogUserId: gog.gogUserId, username: gog.username, gameCount: gog.games.length } : null,
},
totalGames: (steam?.games.length || 0) + (gog?.games.length || 0),
lastUpdated: p.lastUpdated,
};
});
res.json({ profiles });
});
// ── GOG Login ──
app.get('/api/game-library/gog/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 redirectUri = `${realm}/api/game-library/gog/callback`;
const linkTo = req.query.linkTo ? `&state=${req.query.linkTo}` : '';
res.redirect(getGogAuthUrl(redirectUri) + linkTo);
});
// ── GOG Callback ──
app.get('/api/game-library/gog/callback', async (req, res) => {
try {
const code = String(req.query.code || '');
const linkToProfileId = String(req.query.state || '');
if (!code) {
res.status(400).send(errorPage('GOG-Authentifizierung fehlgeschlagen', 'Kein Authorization-Code erhalten.'));
return;
}
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
const redirectUri = `${proto}://${host}/api/game-library/gog/callback`;
// Exchange code for tokens
const tokens = await exchangeGogCode(code, redirectUri);
// Fetch user info + games
const [userInfo, games] = await Promise.all([
fetchGogUserInfo(tokens.accessToken),
fetchGogGames(tokens.accessToken),
]);
const data = loadData(ctx);
if (!data.gogUsers) data.gogUsers = {};
if (!data.profiles) data.profiles = {};
// Store GOG user
data.gogUsers[userInfo.userId] = {
gogUserId: userInfo.userId,
username: userInfo.username,
avatarUrl: userInfo.avatarUrl,
games,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
tokenExpiresAt: tokens.expiresAt,
lastUpdated: new Date().toISOString(),
};
// Link to existing profile or create new one
let profileName = userInfo.username;
if (linkToProfileId && data.profiles[linkToProfileId]) {
// Link GOG to existing profile
data.profiles[linkToProfileId].gogUserId = userInfo.userId;
data.profiles[linkToProfileId].lastUpdated = new Date().toISOString();
profileName = data.profiles[linkToProfileId].displayName;
} else {
// Check if GOG user already has a profile
const existingProfile = Object.values(data.profiles).find(p => p.gogUserId === userInfo.userId);
if (!existingProfile) {
const profileId = crypto.randomUUID();
data.profiles[profileId] = {
id: profileId,
displayName: userInfo.username,
avatarUrl: userInfo.avatarUrl,
gogUserId: userInfo.userId,
lastUpdated: new Date().toISOString(),
};
}
}
saveData(ctx, data);
broadcastUpdate(data);
console.log(`[GameLibrary] GOG verknuepft: ${profileName} (${userInfo.userId}) - ${games.length} Spiele`);
res.send(`<!DOCTYPE html><html><head><title>GOG 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:#a855f7}</style></head><body><div><h2>GOG verbunden!</h2><p>${profileName}: ${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] GOG Callback error:', err);
res.status(500).send(errorPage('GOG-Fehler', 'Ein unerwarteter Fehler ist aufgetreten.'));
}
});
// ── GET /api/game-library/profile/:profileId/games ──
app.get('/api/game-library/profile/:profileId/games', (req, res) => {
const data = loadData(ctx);
const profile = data.profiles[req.params.profileId];
if (!profile) { res.status(404).json({ error: 'Profil nicht gefunden.' }); return; }
const mergedGames: Array<{
name: string;
platform: 'steam' | 'gog';
appid?: number;
gogId?: number;
playtime_forever?: number;
img_icon_url?: string;
image?: string;
igdb?: IgdbGameInfo;
}> = [];
// Add Steam games
if (profile.steamId && data.users[profile.steamId]) {
for (const g of data.users[profile.steamId].games) {
mergedGames.push({
name: g.name,
platform: 'steam',
appid: g.appid,
playtime_forever: g.playtime_forever,
img_icon_url: g.img_icon_url,
igdb: g.igdb,
});
}
}
// Add GOG games (avoid duplicates by name)
if (profile.gogUserId && data.gogUsers[profile.gogUserId]) {
const steamNames = new Set(mergedGames.map(g => g.name.toLowerCase()));
for (const g of data.gogUsers[profile.gogUserId].games) {
if (!steamNames.has(g.title.toLowerCase())) {
mergedGames.push({
name: g.title,
platform: 'gog',
gogId: g.gogId,
image: g.image,
igdb: g.igdb,
});
}
}
}
// Sort by playtime (Steam first since they have playtime), then by name
mergedGames.sort((a, b) => (b.playtime_forever || 0) - (a.playtime_forever || 0));
res.json({
profile: {
id: profile.id,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
steamId: profile.steamId,
gogUserId: profile.gogUserId,
},
games: mergedGames,
totalGames: mergedGames.length,
});
});
// ── DELETE /api/game-library/user/:steamId ── // ── DELETE /api/game-library/user/:steamId ──
app.delete('/api/game-library/user/:steamId', (req, res) => { app.delete('/api/game-library/user/:steamId', (req, res) => {
const { steamId } = req.params; const { steamId } = req.params;

View file

@ -5,14 +5,6 @@ import './game-library.css';
TYPES TYPES
*/ */
interface UserSummary {
steamId: string;
personaName: string;
avatarUrl: string;
gameCount: number;
lastUpdated: string;
}
interface IgdbData { interface IgdbData {
igdbId: number; igdbId: number;
name: string; name: string;
@ -25,11 +17,26 @@ interface IgdbData {
igdbUrl: string | null; igdbUrl: string | null;
} }
interface SteamGame { interface ProfileSummary {
appid: number; id: string;
displayName: string;
avatarUrl: string;
platforms: {
steam: { steamId: string; personaName: string; gameCount: number } | null;
gog: { gogUserId: string; username: string; gameCount: number } | null;
};
totalGames: number;
lastUpdated: string;
}
interface MergedGame {
name: string; name: string;
playtime_forever: number; platform: 'steam' | 'gog';
img_icon_url: string; appid?: number;
gogId?: number;
playtime_forever?: number;
img_icon_url?: string;
image?: string;
igdb?: IgdbData; igdb?: IgdbData;
} }
@ -56,7 +63,8 @@ function gameIconUrl(appid: number, hash: string): string {
return `https://media.steampowered.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`; return `https://media.steampowered.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`;
} }
function formatPlaytime(minutes: number): string { function formatPlaytime(minutes: number | undefined): string {
if (minutes == null || minutes === 0) return '—';
if (minutes < 60) return `${minutes} Min`; if (minutes < 60) return `${minutes} Min`;
const h = Math.floor(minutes / 60); const h = Math.floor(minutes / 60);
const m = minutes % 60; const m = minutes % 60;
@ -83,11 +91,11 @@ function formatDate(iso: string): string {
export default function GameLibraryTab({ data }: { data: any }) { export default function GameLibraryTab({ data }: { data: any }) {
// ── State ── // ── State ──
const [users, setUsers] = useState<UserSummary[]>([]); const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview'); const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview');
const [selectedUser, setSelectedUser] = useState<string | null>(null); const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set()); const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
const [userGames, setUserGames] = useState<SteamGame[] | null>(null); const [userGames, setUserGames] = useState<MergedGame[] | null>(null);
const [commonGames, setCommonGames] = useState<CommonGame[] | null>(null); const [commonGames, setCommonGames] = useState<CommonGame[] | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null); const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
@ -103,20 +111,18 @@ export default function GameLibraryTab({ data }: { data: any }) {
// ── SSE data sync ── // ── SSE data sync ──
useEffect(() => { useEffect(() => {
if (data?.users) setUsers(data.users); if (data?.profiles) setProfiles(data.profiles);
}, [data]); }, [data]);
// ── Refetch users ── // ── Refetch profiles ──
const fetchUsers = useCallback(async () => { const fetchProfiles = useCallback(async () => {
try { try {
const resp = await fetch('/api/game-library/users'); const resp = await fetch('/api/game-library/profiles');
if (resp.ok) { if (resp.ok) {
const d = await resp.json(); const d = await resp.json();
setUsers(d.users || []); setProfiles(d.profiles || []);
} }
} catch { } catch { /* silent */ }
/* silent */
}
}, []); }, []);
// ── Steam login ── // ── Steam login ──
@ -125,45 +131,63 @@ export default function GameLibraryTab({ data }: { data: any }) {
const interval = setInterval(() => { const interval = setInterval(() => {
if (w && w.closed) { if (w && w.closed) {
clearInterval(interval); clearInterval(interval);
setTimeout(fetchUsers, 1000); setTimeout(fetchProfiles, 1000);
} }
}, 500); }, 500);
}, [fetchUsers]); }, [fetchProfiles]);
// ── GOG login ──
const connectGog = useCallback(() => {
// If viewing a profile, pass linkTo param so GOG gets linked to it
const linkParam = selectedProfile ? `?linkTo=${selectedProfile}` : '';
const w = window.open(`/api/game-library/gog/login${linkParam}`, '_blank', 'width=800,height=600');
const interval = setInterval(() => {
if (w && w.closed) {
clearInterval(interval);
setTimeout(fetchProfiles, 1500);
}
}, 500);
}, [fetchProfiles, selectedProfile]);
// ── Refetch on window focus (after login redirect) ── // ── Refetch on window focus (after login redirect) ──
useEffect(() => { useEffect(() => {
const onFocus = () => fetchUsers(); const onFocus = () => fetchProfiles();
window.addEventListener('focus', onFocus); window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus); return () => window.removeEventListener('focus', onFocus);
}, [fetchUsers]); }, [fetchProfiles]);
// ── View user library ── // ── View profile library ──
const viewUser = useCallback(async (steamId: string) => { const viewProfile = useCallback(async (profileId: string) => {
setMode('user'); setMode('user');
setSelectedUser(steamId); setSelectedProfile(profileId);
setUserGames(null); setUserGames(null);
setFilterQuery(''); setFilterQuery('');
setLoading(true); setLoading(true);
try { try {
const resp = await fetch(`/api/game-library/user/${steamId}`); const resp = await fetch(`/api/game-library/profile/${profileId}/games`);
if (resp.ok) { if (resp.ok) {
const d = await resp.json(); const d = await resp.json();
const games: SteamGame[] = d.games || d; const games: MergedGame[] = d.games || d;
setUserGames(games); setUserGames(games);
// Auto-enrich with IGDB if many games lack data // Auto-enrich with IGDB if many games lack data
const unenriched = games.filter(g => !g.igdb).length; // Find steamId from profile to use for enrichment
if (unenriched > 0) { const profile = profiles.find(p => p.id === profileId);
setEnriching(steamId); const steamId = profile?.platforms?.steam?.steamId;
fetch(`/api/game-library/igdb/enrich/${steamId}`) if (steamId) {
.then(r => r.ok ? r.json() : null) const unenriched = games.filter(g => !g.igdb).length;
.then(() => fetch(`/api/game-library/user/${steamId}`)) if (unenriched > 0) {
.then(r => r.ok ? r.json() : null) setEnriching(profileId);
.then(d2 => { fetch(`/api/game-library/igdb/enrich/${steamId}`)
if (d2) setUserGames(d2.games || d2); .then(r => r.ok ? r.json() : null)
}) .then(() => fetch(`/api/game-library/profile/${profileId}/games`))
.catch(() => {}) .then(r => r.ok ? r.json() : null)
.finally(() => setEnriching(null)); .then(d2 => {
if (d2) setUserGames(d2.games || d2);
})
.catch(() => {})
.finally(() => setEnriching(null));
}
} }
} }
} catch { } catch {
@ -171,55 +195,62 @@ export default function GameLibraryTab({ data }: { data: any }) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [profiles]);
// ── Refresh single user ── // ── Refresh single profile ──
const refreshUser = useCallback(async (steamId: string, e?: React.MouseEvent) => { const refreshProfile = useCallback(async (profileId: string, e?: React.MouseEvent) => {
if (e) e.stopPropagation(); if (e) e.stopPropagation();
const profile = profiles.find(p => p.id === profileId);
const steamId = profile?.platforms?.steam?.steamId;
try { try {
await fetch(`/api/game-library/user/${steamId}?refresh=true`); if (steamId) {
await fetchUsers(); await fetch(`/api/game-library/user/${steamId}?refresh=true`);
if (mode === 'user' && selectedUser === steamId) { }
viewUser(steamId); await fetchProfiles();
if (mode === 'user' && selectedProfile === profileId) {
viewProfile(profileId);
} }
} catch { } catch {
/* silent */ /* silent */
} }
}, [fetchUsers, mode, selectedUser, viewUser]); }, [fetchProfiles, mode, selectedProfile, viewProfile, profiles]);
// ── Enrich user library with IGDB data ── // ── Enrich profile library with IGDB data ──
const enrichUser = useCallback(async (steamId: string) => { const enrichProfile = useCallback(async (profileId: string) => {
setEnriching(steamId); const profile = profiles.find(p => p.id === profileId);
const steamId = profile?.platforms?.steam?.steamId;
if (!steamId) return;
setEnriching(profileId);
try { try {
const resp = await fetch(`/api/game-library/igdb/enrich/${steamId}`); const resp = await fetch(`/api/game-library/igdb/enrich/${steamId}`);
if (resp.ok) { if (resp.ok) {
// Reload user's game data to get IGDB info // Reload profile's game data to get IGDB info
if (mode === 'user' && selectedUser === steamId) { if (mode === 'user' && selectedProfile === profileId) {
viewUser(steamId); viewProfile(profileId);
} }
} }
} catch { /* silent */ } } catch { /* silent */ }
finally { setEnriching(null); } finally { setEnriching(null); }
}, [mode, selectedUser, viewUser]); }, [mode, selectedProfile, viewProfile, profiles]);
// ── Toggle user selection for common games ── // ── Toggle profile selection for common games ──
const toggleCommonUser = useCallback((steamId: string) => { const toggleCommonProfile = useCallback((profileId: string) => {
setSelectedUsers(prev => { setSelectedProfiles(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(steamId)) next.delete(steamId); if (next.has(profileId)) next.delete(profileId);
else next.add(steamId); else next.add(profileId);
return next; return next;
}); });
}, []); }, []);
// ── Find common games ── // ── Find common games ──
const findCommonGames = useCallback(async () => { const findCommonGames = useCallback(async () => {
if (selectedUsers.size < 2) return; if (selectedProfiles.size < 2) return;
setMode('common'); setMode('common');
setCommonGames(null); setCommonGames(null);
setLoading(true); setLoading(true);
try { try {
const ids = Array.from(selectedUsers).join(','); const ids = Array.from(selectedProfiles).join(',');
const resp = await fetch(`/api/game-library/common-games?users=${ids}`); const resp = await fetch(`/api/game-library/common-games?users=${ids}`);
if (resp.ok) { if (resp.ok) {
const d = await resp.json(); const d = await resp.json();
@ -230,7 +261,7 @@ export default function GameLibraryTab({ data }: { data: any }) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [selectedUsers]); }, [selectedProfiles]);
// ── Search (debounced) ── // ── Search (debounced) ──
const handleSearch = useCallback((value: string) => { const handleSearch = useCallback((value: string) => {
@ -266,7 +297,7 @@ export default function GameLibraryTab({ data }: { data: any }) {
// ── Back to overview ── // ── Back to overview ──
const goBack = useCallback(() => { const goBack = useCallback(() => {
setMode('overview'); setMode('overview');
setSelectedUser(null); setSelectedProfile(null);
setUserGames(null); setUserGames(null);
setCommonGames(null); setCommonGames(null);
setFilterQuery(''); setFilterQuery('');
@ -274,10 +305,10 @@ export default function GameLibraryTab({ data }: { data: any }) {
setSortBy('playtime'); setSortBy('playtime');
}, []); }, []);
// ── Resolve user by steamId ── // ── Resolve profile by id ──
const getUser = useCallback( const getProfile = useCallback(
(steamId: string) => users.find(u => u.steamId === steamId), (profileId: string) => profiles.find(p => p.id === profileId),
[users], [profiles],
); );
// ── Sort helper ── // ── Sort helper ──
@ -343,89 +374,103 @@ export default function GameLibraryTab({ data }: { data: any }) {
return ( return (
<div className="gl-container"> <div className="gl-container">
{/* ── Top bar ── */} {/* ── Login Bar ── */}
<div className="gl-topbar"> <div className="gl-login-bar">
<button className="gl-connect-btn" onClick={connectSteam}> <button className="gl-connect-btn gl-steam-btn" onClick={connectSteam}>
Mit Steam verbinden 🎮 Steam verbinden
</button> </button>
<div className="gl-user-chips"> <button className="gl-connect-btn gl-gog-btn" onClick={connectGog}>
{users.map(u => ( 🟣 GOG verbinden
</button>
</div>
{/* ── Profile Chips ── */}
{profiles.length > 0 && (
<div className="gl-profile-chips">
{profiles.map(p => (
<div <div
key={u.steamId} key={p.id}
className={`gl-user-chip${selectedUser === u.steamId ? ' selected' : ''}`} className={`gl-profile-chip${selectedProfile === p.id ? ' selected' : ''}`}
onClick={() => viewUser(u.steamId)} onClick={() => viewProfile(p.id)}
> >
<img className="gl-user-chip-avatar" src={u.avatarUrl} alt={u.personaName} /> <img className="gl-profile-chip-avatar" src={p.avatarUrl} alt={p.displayName} />
<span className="gl-user-chip-name">{u.personaName}</span> <div className="gl-profile-chip-info">
<span className="gl-user-chip-count">({u.gameCount})</span> <span className="gl-profile-chip-name">{p.displayName}</span>
<button <span className="gl-profile-chip-platforms">
className="gl-user-chip-refresh" {p.platforms.steam && <span className="gl-platform-badge steam" title={`Steam: ${p.platforms.steam.gameCount} Spiele`}>S</span>}
onClick={e => refreshUser(u.steamId, e)} {p.platforms.gog && <span className="gl-platform-badge gog" title={`GOG: ${p.platforms.gog.gameCount} Spiele`}>G</span>}
title="Aktualisieren" </span>
> </div>
&#x21bb; <span className="gl-profile-chip-count">({p.totalGames})</span>
</button>
</div> </div>
))} ))}
</div> </div>
</div> )}
{/* ── Overview mode ── */} {/* ── Overview mode ── */}
{mode === 'overview' && ( {mode === 'overview' && (
<> <>
{users.length === 0 ? ( {profiles.length === 0 ? (
<div className="gl-empty"> <div className="gl-empty">
<div className="gl-empty-icon">&#x1F3AE;</div> <div className="gl-empty-icon">&#x1F3AE;</div>
<h3>Keine Steam-Konten verbunden</h3> <h3>Keine Konten verbunden</h3>
<p> <p>
Klicke oben auf &ldquo;Mit Steam verbinden&rdquo;, um deine Spielebibliothek Klicke oben auf &ldquo;Steam verbinden&rdquo; oder &ldquo;GOG verbinden&rdquo;, um deine
hinzuzufuegen. Spielebibliothek hinzuzufuegen.
</p> </p>
</div> </div>
) : ( ) : (
<> <>
{/* User cards */} {/* Profile cards */}
<p className="gl-section-title">Verbundene Spieler</p> <p className="gl-section-title">Verbundene Spieler</p>
<div className="gl-users-grid"> <div className="gl-users-grid">
{users.map(u => ( {profiles.map(p => (
<div key={u.steamId} className="gl-user-card" onClick={() => viewUser(u.steamId)}> <div key={p.id} className="gl-user-card" onClick={() => viewProfile(p.id)}>
<img className="gl-user-card-avatar" src={u.avatarUrl} alt={u.personaName} /> <img className="gl-user-card-avatar" src={p.avatarUrl} alt={p.displayName} />
<span className="gl-user-card-name">{u.personaName}</span> <span className="gl-user-card-name">{p.displayName}</span>
<span className="gl-user-card-games">{u.gameCount} Spiele</span> <div className="gl-profile-card-platforms">
{p.platforms.steam && <span className="gl-platform-badge steam" title="Steam">S</span>}
{p.platforms.gog && <span className="gl-platform-badge gog" title="GOG">G</span>}
</div>
<span className="gl-user-card-games">{p.totalGames} Spiele</span>
<span className="gl-user-card-updated"> <span className="gl-user-card-updated">
Aktualisiert: {formatDate(u.lastUpdated)} Aktualisiert: {formatDate(p.lastUpdated)}
</span> </span>
</div> </div>
))} ))}
</div> </div>
{/* Common games finder */} {/* Common games finder */}
{users.length >= 2 && ( {profiles.length >= 2 && (
<div className="gl-common-finder"> <div className="gl-common-finder">
<h3>Gemeinsame Spiele finden</h3> <h3>Gemeinsame Spiele finden</h3>
<div className="gl-common-users"> <div className="gl-common-users">
{users.map(u => ( {profiles.map(p => (
<label <label
key={u.steamId} key={p.id}
className={`gl-common-check${selectedUsers.has(u.steamId) ? ' checked' : ''}`} className={`gl-common-check${selectedProfiles.has(p.id) ? ' checked' : ''}`}
> >
<input <input
type="checkbox" type="checkbox"
checked={selectedUsers.has(u.steamId)} checked={selectedProfiles.has(p.id)}
onChange={() => toggleCommonUser(u.steamId)} onChange={() => toggleCommonProfile(p.id)}
/> />
<img <img
className="gl-common-check-avatar" className="gl-common-check-avatar"
src={u.avatarUrl} src={p.avatarUrl}
alt={u.personaName} alt={p.displayName}
/> />
{u.personaName} {p.displayName}
<span className="gl-profile-chip-platforms" style={{ marginLeft: 4 }}>
{p.platforms.steam && <span className="gl-platform-badge steam">S</span>}
{p.platforms.gog && <span className="gl-platform-badge gog">G</span>}
</span>
</label> </label>
))} ))}
</div> </div>
<button <button
className="gl-common-find-btn" className="gl-common-find-btn"
disabled={selectedUsers.size < 2} disabled={selectedProfiles.size < 2}
onClick={findCommonGames} onClick={findCommonGames}
> >
Finden Finden
@ -465,12 +510,12 @@ export default function GameLibraryTab({ data }: { data: any }) {
<span className="gl-game-name">{g.name}</span> <span className="gl-game-name">{g.name}</span>
<div className="gl-game-owners"> <div className="gl-game-owners">
{g.owners.map(o => { {g.owners.map(o => {
const u = getUser(o.steamId); const profile = profiles.find(p => p.platforms.steam?.steamId === o.steamId);
return u ? ( return profile ? (
<img <img
key={o.steamId} key={o.steamId}
className="gl-game-owner-avatar" className="gl-game-owner-avatar"
src={u.avatarUrl} src={profile.avatarUrl}
alt={o.personaName} alt={o.personaName}
title={o.personaName} title={o.personaName}
/> />
@ -491,42 +536,57 @@ export default function GameLibraryTab({ data }: { data: any }) {
</> </>
)} )}
{/* ── User mode ── */} {/* ── User mode (profile detail) ── */}
{mode === 'user' && (() => { {mode === 'user' && (() => {
const user = selectedUser ? getUser(selectedUser) : null; const profile = selectedProfile ? getProfile(selectedProfile) : null;
return ( return (
<> <>
<div className="gl-detail-header"> <div className="gl-detail-header">
<button className="gl-back-btn" onClick={goBack}> <button className="gl-back-btn" onClick={goBack}>
&#x2190; Zurueck &#x2190; Zurueck
</button> </button>
{user && ( {profile && (
<> <>
<img className="gl-detail-avatar" src={user.avatarUrl} alt={user.personaName} /> <img className="gl-detail-avatar" src={profile.avatarUrl} alt={profile.displayName} />
<div className="gl-detail-info"> <div className="gl-detail-info">
<div className="gl-detail-name"> <div className="gl-detail-name">
{user.personaName} {profile.displayName}
<span className="gl-game-count">{user.gameCount} Spiele</span> <span className="gl-game-count">{profile.totalGames} Spiele</span>
</div> </div>
<div className="gl-detail-sub"> <div className="gl-detail-sub">
Aktualisiert: {formatDate(user.lastUpdated)} {profile.platforms.steam && (
<span className="gl-platform-badge steam" style={{ marginRight: 4 }}>
Steam
</span>
)}
{profile.platforms.gog ? (
<span className="gl-platform-badge gog">
GOG
</span>
) : (
<button className="gl-link-gog-btn" onClick={connectGog}>
🟣 GOG verknuepfen
</button>
)}
</div> </div>
</div> </div>
<button <button
className="gl-refresh-btn" className="gl-refresh-btn"
onClick={() => refreshUser(user.steamId)} onClick={() => refreshProfile(profile.id)}
title="Aktualisieren" title="Aktualisieren"
> >
&#x21bb; &#x21bb;
</button> </button>
<button {profile.platforms.steam && (
className={`gl-enrich-btn ${enriching === selectedUser ? 'enriching' : ''}`} <button
onClick={() => enrichUser(selectedUser!)} className={`gl-enrich-btn ${enriching === selectedProfile ? 'enriching' : ''}`}
disabled={enriching === selectedUser} onClick={() => enrichProfile(selectedProfile!)}
title={enriching === selectedUser ? 'IGDB-Daten werden geladen...' : 'Mit IGDB-Daten anreichern (erneut)'} disabled={enriching === selectedProfile}
> title={enriching === selectedProfile ? 'IGDB-Daten werden geladen...' : 'Mit IGDB-Daten anreichern (erneut)'}
{enriching === selectedUser ? '\u23F3' : '\uD83C\uDF10'} IGDB >
</button> {enriching === selectedProfile ? '\u23F3' : '\uD83C\uDF10'} IGDB
</button>
)}
</> </>
)} )}
</div> </div>
@ -577,14 +637,20 @@ export default function GameLibraryTab({ data }: { data: any }) {
<p className="gl-search-results-title">Keine Spiele gefunden.</p> <p className="gl-search-results-title">Keine Spiele gefunden.</p>
) : ( ) : (
<div className="gl-game-list"> <div className="gl-game-list">
{filteredGames.map(g => ( {filteredGames.map((g, idx) => (
<div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}> <div key={g.appid ?? g.gogId ?? idx} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
{/* Platform indicator */}
<span className={`gl-game-platform-icon ${g.platform || 'steam'}`}>
{g.platform === 'gog' ? 'G' : 'S'}
</span>
{/* Cover/Icon */} {/* Cover/Icon */}
<div className="gl-game-visual"> <div className="gl-game-visual">
{g.igdb?.coverUrl ? ( {g.igdb?.coverUrl ? (
<img className="gl-game-cover" src={g.igdb.coverUrl} alt="" /> <img className="gl-game-cover" src={g.igdb.coverUrl} alt="" />
) : g.img_icon_url ? ( ) : g.img_icon_url && g.appid ? (
<img className="gl-game-icon" src={gameIconUrl(g.appid, g.img_icon_url)} alt="" /> <img className="gl-game-icon" src={gameIconUrl(g.appid, g.img_icon_url)} alt="" />
) : g.image ? (
<img className="gl-game-icon" src={g.image} alt="" />
) : ( ) : (
<div className="gl-game-icon" /> <div className="gl-game-icon" />
)} )}
@ -621,10 +687,10 @@ export default function GameLibraryTab({ data }: { data: any }) {
{/* ── Common mode ── */} {/* ── Common mode ── */}
{mode === 'common' && (() => { {mode === 'common' && (() => {
const selected = Array.from(selectedUsers) const selected = Array.from(selectedProfiles)
.map(id => getUser(id)) .map(id => getProfile(id))
.filter(Boolean) as UserSummary[]; .filter(Boolean) as ProfileSummary[];
const names = selected.map(u => u.personaName).join(', '); const names = selected.map(p => p.displayName).join(', ');
return ( return (
<> <>
<div className="gl-detail-header"> <div className="gl-detail-header">
@ -632,8 +698,8 @@ export default function GameLibraryTab({ data }: { data: any }) {
&#x2190; Zurueck &#x2190; Zurueck
</button> </button>
<div className="gl-detail-avatars"> <div className="gl-detail-avatars">
{selected.map(u => ( {selected.map(p => (
<img key={u.steamId} src={u.avatarUrl} alt={u.personaName} /> <img key={p.id} src={p.avatarUrl} alt={p.displayName} />
))} ))}
</div> </div>
<div className="gl-detail-info"> <div className="gl-detail-info">

View file

@ -9,20 +9,16 @@
margin: 0 auto; margin: 0 auto;
} }
/* ── Top bar ── */ /* ── Login Bar ── */
.gl-topbar { .gl-login-bar {
display: flex; display: flex;
align-items: center; gap: 10px;
gap: 12px; margin-bottom: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
} }
.gl-connect-btn { .gl-connect-btn {
background: #1b2838;
color: #c7d5e0; color: #c7d5e0;
border: 1px solid #2a475e;
padding: 10px 20px; padding: 10px 20px;
border-radius: var(--radius); border-radius: var(--radius);
cursor: pointer; cursor: pointer;
@ -31,71 +27,126 @@
white-space: nowrap; white-space: nowrap;
} }
.gl-connect-btn:hover { .gl-steam-btn {
background: #1b2838;
border: 1px solid #2a475e;
}
.gl-steam-btn:hover {
background: #2a475e; background: #2a475e;
color: #fff; color: #fff;
} }
/* ── User chips (top bar) ── */ .gl-gog-btn {
background: #2c1a4e;
border: 1px solid #4a2d7a;
color: #c7b3e8;
}
.gl-user-chips { .gl-gog-btn:hover {
background: #3d2566;
color: #fff;
}
/* ── Profile Chips ── */
.gl-profile-chips {
display: flex; display: flex;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
flex: 1; margin-bottom: 16px;
} }
.gl-user-chip { .gl-profile-chip {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
background: var(--bg-secondary); padding: 6px 12px;
padding: 4px 10px 4px 4px;
border-radius: 20px; border-radius: 20px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
cursor: pointer; cursor: pointer;
transition: all var(--transition); transition: all 0.2s;
border: 1px solid transparent;
} }
.gl-user-chip:hover { .gl-profile-chip.selected {
border-color: var(--accent); border-color: #e67e22;
background: rgba(230,126,34,0.1);
} }
.gl-user-chip.selected { .gl-profile-chip:hover {
border-color: var(--accent); background: rgba(255,255,255,0.1);
background: rgba(230, 126, 34, 0.12);
} }
.gl-user-chip-avatar { .gl-profile-chip-avatar {
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 50%; border-radius: 50%;
} }
.gl-user-chip-name { .gl-profile-chip-info {
font-size: 13px; display: flex;
color: var(--text-normal); flex-direction: column;
gap: 2px;
} }
.gl-user-chip-count { .gl-profile-chip-name {
font-size: 13px;
font-weight: 600;
color: #e0e0e0;
}
.gl-profile-chip-platforms {
display: flex;
gap: 4px;
}
.gl-profile-chip-count {
font-size: 11px; font-size: 11px;
color: var(--text-faint); color: #667;
} }
.gl-user-chip-refresh { /* ── Platform Badges ── */
background: none;
border: none; .gl-platform-badge {
color: var(--text-faint); font-size: 9px;
cursor: pointer; font-weight: 700;
font-size: 13px; padding: 1px 5px;
padding: 2px; border-radius: 3px;
margin-left: 2px; text-transform: uppercase;
line-height: 1;
transition: color var(--transition);
} }
.gl-user-chip-refresh:hover { .gl-platform-badge.steam {
color: var(--accent); background: rgba(27,40,56,0.8);
color: #66c0f4;
border: 1px solid #2a475e;
}
.gl-platform-badge.gog {
background: rgba(44,26,78,0.8);
color: #b388ff;
border: 1px solid #4a2d7a;
}
/* ── Game Platform Icon ── */
.gl-game-platform-icon {
font-size: 9px;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
margin-right: 6px;
flex-shrink: 0;
}
.gl-game-platform-icon.steam {
background: rgba(27,40,56,0.6);
color: #66c0f4;
}
.gl-game-platform-icon.gog {
background: rgba(44,26,78,0.6);
color: #b388ff;
} }
/* ── User cards grid (overview) ── */ /* ── User cards grid (overview) ── */
@ -147,6 +198,14 @@
color: var(--text-faint); color: var(--text-faint);
} }
/* ── Profile Cards ── */
.gl-profile-card-platforms {
display: flex;
gap: 6px;
margin-top: 4px;
}
/* ── Common games finder ── */ /* ── Common games finder ── */
.gl-common-finder { .gl-common-finder {
@ -338,6 +397,10 @@
.gl-detail-sub { .gl-detail-sub {
font-size: 13px; font-size: 13px;
color: var(--text-faint); color: var(--text-faint);
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
} }
.gl-refresh-btn { .gl-refresh-btn {
@ -354,6 +417,24 @@
color: var(--accent); color: var(--accent);
} }
/* ── Link GOG Button ── */
.gl-link-gog-btn {
background: rgba(168, 85, 247, 0.15);
color: #a855f7;
border: 1px solid rgba(168, 85, 247, 0.3);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.2s;
}
.gl-link-gog-btn:hover {
background: rgba(168, 85, 247, 0.25);
}
/* ── Loading ── */ /* ── Loading ── */
.gl-loading { .gl-loading {
@ -652,8 +733,7 @@
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
} }
.gl-topbar { .gl-login-bar {
flex-direction: column; flex-direction: column;
align-items: stretch;
} }
} }