diff --git a/server/assets-api.mjs b/server/assets-api.mjs new file mode 100644 index 0000000..aa21b5c --- /dev/null +++ b/server/assets-api.mjs @@ -0,0 +1,140 @@ +/** + * Assets API - Lazy-Caching von Game-Assets (Header-Images etc.) + * Beim ersten Abruf: Download von Steam CDN → Disk-Cache → Serve + * Danach: direkt von Disk + */ + +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const DATA_DIR = join(__dirname, "..", "data", "games"); + +const STEAM_CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps"; + +// 1x1 transparent PNG as fallback +const PLACEHOLDER_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualIQAAAABJRU5ErkJggg==", + "base64", +); + +function parseGameId(gameId) { + const match = gameId.match(/^(\w+)-(.+)$/); + if (!match) return null; + return { source: match[1], sourceId: match[2] }; +} + +function getCdnUrl(source, sourceId) { + if (source === "steam") { + return `${STEAM_CDN}/${sourceId}/header.jpg`; + } + return null; +} + +async function ensureGameDir(gameId) { + const dir = join(DATA_DIR, gameId); + await mkdir(dir, { recursive: true }); + return dir; +} + +async function writeMetaJson(gameDir, gameId, parsed) { + const metaPath = join(gameDir, "meta.json"); + if (existsSync(metaPath)) return; + + const meta = { + id: gameId, + source: parsed.source, + sourceId: parsed.sourceId, + headerUrl: getCdnUrl(parsed.source, parsed.sourceId), + }; + await writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf-8"); +} + +async function downloadAndCache(cdnUrl, cachePath) { + const response = await fetch(cdnUrl); + if (!response.ok) return false; + + const buffer = Buffer.from(await response.arrayBuffer()); + await writeFile(cachePath, buffer); + return true; +} + +/** + * Handler: GET /api/games/{gameId}/header + */ +export async function handleGameAsset(req, res) { + if (req.method !== "GET") { + res.statusCode = 405; + res.end("Method Not Allowed"); + return; + } + + const url = req.url ?? ""; + const match = url.match(/^\/api\/games\/([^/]+)\/header/); + if (!match) { + res.statusCode = 400; + res.end("Bad Request"); + return; + } + + const gameId = match[1]; + const parsed = parseGameId(gameId); + if (!parsed) { + res.statusCode = 400; + res.end("Invalid game ID format"); + return; + } + + const gameDir = join(DATA_DIR, gameId); + const cachePath = join(gameDir, "header.jpg"); + + // Serve from cache if available + if (existsSync(cachePath)) { + try { + const data = await readFile(cachePath); + res.statusCode = 200; + res.setHeader("Content-Type", "image/jpeg"); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.end(data); + return; + } catch { + // Fall through to download + } + } + + // Download from CDN + const cdnUrl = getCdnUrl(parsed.source, parsed.sourceId); + if (!cdnUrl) { + res.statusCode = 200; + res.setHeader("Content-Type", "image/png"); + res.end(PLACEHOLDER_PNG); + return; + } + + try { + await ensureGameDir(gameId); + const success = await downloadAndCache(cdnUrl, cachePath); + + if (success) { + // Write meta.json alongside + await writeMetaJson(gameDir, gameId, parsed).catch(() => {}); + + const data = await readFile(cachePath); + res.statusCode = 200; + res.setHeader("Content-Type", "image/jpeg"); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.end(data); + } else { + res.statusCode = 200; + res.setHeader("Content-Type", "image/png"); + res.end(PLACEHOLDER_PNG); + } + } catch { + res.statusCode = 200; + res.setHeader("Content-Type", "image/png"); + res.end(PLACEHOLDER_PNG); + } +} diff --git a/server/steam-backend.mjs b/server/steam-backend.mjs index f86ac22..b8169e5 100644 --- a/server/steam-backend.mjs +++ b/server/steam-backend.mjs @@ -38,13 +38,14 @@ export async function fetchSteamGames(apiKey, steamId) { const games = rawGames.map((game) => ({ id: `steam-${game.appid}`, title: game.name, + source: "steam", + sourceId: String(game.appid), platform: "PC", lastPlayed: game.rtime_last_played ? new Date(game.rtime_last_played * 1000).toISOString().slice(0, 10) : null, playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10, url: `https://store.steampowered.com/app/${game.appid}`, - source: "steam", })); return { diff --git a/src/pages/Discover/DiscoverPage.css b/src/pages/Discover/DiscoverPage.css index 3a3a0d9..1be95ef 100644 --- a/src/pages/Discover/DiscoverPage.css +++ b/src/pages/Discover/DiscoverPage.css @@ -114,11 +114,9 @@ height: 100%; background: #ffffff; border-radius: 20px; - padding: 1.75rem; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column; - justify-content: center; user-select: none; cursor: grab; overflow: hidden; @@ -132,18 +130,43 @@ pointer-events: none; } -/* Card content */ +/* Card image */ +.discover-card-image { + width: 100%; + height: 45%; + min-height: 120px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + overflow: hidden; + flex-shrink: 0; +} + +.discover-card-image img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* Card body */ +.discover-card-body { + flex: 1; + padding: 1.25rem; + display: flex; + flex-direction: column; + min-height: 0; +} + .discover-card-source { - margin-bottom: 1rem; + margin-bottom: 0.5rem; } .discover-card-title { - font-size: 1.5rem; + font-size: 1.25rem; font-weight: 700; - margin: 0 0 1.5rem; + margin: 0 0 auto; line-height: 1.2; display: -webkit-box; - -webkit-line-clamp: 3; + -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } @@ -151,24 +174,24 @@ .discover-card-details { display: flex; gap: 1.5rem; - margin-top: auto; + padding-top: 0.75rem; } .discover-card-detail { display: flex; flex-direction: column; - gap: 0.2rem; + gap: 0.15rem; } .discover-card-detail-label { - font-size: 0.75rem; + font-size: 0.7rem; color: #8e8e93; text-transform: uppercase; letter-spacing: 0.05em; } .discover-card-detail-value { - font-size: 1rem; + font-size: 0.9rem; font-weight: 600; } diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx index 9547b03..27bce0f 100644 --- a/src/pages/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -15,7 +15,14 @@ import { checkmarkOutline, refreshOutline, } from "ionicons/icons"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type SyntheticEvent, +} from "react"; import TinderCard from "react-tinder-card"; import { db, type Game } from "../../services/Database"; @@ -234,32 +241,49 @@ export default function DiscoverPage() { transform: `scale(${1 - stackPosition * 0.04}) translateY(${stackPosition * 12}px)`, }} > -
- - {game.source ?? "Unbekannt"} - +
+ {game.title}, + ) => { + e.currentTarget.style.display = + "none"; + }} + />
-

- {game.title} -

-
-
- - Spielzeit - - - {formatPlaytime( - game.playtimeHours, - )} - +
+
+ + {game.source ?? + "Unbekannt"} +
-
- - Zuletzt gespielt - - - {formatDate(game.lastPlayed)} - +

+ {game.title} +

+
+
+ + Spielzeit + + + {formatPlaytime( + game.playtimeHours, + )} + +
+
+ + Zuletzt gespielt + + + {formatDate( + game.lastPlayed, + )} + +
diff --git a/src/services/Database.ts b/src/services/Database.ts index 770bee5..da7e274 100644 --- a/src/services/Database.ts +++ b/src/services/Database.ts @@ -39,11 +39,13 @@ export interface DbConfig { export interface Game { id: string; title: string; + source?: string; + sourceId?: string; platform?: string; lastPlayed?: string | null; playtimeHours?: number; url?: string; - source?: string; + canonicalId?: string; } const DB_NAME = "whattoplay"; diff --git a/vite.config.ts b/vite.config.ts index 8850076..076ec3b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import { handleSteamRefresh, handleConfigLoad } from "./server/steam-api.mjs"; +import { handleGameAsset } from "./server/assets-api.mjs"; const apiMiddlewarePlugin = { name: "api-middleware", @@ -13,6 +14,9 @@ const apiMiddlewarePlugin = { if (url.startsWith("/api/config/load")) { return handleConfigLoad(req, res); } + if (url.startsWith("/api/games/")) { + return handleGameAsset(req, res); + } next(); }); },