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) => ({
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 {

View File

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

View File

@@ -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)`,
}}
>
<div className="discover-card-source">
<IonBadge color="medium">
{game.source ?? "Unbekannt"}
</IonBadge>
<div className="discover-card-image">
<img
src={`/api/games/${game.id}/header`}
alt={game.title}
onError={(
e: SyntheticEvent<HTMLImageElement>,
) => {
e.currentTarget.style.display =
"none";
}}
/>
</div>
<h2 className="discover-card-title">
{game.title}
</h2>
<div className="discover-card-details">
<div className="discover-card-detail">
<span className="discover-card-detail-label">
Spielzeit
</span>
<span className="discover-card-detail-value">
{formatPlaytime(
game.playtimeHours,
)}
</span>
<div className="discover-card-body">
<div className="discover-card-source">
<IonBadge color="medium">
{game.source ??
"Unbekannt"}
</IonBadge>
</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>
<h2 className="discover-card-title">
{game.title}
</h2>
<div className="discover-card-details">
<div className="discover-card-detail">
<span className="discover-card-detail-label">
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>

View File

@@ -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";

View File

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