add some assets

This commit is contained in:
2026-02-06 16:53:07 +01:00
parent adf3d13ab0
commit 030de7bfbe
6 changed files with 232 additions and 38 deletions

140
server/assets-api.mjs Normal file
View File

@@ -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);
}
}

View File

@@ -38,13 +38,14 @@ export async function fetchSteamGames(apiKey, steamId) {
const games = rawGames.map((game) => ({ const games = rawGames.map((game) => ({
id: `steam-${game.appid}`, id: `steam-${game.appid}`,
title: game.name, title: game.name,
source: "steam",
sourceId: String(game.appid),
platform: "PC", platform: "PC",
lastPlayed: game.rtime_last_played lastPlayed: game.rtime_last_played
? new Date(game.rtime_last_played * 1000).toISOString().slice(0, 10) ? new Date(game.rtime_last_played * 1000).toISOString().slice(0, 10)
: null, : null,
playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10, playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10,
url: `https://store.steampowered.com/app/${game.appid}`, url: `https://store.steampowered.com/app/${game.appid}`,
source: "steam",
})); }));
return { return {

View File

@@ -114,11 +114,9 @@
height: 100%; height: 100%;
background: #ffffff; background: #ffffff;
border-radius: 20px; border-radius: 20px;
padding: 1.75rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
user-select: none; user-select: none;
cursor: grab; cursor: grab;
overflow: hidden; overflow: hidden;
@@ -132,18 +130,43 @@
pointer-events: none; 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 { .discover-card-source {
margin-bottom: 1rem; margin-bottom: 0.5rem;
} }
.discover-card-title { .discover-card-title {
font-size: 1.5rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
margin: 0 0 1.5rem; margin: 0 0 auto;
line-height: 1.2; line-height: 1.2;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
@@ -151,24 +174,24 @@
.discover-card-details { .discover-card-details {
display: flex; display: flex;
gap: 1.5rem; gap: 1.5rem;
margin-top: auto; padding-top: 0.75rem;
} }
.discover-card-detail { .discover-card-detail {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.2rem; gap: 0.15rem;
} }
.discover-card-detail-label { .discover-card-detail-label {
font-size: 0.75rem; font-size: 0.7rem;
color: #8e8e93; color: #8e8e93;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.discover-card-detail-value { .discover-card-detail-value {
font-size: 1rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
} }

View File

@@ -15,7 +15,14 @@ import {
checkmarkOutline, checkmarkOutline,
refreshOutline, refreshOutline,
} from "ionicons/icons"; } 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 TinderCard from "react-tinder-card";
import { db, type Game } from "../../services/Database"; 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)`, transform: `scale(${1 - stackPosition * 0.04}) translateY(${stackPosition * 12}px)`,
}} }}
> >
<div className="discover-card-source"> <div className="discover-card-image">
<IonBadge color="medium"> <img
{game.source ?? "Unbekannt"} src={`/api/games/${game.id}/header`}
</IonBadge> alt={game.title}
onError={(
e: SyntheticEvent<HTMLImageElement>,
) => {
e.currentTarget.style.display =
"none";
}}
/>
</div> </div>
<h2 className="discover-card-title"> <div className="discover-card-body">
{game.title} <div className="discover-card-source">
</h2> <IonBadge color="medium">
<div className="discover-card-details"> {game.source ??
<div className="discover-card-detail"> "Unbekannt"}
<span className="discover-card-detail-label"> </IonBadge>
Spielzeit
</span>
<span className="discover-card-detail-value">
{formatPlaytime(
game.playtimeHours,
)}
</span>
</div> </div>
<div className="discover-card-detail"> <h2 className="discover-card-title">
<span className="discover-card-detail-label"> {game.title}
Zuletzt gespielt </h2>
</span> <div className="discover-card-details">
<span className="discover-card-detail-value"> <div className="discover-card-detail">
{formatDate(game.lastPlayed)} <span className="discover-card-detail-label">
</span> Spielzeit
</span>
<span className="discover-card-detail-value">
{formatPlaytime(
game.playtimeHours,
)}
</span>
</div>
<div className="discover-card-detail">
<span className="discover-card-detail-label">
Zuletzt gespielt
</span>
<span className="discover-card-detail-value">
{formatDate(
game.lastPlayed,
)}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -39,11 +39,13 @@ export interface DbConfig {
export interface Game { export interface Game {
id: string; id: string;
title: string; title: string;
source?: string;
sourceId?: string;
platform?: string; platform?: string;
lastPlayed?: string | null; lastPlayed?: string | null;
playtimeHours?: number; playtimeHours?: number;
url?: string; url?: string;
source?: string; canonicalId?: string;
} }
const DB_NAME = "whattoplay"; const DB_NAME = "whattoplay";

View File

@@ -1,6 +1,7 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { handleSteamRefresh, handleConfigLoad } from "./server/steam-api.mjs"; import { handleSteamRefresh, handleConfigLoad } from "./server/steam-api.mjs";
import { handleGameAsset } from "./server/assets-api.mjs";
const apiMiddlewarePlugin = { const apiMiddlewarePlugin = {
name: "api-middleware", name: "api-middleware",
@@ -13,6 +14,9 @@ const apiMiddlewarePlugin = {
if (url.startsWith("/api/config/load")) { if (url.startsWith("/api/config/load")) {
return handleConfigLoad(req, res); return handleConfigLoad(req, res);
} }
if (url.startsWith("/api/games/")) {
return handleGameAsset(req, res);
}
next(); next();
}); });
}, },