IGDB-Integration für Game Library + Electron Update-Button im Version-Modal
- Neues IGDB-Service-Modul (igdb.ts): Token-Management, Rate-Limiting, Game-Lookup per Steam-AppID/Name, Batch-Enrichment mit In-Memory-Cache - Server: 2 neue Routes (/igdb/enrich/:steamId, /igdb/game/:appid), Auto-Enrichment bei Steam-Login und Refresh - Frontend: IGDB-Cover, Genre-Tags, Plattform-Badges, farbcodiertes Rating, IGDB-Anreichern-Button - Version-Modal: Update-Button für Electron-App (checkForUpdates/installUpdate), Desktop-App-Version anzeigen - CSS: Styles für IGDB-Elemente und Update-UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
aec1142bff
commit
b404c20eca
6 changed files with 785 additions and 25 deletions
|
|
@ -26,6 +26,8 @@ export function registerTab(pluginName: string, component: React.FC<{ data: any
|
|||
tabComponents[pluginName] = component;
|
||||
}
|
||||
|
||||
type UpdateStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'upToDate' | 'error';
|
||||
|
||||
export default function App() {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
|
||||
|
|
@ -37,6 +39,12 @@ export default function App() {
|
|||
};
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
const [pluginData, setPluginData] = useState<Record<string, any>>({});
|
||||
|
||||
// Electron auto-update state
|
||||
const isElectron = !!(window as any).electronAPI?.isElectron;
|
||||
const electronVersion = isElectron ? (window as any).electronAPI.version : null;
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
||||
const [updateError, setUpdateError] = useState<string>('');
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
// Request notification permission
|
||||
|
|
@ -46,6 +54,19 @@ export default function App() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Electron auto-update listeners
|
||||
useEffect(() => {
|
||||
if (!isElectron) return;
|
||||
const api = (window as any).electronAPI;
|
||||
api.onUpdateAvailable(() => setUpdateStatus('downloading'));
|
||||
api.onUpdateReady(() => setUpdateStatus('ready'));
|
||||
api.onUpdateNotAvailable(() => setUpdateStatus('upToDate'));
|
||||
api.onUpdateError((msg: string) => {
|
||||
setUpdateStatus('error');
|
||||
setUpdateError(msg || 'Unbekannter Fehler');
|
||||
});
|
||||
}, [isElectron]);
|
||||
|
||||
// Fetch plugin list
|
||||
useEffect(() => {
|
||||
fetch('/api/plugins')
|
||||
|
|
@ -184,9 +205,15 @@ export default function App() {
|
|||
</div>
|
||||
<div className="hub-version-modal-body">
|
||||
<div className="hub-version-modal-row">
|
||||
<span className="hub-version-modal-label">Version</span>
|
||||
<span className="hub-version-modal-label">Hub-Version</span>
|
||||
<span className="hub-version-modal-value">v{version}</span>
|
||||
</div>
|
||||
{isElectron && (
|
||||
<div className="hub-version-modal-row">
|
||||
<span className="hub-version-modal-label">Desktop-App</span>
|
||||
<span className="hub-version-modal-value">v{electronVersion}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="hub-version-modal-row">
|
||||
<span className="hub-version-modal-label">Server</span>
|
||||
<span className="hub-version-modal-value">
|
||||
|
|
@ -194,6 +221,65 @@ export default function App() {
|
|||
{connected ? 'Verbunden' : 'Getrennt'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isElectron && (
|
||||
<div className="hub-version-modal-update">
|
||||
{updateStatus === 'idle' && (
|
||||
<button
|
||||
className="hub-version-modal-update-btn"
|
||||
onClick={() => {
|
||||
setUpdateStatus('checking');
|
||||
setUpdateError('');
|
||||
(window as any).electronAPI.checkForUpdates();
|
||||
}}
|
||||
>
|
||||
{'\u{1F504}'} Nach Updates suchen
|
||||
</button>
|
||||
)}
|
||||
{updateStatus === 'checking' && (
|
||||
<div className="hub-version-modal-update-status">
|
||||
<span className="hub-update-spinner" />
|
||||
Suche nach Updates…
|
||||
</div>
|
||||
)}
|
||||
{updateStatus === 'downloading' && (
|
||||
<div className="hub-version-modal-update-status">
|
||||
<span className="hub-update-spinner" />
|
||||
Update wird heruntergeladen…
|
||||
</div>
|
||||
)}
|
||||
{updateStatus === 'ready' && (
|
||||
<button
|
||||
className="hub-version-modal-update-btn ready"
|
||||
onClick={() => (window as any).electronAPI.installUpdate()}
|
||||
>
|
||||
{'\u2705'} Jetzt installieren & neu starten
|
||||
</button>
|
||||
)}
|
||||
{updateStatus === 'upToDate' && (
|
||||
<div className="hub-version-modal-update-status success">
|
||||
{'\u2705'} App ist aktuell
|
||||
<button
|
||||
className="hub-version-modal-update-retry"
|
||||
onClick={() => setUpdateStatus('idle')}
|
||||
>
|
||||
Erneut prüfen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{updateStatus === 'error' && (
|
||||
<div className="hub-version-modal-update-status error">
|
||||
{'\u274C'} {updateError}
|
||||
<button
|
||||
className="hub-version-modal-update-retry"
|
||||
onClick={() => setUpdateStatus('idle')}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,17 +13,31 @@ interface UserSummary {
|
|||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface IgdbData {
|
||||
igdbId: number;
|
||||
name: string;
|
||||
coverUrl: string | null;
|
||||
genres: string[];
|
||||
platforms: string[];
|
||||
rating: number | null;
|
||||
firstReleaseDate: string | null;
|
||||
summary: string | null;
|
||||
igdbUrl: string | null;
|
||||
}
|
||||
|
||||
interface SteamGame {
|
||||
appid: number;
|
||||
name: string;
|
||||
playtime_forever: number;
|
||||
img_icon_url: string;
|
||||
igdb?: IgdbData;
|
||||
}
|
||||
|
||||
interface CommonGame {
|
||||
appid: number;
|
||||
name: string;
|
||||
img_icon_url: string;
|
||||
igdb?: IgdbData;
|
||||
owners: Array<{ steamId: string; personaName: string; playtime_forever: number }>;
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +92,7 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [enriching, setEnriching] = useState<string | null>(null);
|
||||
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const filterInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -153,6 +168,21 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
}
|
||||
}, [fetchUsers, mode, selectedUser, viewUser]);
|
||||
|
||||
// ── Enrich user library with IGDB data ──
|
||||
const enrichUser = useCallback(async (steamId: string) => {
|
||||
setEnriching(steamId);
|
||||
try {
|
||||
const resp = await fetch(`/api/game-library/igdb/enrich/${steamId}`);
|
||||
if (resp.ok) {
|
||||
// Reload user's game data to get IGDB info
|
||||
if (mode === 'user' && selectedUser === steamId) {
|
||||
viewUser(steamId);
|
||||
}
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
finally { setEnriching(null); }
|
||||
}, [mode, selectedUser, viewUser]);
|
||||
|
||||
// ── Toggle user selection for common games ──
|
||||
const toggleCommonUser = useCallback((steamId: string) => {
|
||||
setSelectedUsers(prev => {
|
||||
|
|
@ -408,6 +438,14 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
>
|
||||
↻
|
||||
</button>
|
||||
<button
|
||||
className="gl-enrich-btn"
|
||||
onClick={() => enrichUser(selectedUser!)}
|
||||
disabled={enriching === selectedUser}
|
||||
title="Mit IGDB-Daten anreichern"
|
||||
>
|
||||
{enriching === selectedUser ? '\u23F3' : '\uD83C\uDF10'} IGDB
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -431,20 +469,44 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
) : (
|
||||
<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 key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
|
||||
{/* Cover/Icon */}
|
||||
<div className="gl-game-visual">
|
||||
{g.igdb?.coverUrl ? (
|
||||
<img className="gl-game-cover" src={g.igdb.coverUrl} alt="" />
|
||||
) : g.img_icon_url ? (
|
||||
<img className="gl-game-icon" src={gameIconUrl(g.appid, g.img_icon_url)} alt="" />
|
||||
) : (
|
||||
<div className="gl-game-icon" />
|
||||
)}
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div className="gl-game-info">
|
||||
<span className="gl-game-name">{g.name}</span>
|
||||
{g.igdb?.genres && g.igdb.genres.length > 0 && (
|
||||
<div className="gl-game-genres">
|
||||
{g.igdb.genres.slice(0, 3).map(genre => (
|
||||
<span key={genre} className="gl-genre-tag">{genre}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{g.igdb?.platforms && g.igdb.platforms.filter(p => !p.includes('Windows') && !p.includes('Linux') && !p.includes('Mac')).length > 0 && (
|
||||
<div className="gl-game-platforms">
|
||||
Auch auf: {g.igdb.platforms.filter(p => !p.includes('Windows') && !p.includes('Linux') && !p.includes('Mac')).slice(0, 3).map(p => (
|
||||
<span key={p} className="gl-platform-tag">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Right side: rating + playtime */}
|
||||
<div className="gl-game-meta">
|
||||
{g.igdb?.rating != null && (
|
||||
<span className={`gl-game-rating ${g.igdb.rating >= 75 ? 'high' : g.igdb.rating >= 50 ? 'mid' : 'low'}`}>
|
||||
{Math.round(g.igdb.rating)}
|
||||
</span>
|
||||
)}
|
||||
<span className="gl-game-playtime">{formatPlaytime(g.playtime_forever)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -494,16 +556,16 @@ export default function GameLibraryTab({ data }: { data: any }) {
|
|||
</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" />
|
||||
)}
|
||||
<div key={g.appid} className={`gl-game-item ${g.igdb ? 'enriched' : ''}`}>
|
||||
<div className="gl-game-visual">
|
||||
{g.igdb?.coverUrl ? (
|
||||
<img className="gl-game-cover" src={g.igdb.coverUrl} alt="" />
|
||||
) : g.img_icon_url ? (
|
||||
<img className="gl-game-icon" src={gameIconUrl(g.appid, g.img_icon_url)} alt="" />
|
||||
) : (
|
||||
<div className="gl-game-icon" />
|
||||
)}
|
||||
</div>
|
||||
<span className="gl-game-name">{g.name}</span>
|
||||
<div className="gl-common-playtimes">
|
||||
{g.owners.map(o => (
|
||||
|
|
|
|||
|
|
@ -447,6 +447,112 @@
|
|||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── IGDB Enriched game items ── */
|
||||
|
||||
.gl-game-item.enriched {
|
||||
align-items: flex-start;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.gl-game-visual {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gl-game-cover {
|
||||
width: 45px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gl-game-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gl-game-genres {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gl-genre-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(230, 126, 34, 0.15);
|
||||
color: #e67e22;
|
||||
}
|
||||
|
||||
.gl-game-platforms {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.gl-platform-tag {
|
||||
margin-left: 4px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.gl-game-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gl-game-rating {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gl-game-rating.high {
|
||||
background: rgba(46, 204, 113, 0.2);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.gl-game-rating.mid {
|
||||
background: rgba(241, 196, 15, 0.2);
|
||||
color: #f1c40f;
|
||||
}
|
||||
|
||||
.gl-game-rating.low {
|
||||
background: rgba(231, 76, 60, 0.2);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.gl-enrich-btn {
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
border: 1px solid rgba(52, 152, 219, 0.3);
|
||||
color: #3498db;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.gl-enrich-btn:hover:not(:disabled) {
|
||||
background: rgba(52, 152, 219, 0.25);
|
||||
}
|
||||
|
||||
.gl-enrich-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
|
|
|||
|
|
@ -443,6 +443,82 @@ html, body {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Update Section in Version Modal ── */
|
||||
.hub-version-modal-update {
|
||||
margin-top: 4px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.hub-version-modal-update-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-normal);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.hub-version-modal-update-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
.hub-version-modal-update-btn.ready {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: #2ecc71;
|
||||
}
|
||||
.hub-version-modal-update-btn.ready:hover {
|
||||
background: rgba(46, 204, 113, 0.25);
|
||||
}
|
||||
.hub-version-modal-update-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hub-version-modal-update-status.success {
|
||||
color: #2ecc71;
|
||||
}
|
||||
.hub-version-modal-update-status.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
.hub-version-modal-update-retry {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 2px 4px;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.hub-version-modal-update-retry:hover {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
@keyframes hub-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hub-update-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: hub-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Main Content Area ── */
|
||||
.hub-content {
|
||||
flex: 1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue