add IGDB metadata enrichment, image proxy, rich game UI
server: metadata cache + fetch service, image proxy with disk cache, POST /igdb/metadata + GET /igdb/image/:id/:size routes. client: 002-metadata migration, enrichment wired into sync pipeline, extracted SyncProgress component, square cover icons in list items, genres/rating/summary on discover cards, tap-to-navigate on card stack, rich game detail with screenshots, trailer embed, developer info. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,6 +15,8 @@ node_modules
|
||||
|
||||
# IGDB cache (generated at runtime)
|
||||
server/data/igdb-cache.json
|
||||
server/data/igdb-metadata.json
|
||||
server/data/igdb-images/
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
|
||||
@@ -17,7 +17,7 @@ ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_HTML_DIR"
|
||||
rsync -avz --delete dist/ "$UBERSPACE_HOST:$REMOTE_HTML_DIR/"
|
||||
|
||||
echo "==> syncing server to $REMOTE_SERVICE_DIR/"
|
||||
ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_SERVICE_DIR/src $REMOTE_SERVICE_DIR/data/steam-icons"
|
||||
ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_SERVICE_DIR/src $REMOTE_SERVICE_DIR/data/steam-icons $REMOTE_SERVICE_DIR/data/igdb-images/thumb $REMOTE_SERVICE_DIR/data/igdb-images/cover_big $REMOTE_SERVICE_DIR/data/igdb-images/screenshot_med"
|
||||
rsync -avz --delete \
|
||||
server/src/ "$UBERSPACE_HOST:$REMOTE_SERVICE_DIR/src/"
|
||||
rsync -avz \
|
||||
|
||||
43
server/src/features/igdb/image-cache.ts
Normal file
43
server/src/features/igdb/image-cache.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const IMAGE_DIR = join(__dirname, "../../../data/igdb-images")
|
||||
|
||||
const ALLOWED_SIZES = ["thumb", "cover_big", "screenshot_med"] as const
|
||||
export type IgdbImageSize = (typeof ALLOWED_SIZES)[number]
|
||||
|
||||
export function isValidSize(size: string): size is IgdbImageSize {
|
||||
return (ALLOWED_SIZES as readonly string[]).includes(size)
|
||||
}
|
||||
|
||||
for (const size of ALLOWED_SIZES) {
|
||||
mkdirSync(join(IMAGE_DIR, size), { recursive: true })
|
||||
}
|
||||
|
||||
function imagePath(imageId: string, size: IgdbImageSize): string {
|
||||
return join(IMAGE_DIR, size, `${imageId}.jpg`)
|
||||
}
|
||||
|
||||
export function hasImage(imageId: string, size: IgdbImageSize): boolean {
|
||||
return existsSync(imagePath(imageId, size))
|
||||
}
|
||||
|
||||
export function readImage(imageId: string, size: IgdbImageSize): Uint8Array {
|
||||
return new Uint8Array(readFileSync(imagePath(imageId, size)))
|
||||
}
|
||||
|
||||
export async function fetchAndCacheImage(
|
||||
imageId: string,
|
||||
size: IgdbImageSize,
|
||||
): Promise<Uint8Array> {
|
||||
const url = `https://images.igdb.com/igdb/image/upload/t_${size}/${imageId}.jpg`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(`IGDB CDN returned ${res.status} for ${imageId}/${size}`)
|
||||
}
|
||||
const bytes = new Uint8Array(await res.arrayBuffer())
|
||||
writeFileSync(imagePath(imageId, size), bytes)
|
||||
return bytes
|
||||
}
|
||||
53
server/src/features/igdb/metadata-cache.ts
Normal file
53
server/src/features/igdb/metadata-cache.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const CACHE_FILE = join(__dirname, "../../data/igdb-metadata.json")
|
||||
|
||||
export interface IgdbMetadata {
|
||||
summary: string | null
|
||||
coverImageId: string | null
|
||||
screenshots: string[]
|
||||
videoIds: string[]
|
||||
genres: string[]
|
||||
aggregatedRating: number | null
|
||||
releaseDate: string | null
|
||||
developers: string[]
|
||||
}
|
||||
|
||||
const cache = new Map<string, IgdbMetadata>()
|
||||
|
||||
export function loadMetadataCache() {
|
||||
try {
|
||||
const data = readFileSync(CACHE_FILE, "utf-8")
|
||||
const entries: Record<string, IgdbMetadata> = JSON.parse(data)
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
cache.set(key, value)
|
||||
}
|
||||
console.log(`[IGDB] Metadata cache loaded: ${cache.size} entries`)
|
||||
} catch {
|
||||
console.log("[IGDB] No metadata cache file found, starting fresh")
|
||||
}
|
||||
}
|
||||
|
||||
function saveMetadataCache() {
|
||||
try {
|
||||
mkdirSync(join(__dirname, "../../data"), { recursive: true })
|
||||
const obj = Object.fromEntries(cache)
|
||||
writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2))
|
||||
} catch (err) {
|
||||
console.error("[IGDB] Failed to save metadata cache:", (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
export function getMetadata(canonicalId: string): IgdbMetadata | undefined {
|
||||
return cache.get(canonicalId)
|
||||
}
|
||||
|
||||
export function setMetadataBatch(entries: Map<string, IgdbMetadata>) {
|
||||
for (const [key, value] of entries) {
|
||||
cache.set(key, value)
|
||||
}
|
||||
saveMetadataCache()
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { zValidator } from "@hono/zod-validator"
|
||||
import { Hono } from "hono"
|
||||
import { z } from "zod"
|
||||
import { enrichGamesWithIgdb } from "./service.ts"
|
||||
import { fetchAndCacheImage, hasImage, isValidSize, readImage } from "./image-cache.ts"
|
||||
import { enrichGamesWithIgdb, fetchMetadataForGames } from "./service.ts"
|
||||
|
||||
const enrichInput = z.object({
|
||||
games: z.array(
|
||||
@@ -14,8 +15,41 @@ const enrichInput = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
export const igdbRouter = new Hono().post("/enrich", zValidator("json", enrichInput), async (c) => {
|
||||
const { games } = c.req.valid("json")
|
||||
const enriched = await enrichGamesWithIgdb(games)
|
||||
return c.json({ games: enriched })
|
||||
const metadataInput = z.object({
|
||||
canonicalIds: z.array(z.string()),
|
||||
})
|
||||
|
||||
export const igdbRouter = new Hono()
|
||||
.post("/enrich", zValidator("json", enrichInput), async (c) => {
|
||||
const { games } = c.req.valid("json")
|
||||
const enriched = await enrichGamesWithIgdb(games)
|
||||
return c.json({ games: enriched })
|
||||
})
|
||||
.post("/metadata", zValidator("json", metadataInput), async (c) => {
|
||||
const { canonicalIds } = c.req.valid("json")
|
||||
const metadataMap = await fetchMetadataForGames(canonicalIds)
|
||||
return c.json({ metadata: Object.fromEntries(metadataMap) })
|
||||
})
|
||||
.get("/image/:imageId/:size", async (c) => {
|
||||
const { imageId, size } = c.req.param()
|
||||
if (!/^[a-z0-9]+$/.test(imageId)) {
|
||||
return c.text("Invalid image ID", 400)
|
||||
}
|
||||
if (!isValidSize(size)) {
|
||||
return c.text("Invalid size", 400)
|
||||
}
|
||||
try {
|
||||
const bytes = hasImage(imageId, size)
|
||||
? readImage(imageId, size)
|
||||
: await fetchAndCacheImage(imageId, size)
|
||||
return new Response(bytes.buffer as ArrayBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "image/jpeg",
|
||||
"Cache-Control": "public, max-age=604800, immutable",
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error"
|
||||
return c.json({ error: message }, 502)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { env } from "../../shared/lib/env.ts"
|
||||
import { getCacheEntry, getCacheSize, saveCache, setCacheEntry } from "./cache.ts"
|
||||
import { type IgdbMetadata, getMetadata, setMetadataBatch } from "./metadata-cache.ts"
|
||||
|
||||
const CATEGORY_STEAM = 1
|
||||
const CATEGORY_GOG = 2
|
||||
@@ -152,3 +153,86 @@ export async function enrichGamesWithIgdb<T extends GameForEnrichment>(
|
||||
return game
|
||||
})
|
||||
}
|
||||
|
||||
interface IgdbGameResponse {
|
||||
id: number
|
||||
summary?: string
|
||||
cover?: { image_id: string }
|
||||
screenshots?: Array<{ image_id: string }>
|
||||
videos?: Array<{ video_id: string }>
|
||||
genres?: Array<{ name: string }>
|
||||
aggregated_rating?: number
|
||||
first_release_date?: number
|
||||
involved_companies?: Array<{
|
||||
developer: boolean
|
||||
company: { name: string }
|
||||
}>
|
||||
}
|
||||
|
||||
export async function fetchMetadataForGames(
|
||||
canonicalIds: string[],
|
||||
): Promise<Map<string, IgdbMetadata>> {
|
||||
const results = new Map<string, IgdbMetadata>()
|
||||
|
||||
if (!env.TWITCH_CLIENT_ID || !env.TWITCH_CLIENT_SECRET) {
|
||||
return results
|
||||
}
|
||||
|
||||
const uncached = canonicalIds.filter((id) => !getMetadata(id))
|
||||
if (uncached.length === 0) {
|
||||
for (const id of canonicalIds) {
|
||||
const cached = getMetadata(id)
|
||||
if (cached) results.set(id, cached)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
console.log(`[IGDB] Fetching metadata for ${uncached.length} games...`)
|
||||
|
||||
const BATCH_SIZE = 10
|
||||
const freshEntries = new Map<string, IgdbMetadata>()
|
||||
|
||||
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
||||
const batch = uncached.slice(i, i + BATCH_SIZE)
|
||||
const ids = batch.join(",")
|
||||
const query = `fields summary, cover.image_id, screenshots.image_id, videos.video_id, genres.name, aggregated_rating, first_release_date, involved_companies.company.name, involved_companies.developer; where id = (${ids}); limit ${BATCH_SIZE};`
|
||||
|
||||
const data = (await igdbRequest("/games", query)) as IgdbGameResponse[]
|
||||
|
||||
for (const game of data) {
|
||||
const developers = (game.involved_companies ?? [])
|
||||
.filter((ic) => ic.developer)
|
||||
.map((ic) => ic.company.name)
|
||||
|
||||
const metadata: IgdbMetadata = {
|
||||
summary: game.summary ?? null,
|
||||
coverImageId: game.cover?.image_id ?? null,
|
||||
screenshots: (game.screenshots ?? []).map((s) => s.image_id),
|
||||
videoIds: (game.videos ?? []).map((v) => v.video_id),
|
||||
genres: (game.genres ?? []).map((g) => g.name),
|
||||
aggregatedRating: game.aggregated_rating ?? null,
|
||||
releaseDate: game.first_release_date
|
||||
? new Date(game.first_release_date * 1000).toISOString().split("T")[0]
|
||||
: null,
|
||||
developers,
|
||||
}
|
||||
freshEntries.set(String(game.id), metadata)
|
||||
}
|
||||
|
||||
if (i + BATCH_SIZE < uncached.length) {
|
||||
await sleep(260)
|
||||
}
|
||||
}
|
||||
|
||||
if (freshEntries.size > 0) {
|
||||
setMetadataBatch(freshEntries)
|
||||
console.log(`[IGDB] Fetched metadata for ${freshEntries.size} games`)
|
||||
}
|
||||
|
||||
for (const id of canonicalIds) {
|
||||
const meta = freshEntries.get(id) ?? getMetadata(id)
|
||||
if (meta) results.set(id, meta)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import app from "./app.ts"
|
||||
import { loadCache } from "./features/igdb/cache.ts"
|
||||
import { loadMetadataCache } from "./features/igdb/metadata-cache.ts"
|
||||
import { env } from "./shared/lib/env.ts"
|
||||
|
||||
loadCache()
|
||||
loadMetadataCache()
|
||||
|
||||
console.log(`[server] listening on http://localhost:${env.PORT}`)
|
||||
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { useSwipeGesture } from "@/shared/hooks/use-swipe-gesture"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { GameDiscoverCard } from "./game-discover-card"
|
||||
|
||||
const apiBase = import.meta.env.BASE_URL.replace(/\/$/, "")
|
||||
|
||||
function getSteamHeaderImage(sourceId: string): string {
|
||||
return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg`
|
||||
}
|
||||
|
||||
function getPreloadUrl(game: Game): string | null {
|
||||
if (game.cover_image_id) return `${apiBase}/api/igdb/image/${game.cover_image_id}/cover_big`
|
||||
if (game.source === "steam") return getSteamHeaderImage(game.source_id)
|
||||
return null
|
||||
}
|
||||
|
||||
interface CardStackProps {
|
||||
games: Game[]
|
||||
onSwipeLeft: () => void
|
||||
@@ -14,19 +23,26 @@ interface CardStackProps {
|
||||
}
|
||||
|
||||
export function CardStack({ games, onSwipeLeft, onSwipeRight }: CardStackProps) {
|
||||
const navigate = useNavigate()
|
||||
const topGame = games[0]
|
||||
|
||||
const { offsetX, isDragging, handlers } = useSwipeGesture({
|
||||
threshold: 80,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
onTap: topGame
|
||||
? () => navigate({ to: "/games/$gameId", params: { gameId: topGame.id } })
|
||||
: undefined,
|
||||
})
|
||||
|
||||
// Preload 4th card's image so it's cached before entering the visible stack
|
||||
const preloadGame = games[3]
|
||||
useEffect(() => {
|
||||
if (preloadGame?.source !== "steam") return
|
||||
const url = preloadGame ? getPreloadUrl(preloadGame) : null
|
||||
if (!url) return
|
||||
const img = new Image()
|
||||
img.src = getSteamHeaderImage(preloadGame.source_id)
|
||||
}, [preloadGame?.source, preloadGame?.source_id])
|
||||
img.src = url
|
||||
}, [preloadGame])
|
||||
|
||||
const visibleCards = games.slice(0, 3)
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { formatPlaytime, gameStateColors } from "@/features/games/schema"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { Info } from "lucide-react"
|
||||
|
||||
interface GameDiscoverCardProps {
|
||||
game: Game
|
||||
@@ -12,10 +10,20 @@ function getSteamHeaderImage(sourceId: string): string {
|
||||
return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg`
|
||||
}
|
||||
|
||||
function parseJsonArray(text: string | null): string[] {
|
||||
if (!text) return []
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
|
||||
const navigate = useNavigate()
|
||||
const imageUrl = game.source === "steam" ? getSteamHeaderImage(game.source_id) : null
|
||||
const dotColor = game.game_state !== "not_set" ? gameStateColors[game.game_state] : null
|
||||
const genres = parseJsonArray(game.genres).slice(0, 3)
|
||||
const rating = game.aggregated_rating != null ? Math.round(game.aggregated_rating) : null
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-lg">
|
||||
@@ -31,18 +39,25 @@ export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
|
||||
{dotColor && (
|
||||
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto shrink-0 text-muted-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate({ to: "/games/$gameId", params: { gameId: game.id } })
|
||||
}}
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</button>
|
||||
{rating != null && (
|
||||
<Badge variant="outline" className="ml-auto shrink-0">
|
||||
{rating}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{game.playtime_hours > 0 && (
|
||||
{genres.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{genres.map((g) => (
|
||||
<Badge key={g} variant="secondary" className="text-[10px]">
|
||||
{g}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{game.summary && (
|
||||
<p className="mt-1.5 line-clamp-2 text-xs text-muted-foreground">{game.summary}</p>
|
||||
)}
|
||||
{!game.summary && game.playtime_hours > 0 && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{formatPlaytime(game.playtime_hours)} played
|
||||
</p>
|
||||
|
||||
@@ -8,10 +8,21 @@ import { FavoriteButton } from "./favorite-button"
|
||||
import { GameStateSelect } from "./game-state-select"
|
||||
import { StarRating } from "./star-rating"
|
||||
|
||||
const apiBase = import.meta.env.BASE_URL.replace(/\/$/, "")
|
||||
|
||||
function getSteamHeaderImage(sourceId: string): string {
|
||||
return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg`
|
||||
}
|
||||
|
||||
function parseJsonArray(text: string | null): string[] {
|
||||
if (!text) return []
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
interface GameDetailProps {
|
||||
gameId: string
|
||||
}
|
||||
@@ -30,6 +41,11 @@ export function GameDetail({ gameId }: GameDetailProps) {
|
||||
|
||||
function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => void }) {
|
||||
const imageUrl = game.source === "steam" ? getSteamHeaderImage(game.source_id) : null
|
||||
const genres = parseJsonArray(game.genres)
|
||||
const developers = parseJsonArray(game.developers)
|
||||
const screenshots = parseJsonArray(game.screenshots)
|
||||
const videoIds = parseJsonArray(game.video_ids)
|
||||
const rating = game.aggregated_rating != null ? Math.round(game.aggregated_rating) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -61,11 +77,32 @@ function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => voi
|
||||
<FavoriteButton gameId={game.id} isFavorite={game.is_favorite} onChange={onUpdate} />
|
||||
</div>
|
||||
|
||||
{genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{genres.map((g) => (
|
||||
<Badge key={g} variant="secondary">
|
||||
{g}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<StarRating gameId={game.id} rating={game.rating} onChange={onUpdate} />
|
||||
<div className="flex items-center gap-3">
|
||||
<StarRating gameId={game.id} rating={game.rating} onChange={onUpdate} />
|
||||
{rating != null && <Badge variant="outline">{rating}%</Badge>}
|
||||
</div>
|
||||
<GameStateSelect gameId={game.id} state={game.game_state} onChange={onUpdate} />
|
||||
</div>
|
||||
|
||||
{(developers.length > 0 || game.release_date) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{developers.length > 0 && <>by {developers.join(", ")}</>}
|
||||
{developers.length > 0 && game.release_date && " · "}
|
||||
{game.release_date && <>{game.release_date}</>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{game.url && (
|
||||
<a
|
||||
href={game.url}
|
||||
@@ -77,6 +114,41 @@ function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => voi
|
||||
{t("game.openStore")}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{game.summary && (
|
||||
<div>
|
||||
<h3 className="mb-1 text-sm font-semibold">{t("game.summary")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{game.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{screenshots.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold">{t("game.screenshots")}</h3>
|
||||
<div className="flex snap-x gap-2 overflow-x-auto pb-2">
|
||||
{screenshots.map((id) => (
|
||||
<img
|
||||
key={id}
|
||||
src={`${apiBase}/api/igdb/image/${id}/screenshot_med`}
|
||||
alt=""
|
||||
className="h-40 shrink-0 snap-start rounded-lg object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoIds.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold">{t("game.trailer")}</h3>
|
||||
<iframe
|
||||
src={`https://www.youtube-nocookie.com/embed/${videoIds[0]}`}
|
||||
className="aspect-video w-full rounded-lg"
|
||||
allowFullScreen
|
||||
title={t("game.trailer")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,15 @@ interface GameListItemProps {
|
||||
}
|
||||
|
||||
export function GameListItem({ game, onClick }: GameListItemProps) {
|
||||
const iconUrl =
|
||||
game.source === "steam" ? `${apiBase}/api/steam/icon/${game.source_id}` : undefined
|
||||
const coverUrl = game.cover_image_id
|
||||
? `${apiBase}/api/igdb/image/${game.cover_image_id}/thumb`
|
||||
: game.source === "steam"
|
||||
? `${apiBase}/api/steam/icon/${game.source_id}`
|
||||
: undefined
|
||||
|
||||
const imgClass = game.cover_image_id
|
||||
? "h-10 w-10 rounded object-cover"
|
||||
: "h-10 w-16 rounded object-cover"
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
@@ -20,10 +27,10 @@ export function GameListItem({ game, onClick }: GameListItemProps) {
|
||||
title={game.title}
|
||||
subtitle={game.playtime_hours > 0 ? formatPlaytime(game.playtime_hours) : undefined}
|
||||
media={
|
||||
iconUrl ? (
|
||||
<img src={iconUrl} alt="" className="h-10 w-16 rounded object-cover" />
|
||||
coverUrl ? (
|
||||
<img src={coverUrl} alt="" className={imgClass} />
|
||||
) : (
|
||||
<span className="flex h-10 w-16 items-center justify-center rounded bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
{game.source.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SyncProgress } from "@/shared/components/sync-progress"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { ListItem } from "@/shared/components/ui/list-item"
|
||||
@@ -13,7 +14,7 @@ export function GogSettings() {
|
||||
const gogConfig = useConfig<{ accessToken: string; refreshToken: string; userId: string }>("gog")
|
||||
const saveConfig = useSaveConfig()
|
||||
const lastSync = useConfig<string>("gog_last_sync")
|
||||
const { syncing, error, lastCount } = useSyncStore((s) => s.gog)
|
||||
const { syncing, error, lastCount, progress } = useSyncStore((s) => s.gog)
|
||||
const connectGog = useSyncStore((s) => s.connectGog)
|
||||
const syncGogGames = useSyncStore((s) => s.syncGogGames)
|
||||
|
||||
@@ -99,6 +100,8 @@ export function GogSettings() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<SyncProgress progress={progress} />
|
||||
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
|
||||
@@ -1,45 +1,12 @@
|
||||
import { SyncProgress } from "@/shared/components/sync-progress"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { ListItem } from "@/shared/components/ui/list-item"
|
||||
import { useConfig } from "@/shared/db/hooks"
|
||||
import { t } from "@/shared/i18n"
|
||||
import { useSyncStore } from "@/shared/stores/sync-store"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
function SyncProgress({ progress }: { progress: string | null }) {
|
||||
if (!progress) return null
|
||||
|
||||
if (progress === "fetching") {
|
||||
return (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("settings.syncFetching")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (progress.startsWith("saving:")) {
|
||||
const [, current, total] = progress.split(":")
|
||||
return (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("settings.syncSaving", { current, total })}
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${(Number(current) / Number(total)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function SteamSettings() {
|
||||
const savedConfig = useConfig<{ apiKey: string; steamId: string }>("steam")
|
||||
const { syncing, error, lastCount, progress } = useSyncStore((s) => s.steam)
|
||||
|
||||
53
src/shared/components/sync-progress.tsx
Normal file
53
src/shared/components/sync-progress.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { t } from "@/shared/i18n"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
export function SyncProgress({ progress }: { progress: string | null }) {
|
||||
if (!progress) return null
|
||||
|
||||
if (progress === "fetching") {
|
||||
return (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("settings.syncFetching")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (progress.startsWith("saving:")) {
|
||||
const [, current, total] = progress.split(":")
|
||||
return (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("settings.syncSaving", { current, total })}
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${(Number(current) / Number(total)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (progress === "enriching:ids") {
|
||||
return (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("settings.syncEnrichingIds")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (progress === "enriching:metadata") {
|
||||
return (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("settings.syncEnrichingMetadata")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PGlite } from "@electric-sql/pglite"
|
||||
import migrationSql from "./migrations/001-initial.sql?raw"
|
||||
import metadataSql from "./migrations/002-metadata.sql?raw"
|
||||
|
||||
let db: PGlite | null = null
|
||||
|
||||
@@ -8,5 +9,6 @@ export async function getDb(): Promise<PGlite> {
|
||||
|
||||
db = new PGlite("idb://whattoplay")
|
||||
await db.exec(migrationSql)
|
||||
await db.exec(metadataSql)
|
||||
return db
|
||||
}
|
||||
|
||||
9
src/shared/db/migrations/002-metadata.sql
Normal file
9
src/shared/db/migrations/002-metadata.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS summary TEXT;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS genres TEXT;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS aggregated_rating REAL;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS release_date TEXT;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS developers TEXT;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS cover_image_id TEXT;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS screenshots TEXT;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS video_ids TEXT;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS metadata_fetched_at TIMESTAMPTZ;
|
||||
@@ -25,6 +25,15 @@ export const gameSchema = z.object({
|
||||
rating: z.number().min(-1).max(10).default(-1),
|
||||
game_state: gameStateEnum.default("not_set"),
|
||||
is_favorite: z.boolean().default(false),
|
||||
summary: z.string().nullable().default(null),
|
||||
genres: z.string().nullable().default(null),
|
||||
aggregated_rating: z.number().nullable().default(null),
|
||||
release_date: z.string().nullable().default(null),
|
||||
developers: z.string().nullable().default(null),
|
||||
cover_image_id: z.string().nullable().default(null),
|
||||
screenshots: z.string().nullable().default(null),
|
||||
video_ids: z.string().nullable().default(null),
|
||||
metadata_fetched_at: z.string().nullable().default(null),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ interface SwipeGestureOptions {
|
||||
onSwipeLeft?: () => void
|
||||
/** Called when swiped past threshold to the right */
|
||||
onSwipeRight?: () => void
|
||||
/** Called when pointer up with minimal movement (tap) */
|
||||
onTap?: () => void
|
||||
}
|
||||
|
||||
interface SwipeGestureResult {
|
||||
@@ -23,6 +25,7 @@ export function useSwipeGesture({
|
||||
threshold = 80,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
onTap,
|
||||
}: SwipeGestureOptions): SwipeGestureResult {
|
||||
const [offsetX, setOffsetX] = useState(0)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
@@ -30,8 +33,10 @@ export function useSwipeGesture({
|
||||
const offsetRef = useRef(0)
|
||||
const onSwipeLeftRef = useRef(onSwipeLeft)
|
||||
const onSwipeRightRef = useRef(onSwipeRight)
|
||||
const onTapRef = useRef(onTap)
|
||||
onSwipeLeftRef.current = onSwipeLeft
|
||||
onSwipeRightRef.current = onSwipeRight
|
||||
onTapRef.current = onTap
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
startXRef.current = e.clientX
|
||||
@@ -58,6 +63,8 @@ export function useSwipeGesture({
|
||||
if (Math.abs(dx) > threshold) {
|
||||
if (dx > 0) onSwipeRightRef.current?.()
|
||||
else onSwipeLeftRef.current?.()
|
||||
} else if (Math.abs(dx) <= 5) {
|
||||
onTapRef.current?.()
|
||||
}
|
||||
setOffsetX(0)
|
||||
offsetRef.current = 0
|
||||
|
||||
@@ -60,10 +60,15 @@ export const de: Record<TranslationKey, string> = {
|
||||
"settings.syncFetching": "Spiele werden vom Server geladen...",
|
||||
"settings.syncSaving": "Speichere {current} / {total} Spiele...",
|
||||
"settings.syncSuccess": "{count} Spiele synchronisiert",
|
||||
"settings.syncEnrichingIds": "Spiel-IDs werden aufgelöst...",
|
||||
"settings.syncEnrichingMetadata": "Metadaten werden geladen...",
|
||||
|
||||
"game.lastPlayed": "Zuletzt gespielt",
|
||||
"game.openStore": "Im Store öffnen",
|
||||
"game.notFound": "Spiel nicht gefunden.",
|
||||
"game.summary": "Zusammenfassung",
|
||||
"game.screenshots": "Screenshots",
|
||||
"game.trailer": "Trailer",
|
||||
|
||||
"state.not_set": "Nicht gesetzt",
|
||||
"state.wishlisted": "Gewünscht",
|
||||
|
||||
@@ -63,11 +63,16 @@ export const en = {
|
||||
"settings.syncFetching": "Fetching games from server...",
|
||||
"settings.syncSaving": "Saving {current} / {total} games...",
|
||||
"settings.syncSuccess": "Synced {count} games",
|
||||
"settings.syncEnrichingIds": "Resolving game IDs...",
|
||||
"settings.syncEnrichingMetadata": "Fetching metadata...",
|
||||
|
||||
// Game detail
|
||||
"game.lastPlayed": "Last played",
|
||||
"game.openStore": "Open in Store",
|
||||
"game.notFound": "Game not found.",
|
||||
"game.summary": "Summary",
|
||||
"game.screenshots": "Screenshots",
|
||||
"game.trailer": "Trailer",
|
||||
|
||||
// Game states
|
||||
"state.not_set": "Not Set",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { TranslationKey } from "./en"
|
||||
|
||||
export const es: Partial<Record<TranslationKey, string>> = {
|
||||
// TODO: complete Spanish translations
|
||||
"settings.syncEnrichingIds": "Resolviendo IDs de juegos...",
|
||||
"settings.syncEnrichingMetadata": "Obteniendo metadatos...",
|
||||
"game.summary": "Resumen",
|
||||
"game.screenshots": "Capturas de pantalla",
|
||||
"game.trailer": "Tráiler",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { TranslationKey } from "./en"
|
||||
|
||||
export const fr: Partial<Record<TranslationKey, string>> = {
|
||||
// TODO: complete French translations
|
||||
"settings.syncEnrichingIds": "Résolution des IDs de jeux...",
|
||||
"settings.syncEnrichingMetadata": "Récupération des métadonnées...",
|
||||
"game.summary": "Résumé",
|
||||
"game.screenshots": "Captures d'écran",
|
||||
"game.trailer": "Bande-annonce",
|
||||
}
|
||||
|
||||
@@ -23,6 +23,80 @@ interface SyncStore {
|
||||
|
||||
const initial: SourceState = { syncing: false, error: null, lastCount: null, progress: null }
|
||||
|
||||
async function enrichAfterSync(
|
||||
source: "steam" | "gog",
|
||||
set: (partial: Partial<Record<"steam" | "gog", SourceState>>) => void,
|
||||
get: () => SyncStore,
|
||||
) {
|
||||
const db = await getDb()
|
||||
|
||||
// Step 1: resolve canonical_ids for games that don't have one
|
||||
try {
|
||||
const missing = await db.query<{ source: string; source_id: string }>(
|
||||
"SELECT source, source_id FROM games WHERE canonical_id IS NULL AND source = $1",
|
||||
[source],
|
||||
)
|
||||
if (missing.rows.length > 0) {
|
||||
set({ [source]: { ...get()[source], progress: "enriching:ids" } })
|
||||
const gamesToEnrich = missing.rows.map((r) => ({
|
||||
source: r.source,
|
||||
sourceId: r.source_id,
|
||||
}))
|
||||
const res = await api.igdb.enrich.$post({ json: { games: gamesToEnrich } })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
for (const g of data.games) {
|
||||
if (g.canonicalId) {
|
||||
await db.query(
|
||||
"UPDATE games SET canonical_id = $1 WHERE source = $2 AND source_id = $3",
|
||||
[g.canonicalId, g.source, g.sourceId],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[sync] canonical_id enrichment failed (non-fatal):", (err as Error).message)
|
||||
}
|
||||
|
||||
// Step 2: fetch metadata for games with canonical_id but no metadata yet
|
||||
try {
|
||||
const needsMeta = await db.query<{ canonical_id: string }>(
|
||||
"SELECT canonical_id FROM games WHERE canonical_id IS NOT NULL AND metadata_fetched_at IS NULL",
|
||||
)
|
||||
if (needsMeta.rows.length > 0) {
|
||||
set({ [source]: { ...get()[source], progress: "enriching:metadata" } })
|
||||
const canonicalIds = needsMeta.rows.map((r) => r.canonical_id)
|
||||
const res = await api.igdb.metadata.$post({ json: { canonicalIds } })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
for (const [canonicalId, meta] of Object.entries(data.metadata)) {
|
||||
await db.query(
|
||||
`UPDATE games SET
|
||||
summary = $1, cover_image_id = $2, screenshots = $3, video_ids = $4,
|
||||
genres = $5, aggregated_rating = $6, release_date = $7, developers = $8,
|
||||
metadata_fetched_at = NOW()
|
||||
WHERE canonical_id = $9`,
|
||||
[
|
||||
meta.summary,
|
||||
meta.coverImageId,
|
||||
JSON.stringify(meta.screenshots),
|
||||
JSON.stringify(meta.videoIds),
|
||||
JSON.stringify(meta.genres),
|
||||
meta.aggregatedRating,
|
||||
meta.releaseDate,
|
||||
JSON.stringify(meta.developers),
|
||||
canonicalId,
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[sync] metadata enrichment failed (non-fatal):", (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(key: string, value: unknown) {
|
||||
const db = await getDb()
|
||||
await db.query(
|
||||
@@ -32,9 +106,22 @@ async function saveConfig(key: string, value: unknown) {
|
||||
)
|
||||
}
|
||||
|
||||
type SyncGame = Pick<
|
||||
Game,
|
||||
| "id"
|
||||
| "title"
|
||||
| "source"
|
||||
| "source_id"
|
||||
| "platform"
|
||||
| "last_played"
|
||||
| "playtime_hours"
|
||||
| "url"
|
||||
| "canonical_id"
|
||||
>
|
||||
|
||||
async function saveGamesBySource(
|
||||
_source: string,
|
||||
games: Omit<Game, "rating" | "game_state" | "is_favorite">[],
|
||||
games: SyncGame[],
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
) {
|
||||
const db = await getDb()
|
||||
@@ -106,6 +193,7 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
|
||||
set({ steam: { ...get().steam, progress: `saving:${current}:${total}` } })
|
||||
})
|
||||
await saveConfig("steam_last_sync", new Date().toISOString())
|
||||
await enrichAfterSync("steam", set, get)
|
||||
set({ steam: { syncing: false, error: null, lastCount: data.count, progress: null } })
|
||||
} catch (err) {
|
||||
set({
|
||||
@@ -191,6 +279,7 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
|
||||
set({ gog: { ...get().gog, progress: `saving:${current}:${total}` } })
|
||||
})
|
||||
await saveConfig("gog_last_sync", new Date().toISOString())
|
||||
await enrichAfterSync("gog", set, get)
|
||||
set({ gog: { syncing: false, error: null, lastCount: data.count, progress: null } })
|
||||
} catch (err) {
|
||||
set({
|
||||
|
||||
Reference in New Issue
Block a user