normalize project structure: src/client + src/server + src/shared, standardize biome to lineWidth 80

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 23:00:43 +01:00
parent 6e9cd45671
commit 1b5cff78e2
106 changed files with 815 additions and 547 deletions

View File

@@ -3,6 +3,7 @@ VITE_API_URL=
# Server
PORT=3001
ALLOWED_ORIGIN=https://serve.uber.space
ALLOWED_ORIGIN=http://localhost:5173
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
DATABASE_URL=postgresql://localhost:5432/whattoplay

9
.gitignore vendored
View File

@@ -3,7 +3,6 @@ node_modules
.claude
# Override global gitignore exclusions
!/server/
!/src/
!**/lib/
@@ -14,9 +13,7 @@ node_modules
!.env.example
# IGDB cache (generated at runtime)
server/data/igdb-cache.json
server/data/igdb-metadata.json
server/data/igdb-images/
src/server/data/
# Build outputs
dist
@@ -24,9 +21,13 @@ build
.vite
coverage
# Database
drizzle/
# Logs
*.log
npm-debug.log*
# bun
bun.lock
.mise.local.toml

View File

@@ -1,3 +1,2 @@
[tools]
node = "22"
bun = "latest"
bun = "1.3.0"

5
.vscode/tasks.json vendored
View File

@@ -5,10 +5,7 @@
"label": "vite: dev server",
"type": "shell",
"command": "npm",
"args": [
"run",
"dev"
],
"args": ["run", "dev"],
"isBackground": true,
"problemMatcher": [],
"group": "build"

55
CLAUDE.md Normal file
View File

@@ -0,0 +1,55 @@
# whattoplay — Game Discovery App
Game recommendation and collection management tool with Steam/GOG integration and IGDB metadata.
## Stack
- **Frontend:** React 19, Vite, Tailwind CSS 4, TanStack Router (file-based), Zustand, PGlite
- **Backend:** Hono (Bun), Drizzle ORM, PostgreSQL, Twitch/IGDB API
- **Linting:** Biome (tabs, 80 chars, double quotes)
## Project Structure
```
src/
├── client/ ← React PWA (features/, routes/, shared/)
├── server/ ← Hono API (features/, shared/)
└── shared/ ← isomorphic code
```
## Local Development
```bash
bun install
bun run dev # frontend (Vite)
bun run dev:server # backend (Bun --watch)
bun run dev:all # both
```
## Deployment
```bash
./deploy.sh
```
Deploys to Uberspace (`serve.uber.space`):
- Frontend → `/var/www/virtual/serve/html/whattoplay/`
- Backend → `~/services/whattoplay/` (systemd: `whattoplay.service`, port 3001)
- Route: `/whattoplay/api/*` → port 3001 (prefix removed)
## Environment Variables
See `.env.example`:
- `DATABASE_URL` — PostgreSQL connection string
- `PORT` — server port (default 3001)
- `ALLOWED_ORIGIN` — CORS origin
- `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET` — Twitch API credentials (for IGDB)
## Database
PostgreSQL via Drizzle ORM. Migrations in `drizzle/`.
```bash
bun run db:generate
bun run db:migrate
```

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"files": {
"ignore": ["src/routeTree.gen.ts"]
"ignore": ["src/client/routeTree.gen.ts", "drizzle/"]
},
"organizeImports": {
"enabled": true
@@ -9,7 +9,7 @@
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 100
"lineWidth": 80
},
"linter": {
"enabled": true,

View File

@@ -16,16 +16,20 @@ echo "==> syncing frontend to $REMOTE_HTML_DIR/"
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/drizzle $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"
echo "==> syncing project to $REMOTE_SERVICE_DIR/"
ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_SERVICE_DIR"
rsync -avz --delete \
server/src/ "$UBERSPACE_HOST:$REMOTE_SERVICE_DIR/src/"
rsync -avz --delete \
server/drizzle/ "$UBERSPACE_HOST:$REMOTE_SERVICE_DIR/drizzle/"
rsync -avz \
server/package.json server/drizzle.config.ts "$UBERSPACE_HOST:$REMOTE_SERVICE_DIR/"
--exclude='.git/' \
--exclude='.env' \
--exclude='dist/' \
--exclude='node_modules/' \
--exclude='.DS_Store' \
./ "$UBERSPACE_HOST:$REMOTE_SERVICE_DIR/"
echo "==> installing server dependencies..."
echo "==> ensuring data directories exist..."
ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_SERVICE_DIR/src/server/data/steam-icons $REMOTE_SERVICE_DIR/src/server/data/igdb-images/thumb $REMOTE_SERVICE_DIR/src/server/data/igdb-images/cover_big $REMOTE_SERVICE_DIR/src/server/data/igdb-images/screenshot_med"
echo "==> installing dependencies..."
ssh "$UBERSPACE_HOST" "cd $REMOTE_SERVICE_DIR && bun install"
echo "==> creating .env if missing..."
@@ -48,17 +52,18 @@ ssh "$UBERSPACE_HOST" "uberspace web backend add /whattoplay/api port $PORT --re
echo "==> setting up systemd service..."
ssh "$UBERSPACE_HOST" "mkdir -p ~/.config/systemd/user"
ssh "$UBERSPACE_HOST" "cat > ~/.config/systemd/user/$SERVICE_NAME.service" <<'UNIT'
ssh "$UBERSPACE_HOST" "cat > ~/.config/systemd/user/$SERVICE_NAME.service" <<UNIT
[Unit]
Description=WhatToPlay API server
After=network.target
After=network.target postgresql.service
[Service]
ExecStart=/usr/bin/bun run src/index.ts
WorkingDirectory=%h/services/whattoplay
Restart=always
Type=simple
WorkingDirectory=/home/serve/services/whattoplay
EnvironmentFile=/home/serve/services/whattoplay/.env
ExecStart=/usr/bin/bun run src/server/index.ts
Restart=on-failure
RestartSec=5
EnvironmentFile=%h/services/whattoplay/.env
[Install]
WantedBy=default.target

View File

@@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit"
export default defineConfig({
dialect: "postgresql",
schema: "./src/shared/db/schema/*",
schema: "./src/server/shared/db/schema/*",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_URL ?? "",

View File

@@ -11,6 +11,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

View File

@@ -5,24 +5,29 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:server": "cd server && bun run dev",
"dev:server": "bun --watch src/server/index.ts",
"dev:all": "bun run dev & bun run dev:server",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"start": "bun run src/server/index.ts",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"test": "vitest run",
"db:generate": "bunx drizzle-kit generate",
"db:migrate": "bunx drizzle-kit migrate",
"prepare": "simple-git-hooks"
},
"dependencies": {
"@biomejs/cli-darwin-arm64": "^2.4.5",
"@electric-sql/pglite": "^0.2.17",
"@hono/zod-validator": "^0.5.0",
"@tanstack/react-form": "^1.0.0",
"@tanstack/react-router": "^1.114.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"hono": "^4.7.0",
"lucide-react": "^0.474.0",
"postgres": "^3.4.8",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -35,10 +40,12 @@
"@tailwindcss/vite": "^4.0.0",
"@tanstack/router-plugin": "^1.114.0",
"@testing-library/react": "^16.0.0",
"@types/bun": "^1.3.10",
"@types/node": "^25.3.3",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.0",
"drizzle-kit": "^0.31.9",
"lint-staged": "^15.0.0",
"simple-git-hooks": "^2.11.0",
"tailwindcss": "^4.0.0",

View File

@@ -1,5 +0,0 @@
PORT=3001
ALLOWED_ORIGIN=http://localhost:5173
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
DATABASE_URL=postgresql://localhost:5432/whattoplay

View File

@@ -1,20 +0,0 @@
CREATE TABLE "igdb_metadata" (
"canonical_id" text PRIMARY KEY NOT NULL,
"summary" text,
"cover_image_id" text,
"screenshots" jsonb,
"video_ids" jsonb,
"genres" jsonb,
"aggregated_rating" real,
"release_date" text,
"developers" jsonb,
"fetched_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "igdb_resolutions" (
"cache_key" text PRIMARY KEY NOT NULL,
"source" text NOT NULL,
"source_id" text NOT NULL,
"igdb_id" integer,
"resolved_at" timestamp with time zone DEFAULT now()
);

View File

@@ -1,137 +0,0 @@
{
"id": "cb778c12-a1c7-4483-ae9a-cfa44e7cab62",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.igdb_metadata": {
"name": "igdb_metadata",
"schema": "",
"columns": {
"canonical_id": {
"name": "canonical_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cover_image_id": {
"name": "cover_image_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"screenshots": {
"name": "screenshots",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"video_ids": {
"name": "video_ids",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"genres": {
"name": "genres",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"aggregated_rating": {
"name": "aggregated_rating",
"type": "real",
"primaryKey": false,
"notNull": false
},
"release_date": {
"name": "release_date",
"type": "text",
"primaryKey": false,
"notNull": false
},
"developers": {
"name": "developers",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"fetched_at": {
"name": "fetched_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.igdb_resolutions": {
"name": "igdb_resolutions",
"schema": "",
"columns": {
"cache_key": {
"name": "cache_key",
"type": "text",
"primaryKey": true,
"notNull": true
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_id": {
"name": "source_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"igdb_id": {
"name": "igdb_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"resolved_at": {
"name": "resolved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1772543801794,
"tag": "0000_heavy_lila_cheney",
"breakpoints": true
}
]
}

View File

@@ -1,23 +0,0 @@
{
"name": "whattoplay-server",
"private": true,
"version": "2026.03.02",
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"db:generate": "bunx drizzle-kit generate",
"db:migrate": "bunx drizzle-kit migrate"
},
"dependencies": {
"@hono/zod-validator": "^0.5.0",
"drizzle-orm": "^0.45.1",
"hono": "^4.7.0",
"postgres": "^3.4.8",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/bun": "^1.3.10",
"drizzle-kit": "^0.31.9"
}
}

View File

@@ -1,106 +0,0 @@
/**
* One-time migration: import existing igdb-cache.json and igdb-metadata.json
* into PostgreSQL. Run with: DATABASE_URL=... bun server/scripts/migrate-json-to-pg.ts
*/
import { readFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import postgres from "postgres"
const __dirname = dirname(fileURLToPath(import.meta.url))
const DATA_DIR = join(__dirname, "../src/data")
const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) {
console.error("DATABASE_URL is required")
process.exit(1)
}
const sql = postgres(DATABASE_URL)
const BATCH_SIZE = 500
async function migrateResolutions() {
const file = join(DATA_DIR, "igdb-cache.json")
let data: Record<string, { igdbId: number | null }>
try {
data = JSON.parse(readFileSync(file, "utf-8"))
} catch {
console.log("[migrate] No igdb-cache.json found, skipping resolutions")
return
}
const entries = Object.entries(data)
console.log(`[migrate] Importing ${entries.length} resolution entries...`)
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = entries.slice(i, i + BATCH_SIZE)
const values = batch.map(([key, val]) => {
const [source, ...rest] = key.split(":")
const sourceId = rest.join(":")
return { cache_key: key, source, source_id: sourceId, igdb_id: val.igdbId }
})
await sql`
INSERT INTO igdb_resolutions ${sql(values, "cache_key", "source", "source_id", "igdb_id")}
ON CONFLICT (cache_key) DO NOTHING
`
console.log(
`[migrate] Resolutions: ${Math.min(i + BATCH_SIZE, entries.length)}/${entries.length}`,
)
}
}
async function migrateMetadata() {
const file = join(DATA_DIR, "igdb-metadata.json")
let data: Record<
string,
{
summary: string | null
coverImageId: string | null
screenshots: string[]
videoIds: string[]
genres: string[]
aggregatedRating: number | null
releaseDate: string | null
developers: string[]
}
>
try {
data = JSON.parse(readFileSync(file, "utf-8"))
} catch {
console.log("[migrate] No igdb-metadata.json found, skipping metadata")
return
}
const entries = Object.entries(data)
console.log(`[migrate] Importing ${entries.length} metadata entries...`)
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = entries.slice(i, i + BATCH_SIZE)
const values = batch.map(([canonicalId, meta]) => ({
canonical_id: canonicalId,
summary: meta.summary,
cover_image_id: meta.coverImageId,
screenshots: JSON.stringify(meta.screenshots),
video_ids: JSON.stringify(meta.videoIds),
genres: JSON.stringify(meta.genres),
aggregated_rating: meta.aggregatedRating,
release_date: meta.releaseDate,
developers: JSON.stringify(meta.developers),
}))
await sql`
INSERT INTO igdb_metadata ${sql(values, "canonical_id", "summary", "cover_image_id", "screenshots", "video_ids", "genres", "aggregated_rating", "release_date", "developers")}
ON CONFLICT (canonical_id) DO NOTHING
`
console.log(`[migrate] Metadata: ${Math.min(i + BATCH_SIZE, entries.length)}/${entries.length}`)
}
}
await migrateResolutions()
await migrateMetadata()
console.log("[migrate] Done!")
await sql.end()

View File

@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["bun-types"]
},
"include": ["src"]
}

View File

@@ -11,7 +11,8 @@ function getSteamHeaderImage(sourceId: string): string {
}
function getPreloadUrl(game: Game): string | null {
if (game.cover_image_id) return `${apiBase}/api/igdb/image/${game.cover_image_id}/cover_big`
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
}
@@ -22,7 +23,11 @@ interface CardStackProps {
onSwipeRight: () => void
}
export function CardStack({ games, onSwipeLeft, onSwipeRight }: CardStackProps) {
export function CardStack({
games,
onSwipeLeft,
onSwipeRight,
}: CardStackProps) {
const navigate = useNavigate()
const topGame = games[0]

View File

@@ -6,7 +6,11 @@ interface DiscoverProgressProps {
totalCount: number
}
export function DiscoverProgress({ progress, seenCount, totalCount }: DiscoverProgressProps) {
export function DiscoverProgress({
progress,
seenCount,
totalCount,
}: DiscoverProgressProps) {
return (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">

View File

@@ -20,15 +20,22 @@ function parseJsonArray(text: string | null): string[] {
}
export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
const imageUrl = game.source === "steam" ? getSteamHeaderImage(game.source_id) : null
const dotColor = game.game_state !== "not_set" ? gameStateColors[game.game_state] : null
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
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">
{imageUrl && (
<img src={imageUrl} alt={game.title} className="w-full flex-1 object-cover min-h-0" />
<img
src={imageUrl}
alt={game.title}
className="w-full flex-1 object-cover min-h-0"
/>
)}
<div className="shrink-0 p-4">
<div className="flex items-center gap-2">
@@ -37,7 +44,9 @@ export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
{game.source}
</Badge>
{dotColor && (
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
<span
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`}
/>
)}
{rating != null && (
<Badge variant="outline" className="ml-auto shrink-0">
@@ -55,7 +64,9 @@ export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
</div>
)}
{game.summary && (
<p className="mt-1.5 line-clamp-2 text-xs text-muted-foreground">{game.summary}</p>
<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">

View File

@@ -5,10 +5,13 @@ import { useDiscoverStore } from "../store"
export function useDiscover() {
const { games: allGames } = useGames()
const { games: wantToPlayGames, reload: reloadWtp } = usePlaylist("want-to-play")
const { games: notIntGames, reload: reloadNi } = usePlaylist("not-interesting")
const { games: wantToPlayGames, reload: reloadWtp } =
usePlaylist("want-to-play")
const { games: notIntGames, reload: reloadNi } =
usePlaylist("not-interesting")
const { addGame } = usePlaylistMutations()
const { currentIndex, setCurrentIndex, updateShuffledGames, reset } = useDiscoverStore()
const { currentIndex, setCurrentIndex, updateShuffledGames, reset } =
useDiscoverStore()
const [ready, setReady] = useState(false)
const [localSeenIds, setLocalSeenIds] = useState<Set<string>>(new Set())
@@ -32,7 +35,9 @@ export function useDiscover() {
const currentGame: Game | null = unseenGames[currentIndex] ?? null
const isDone = ready && unseenGames.length === 0
const progress =
allGames.length > 0 ? ((allGames.length - unseenGames.length) / allGames.length) * 100 : 0
allGames.length > 0
? ((allGames.length - unseenGames.length) / allGames.length) * 100
: 0
const swipeRight = useCallback(() => {
if (!currentGame) return

View File

@@ -16,7 +16,11 @@ interface DiscoverState {
reset: () => void
}
function buildShuffleKey(games: Game[], seenIds: Set<string>, seed: number): string {
function buildShuffleKey(
games: Game[],
seenIds: Set<string>,
seed: number,
): string {
return `${games.length}:${seenIds.size}:${seed}`
}

View File

@@ -8,7 +8,11 @@ interface FavoriteButtonProps {
onChange?: () => void
}
export function FavoriteButton({ gameId, isFavorite, onChange }: FavoriteButtonProps) {
export function FavoriteButton({
gameId,
isFavorite,
onChange,
}: FavoriteButtonProps) {
const updateGame = useUpdateGame()
const { addGame, removeGame } = usePlaylistMutations()

View File

@@ -24,15 +24,29 @@ export function GameCard({ game, onUpdate }: GameCardProps) {
</Badge>
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
{game.playtime_hours > 0 && <span>{formatPlaytime(game.playtime_hours)}</span>}
{game.playtime_hours > 0 && (
<span>{formatPlaytime(game.playtime_hours)}</span>
)}
{game.last_played && <span>Last: {game.last_played}</span>}
</div>
</div>
<FavoriteButton gameId={game.id} isFavorite={game.is_favorite} onChange={onUpdate} />
<FavoriteButton
gameId={game.id}
isFavorite={game.is_favorite}
onChange={onUpdate}
/>
</div>
<div className="mt-3 flex items-center justify-between">
<StarRating gameId={game.id} rating={game.rating} onChange={onUpdate} />
<GameStateSelect gameId={game.id} state={game.game_state} onChange={onUpdate} />
<StarRating
gameId={game.id}
rating={game.rating}
onChange={onUpdate}
/>
<GameStateSelect
gameId={game.id}
state={game.game_state}
onChange={onUpdate}
/>
</div>
</CardContent>
</Card>

View File

@@ -33,19 +33,28 @@ export function GameDetail({ gameId }: GameDetailProps) {
if (loading) return null
if (!game) {
return <p className="py-8 text-center text-muted-foreground">{t("game.notFound")}</p>
return (
<p className="py-8 text-center text-muted-foreground">
{t("game.notFound")}
</p>
)
}
return <GameDetailContent game={game} onUpdate={reload} />
}
function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => void }) {
const imageUrl = game.source === "steam" ? getSteamHeaderImage(game.source_id) : null
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
const rating =
game.aggregated_rating != null ? Math.round(game.aggregated_rating) : null
return (
<div className="space-y-4">
@@ -66,7 +75,9 @@ function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => voi
</Badge>
</div>
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
{game.playtime_hours > 0 && <span>{formatPlaytime(game.playtime_hours)}</span>}
{game.playtime_hours > 0 && (
<span>{formatPlaytime(game.playtime_hours)}</span>
)}
{game.last_played && (
<span>
{t("game.lastPlayed")}: {game.last_played}
@@ -74,7 +85,11 @@ function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => voi
)}
</div>
</div>
<FavoriteButton gameId={game.id} isFavorite={game.is_favorite} onChange={onUpdate} />
<FavoriteButton
gameId={game.id}
isFavorite={game.is_favorite}
onChange={onUpdate}
/>
</div>
{genres.length > 0 && (
@@ -89,10 +104,18 @@ function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => voi
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StarRating gameId={game.id} rating={game.rating} onChange={onUpdate} />
<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} />
<GameStateSelect
gameId={game.id}
state={game.game_state}
onChange={onUpdate}
/>
</div>
{(developers.length > 0 || game.release_date) && (
@@ -124,7 +147,9 @@ function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => voi
{screenshots.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold">{t("game.screenshots")}</h3>
<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

View File

@@ -25,7 +25,11 @@ export function GameListItem({ game, onClick }: GameListItemProps) {
<ListItem
link
title={game.title}
subtitle={game.playtime_hours > 0 ? formatPlaytime(game.playtime_hours) : undefined}
subtitle={
game.playtime_hours > 0
? formatPlaytime(game.playtime_hours)
: undefined
}
media={
coverUrl ? (
<img src={coverUrl} alt="" className={imgClass} />
@@ -42,13 +46,17 @@ export function GameListItem({ game, onClick }: GameListItemProps) {
}
function GameListItemAfter({ game }: { game: Game }) {
const ratingText = game.rating >= 0 ? `${Math.round(game.rating / 2)}/5` : null
const dotColor = game.game_state !== "not_set" ? gameStateColors[game.game_state] : null
const ratingText =
game.rating >= 0 ? `${Math.round(game.rating / 2)}/5` : null
const dotColor =
game.game_state !== "not_set" ? gameStateColors[game.game_state] : null
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{ratingText && <span>{ratingText}</span>}
{dotColor && <span className={`inline-block h-2 w-2 rounded-full ${dotColor}`} />}
{dotColor && (
<span className={`inline-block h-2 w-2 rounded-full ${dotColor}`} />
)}
{game.is_favorite && <span className="text-red-500"></span>}
</span>
)

View File

@@ -10,7 +10,11 @@ interface GameStateSelectProps {
const states = Object.keys(gameStateLabels) as GameState[]
export function GameStateSelect({ gameId, state, onChange }: GameStateSelectProps) {
export function GameStateSelect({
gameId,
state,
onChange,
}: GameStateSelectProps) {
const updateGame = useUpdateGame()
const handleChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {

View File

@@ -52,7 +52,10 @@ export function StarRating({ gameId, rating, onChange }: StarRatingProps) {
) : halfFilled ? (
<div className="relative">
<Star className="h-5 w-5 text-muted-foreground/30" />
<div className="absolute inset-0 overflow-hidden" style={{ width: "50%" }}>
<div
className="absolute inset-0 overflow-hidden"
style={{ width: "50%" }}
>
<Star className="h-5 w-5 fill-current" />
</div>
</div>

View File

@@ -6,7 +6,10 @@ interface LibraryHeaderProps {
totalPlaytime: number
}
export function LibraryHeader({ totalCount, totalPlaytime }: LibraryHeaderProps) {
export function LibraryHeader({
totalCount,
totalPlaytime,
}: LibraryHeaderProps) {
return (
<div className="mb-4">
<h1 className="text-2xl font-bold">{t("library.title")}</h1>

View File

@@ -30,7 +30,11 @@ export function LibraryList({ games, hasMore, loadMore }: LibraryListProps) {
}, [hasMore, loadMore])
if (games.length === 0) {
return <p className="py-8 text-center text-muted-foreground">{t("library.empty")}</p>
return (
<p className="py-8 text-center text-muted-foreground">
{t("library.empty")}
</p>
)
}
return (
@@ -40,7 +44,9 @@ export function LibraryList({ games, hasMore, loadMore }: LibraryListProps) {
<GameListItem
key={game.id}
game={game}
onClick={() => navigate({ to: "/games/$gameId", params: { gameId: game.id } })}
onClick={() =>
navigate({ to: "/games/$gameId", params: { gameId: game.id } })
}
/>
))}
</div>

View File

@@ -5,8 +5,14 @@ import { ArrowDownAZ, ArrowUpAZ } from "lucide-react"
import { startTransition } from "react"
export function LibrarySearch() {
const { searchText, setSearchText, sortBy, setSortBy, sortDirection, toggleSortDirection } =
useUiStore()
const {
searchText,
setSearchText,
sortBy,
setSortBy,
sortDirection,
toggleSortDirection,
} = useUiStore()
return (
<div className="mb-4 flex gap-2">
@@ -19,7 +25,9 @@ export function LibrarySearch() {
<select
value={sortBy}
onChange={(e) =>
startTransition(() => setSortBy(e.target.value as "title" | "playtime" | "lastPlayed"))
startTransition(() =>
setSortBy(e.target.value as "title" | "playtime" | "lastPlayed"),
)
}
className="w-32 rounded-lg border border-input bg-transparent px-2 py-2 text-sm"
>

View File

@@ -25,7 +25,10 @@ function mergeGames(games: Game[]): Game[] {
: game.last_played
: existing.last_played || game.last_played,
rating: existing.rating >= 0 ? existing.rating : game.rating,
game_state: existing.game_state !== "not_set" ? existing.game_state : game.game_state,
game_state:
existing.game_state !== "not_set"
? existing.game_state
: game.game_state,
is_favorite: existing.is_favorite || game.is_favorite,
})
} else {
@@ -48,7 +51,9 @@ export function useLibrary() {
const filtered = useMemo(() => {
const result = searchText
? merged.filter((g) => g.title.toLowerCase().includes(searchText.toLowerCase()))
? merged.filter((g) =>
g.title.toLowerCase().includes(searchText.toLowerCase()),
)
: merged.slice()
result.sort((a, b) => {
const dir = sortDirection === "asc" ? 1 : -1
@@ -67,7 +72,10 @@ export function useLibrary() {
return result
}, [merged, searchText, sortBy, sortDirection])
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount])
const visible = useMemo(
() => filtered.slice(0, visibleCount),
[filtered, visibleCount],
)
const loadMore = useCallback(() => {
setVisibleCount((c) => Math.min(c + BATCH_SIZE, filtered.length))

View File

@@ -110,7 +110,9 @@ export function PlaylistDetail({ playlistId }: PlaylistDetailProps) {
{games.length === 0 ? (
<div>
<p className="py-4 text-center text-muted-foreground">{t("playlists.noGames")}</p>
<p className="py-4 text-center text-muted-foreground">
{t("playlists.noGames")}
</p>
</div>
) : (
<div className="divide-y rounded-lg border bg-card">
@@ -119,10 +121,19 @@ export function PlaylistDetail({ playlistId }: PlaylistDetailProps) {
<div className="min-w-0 flex-1">
<GameListItem
game={game}
onClick={() => navigate({ to: "/games/$gameId", params: { gameId: game.id } })}
onClick={() =>
navigate({
to: "/games/$gameId",
params: { gameId: game.id },
})
}
/>
</div>
<Button variant="ghost" className="shrink-0" onClick={() => removeGame(game.id)}>
<Button
variant="ghost"
className="shrink-0"
onClick={() => removeGame(game.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>

View File

@@ -5,7 +5,10 @@ import { t } from "@/shared/i18n"
import { useNavigate } from "@tanstack/react-router"
import { Heart, ListMusic, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"
const staticIcons: Record<string, React.ComponentType<{ className?: string }>> = {
const staticIcons: Record<
string,
React.ComponentType<{ className?: string }>
> = {
favorites: Heart,
"want-to-play": ThumbsUp,
"not-interesting": ThumbsDown,
@@ -41,9 +44,16 @@ export function PlaylistsList() {
link
title={p.name}
media={<Icon className="h-5 w-5 text-muted-foreground" />}
after={<span className="text-sm text-muted-foreground">{p.game_count}</span>}
after={
<span className="text-sm text-muted-foreground">
{p.game_count}
</span>
}
onClick={() =>
navigate({ to: "/playlists/$playlistId", params: { playlistId: p.id } })
navigate({
to: "/playlists/$playlistId",
params: { playlistId: p.id },
})
}
/>
)
@@ -60,7 +70,9 @@ export function PlaylistsList() {
media={<ListMusic className="h-5 w-5 text-muted-foreground" />}
after={
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{p.game_count}</span>
<span className="text-sm text-muted-foreground">
{p.game_count}
</span>
<button
type="button"
onClick={(e) => handleDelete(e, p.id)}
@@ -71,7 +83,10 @@ export function PlaylistsList() {
</div>
}
onClick={() =>
navigate({ to: "/playlists/$playlistId", params: { playlistId: p.id } })
navigate({
to: "/playlists/$playlistId",
params: { playlistId: p.id },
})
}
/>
))}

View File

@@ -4,7 +4,8 @@ import { useCallback, useMemo, useState } from "react"
export function usePlaylistDetail(id: string) {
const { playlist, games, loading, reload } = usePlaylist(id)
const { addGame, removeGame, renamePlaylist, deletePlaylist } = usePlaylistMutations()
const { addGame, removeGame, renamePlaylist, deletePlaylist } =
usePlaylistMutations()
const { games: allGames } = useGames()
const [searchText, setSearchText] = useState("")

View File

@@ -49,14 +49,24 @@ export function DataSettings() {
<ListItem
title={t("settings.data.import")}
after={
<Button size="sm" variant="outline" onClick={() => fileRef.current?.click()}>
<Button
size="sm"
variant="outline"
onClick={() => fileRef.current?.click()}
>
{t("settings.data.import")}
</Button>
}
/>
</div>
<input ref={fileRef} type="file" accept=".json" onChange={handleImport} className="hidden" />
<input
ref={fileRef}
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
<div className="mt-4 divide-y rounded-lg border bg-card">
<ListItem
@@ -84,7 +94,9 @@ export function DataSettings() {
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>{t("settings.data.clear")}</DialogTitle>
<DialogDescription>{t("settings.data.clearConfirm")}</DialogDescription>
<DialogDescription>
{t("settings.data.clearConfirm")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>

View File

@@ -11,7 +11,11 @@ const GOG_AUTH_URL =
"https://auth.gog.com/auth?client_id=46899977096215655&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=code&layout=client2"
export function GogSettings() {
const gogConfig = useConfig<{ accessToken: string; refreshToken: string; userId: string }>("gog")
const gogConfig = useConfig<{
accessToken: string
refreshToken: string
userId: string
}>("gog")
const saveConfig = useSaveConfig()
const lastSync = useConfig<string>("gog_last_sync")
const { syncing, error, lastCount, progress } = useSyncStore((s) => s.gog)
@@ -61,7 +65,9 @@ export function GogSettings() {
<div className="mt-4">
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
<label className="space-y-1">
<span className="text-sm font-medium">{t("settings.gog.code")}</span>
<span className="text-sm font-medium">
{t("settings.gog.code")}
</span>
<Input
type="text"
value={code}
@@ -93,7 +99,11 @@ export function GogSettings() {
<Button onClick={handleSync} disabled={syncing}>
{syncing ? t("settings.syncing") : t("settings.steam.sync")}
</Button>
<Button variant="ghost" className="text-red-500" onClick={handleDisconnect}>
<Button
variant="ghost"
className="text-red-500"
onClick={handleDisconnect}
>
{t("settings.gog.disconnect")}
</Button>
</div>

View File

@@ -19,7 +19,9 @@ export function SettingsList() {
const gogConfig = useConfig<{ accessToken: string }>("gog")
const navigate = useNavigate()
const [testState, setTestState] = useState<"idle" | "testing" | "ok" | "failed">("idle")
const [testState, setTestState] = useState<
"idle" | "testing" | "ok" | "failed"
>("idle")
const {
needRefresh: [needRefresh],
@@ -53,19 +55,27 @@ export function SettingsList() {
</h3>
<div className="divide-y rounded-lg border bg-card">
<ListItem
title={needRefresh ? t("settings.updateAvailable") : t("settings.appUpToDate")}
title={
needRefresh
? t("settings.updateAvailable")
: t("settings.appUpToDate")
}
after={
needRefresh ? (
<Button size="sm" onClick={() => updateServiceWorker()}>
{t("settings.updateApp")}
</Button>
) : (
<span className="text-sm text-muted-foreground">{t("settings.upToDate")}</span>
<span className="text-sm text-muted-foreground">
{t("settings.upToDate")}
</span>
)
}
/>
</div>
<p className="px-1 pt-1 text-xs text-muted-foreground/60">v{__APP_VERSION__}</p>
<p className="px-1 pt-1 text-xs text-muted-foreground/60">
v{__APP_VERSION__}
</p>
<h3 className="pt-4 pb-1 text-xs font-medium uppercase text-muted-foreground">
{t("settings.server")}
@@ -75,7 +85,9 @@ export function SettingsList() {
title={t("settings.connection")}
after={
<div className="flex items-center gap-2">
{testState === "testing" && <Loader2 className="h-5 w-5 animate-spin" />}
{testState === "testing" && (
<Loader2 className="h-5 w-5 animate-spin" />
)}
{testState === "ok" && (
<span className="text-sm font-medium text-green-600">
{t("settings.connectionOk")}
@@ -111,13 +123,20 @@ export function SettingsList() {
after={
<Badge
className={
isConnected(p.id) ? "bg-green-500 text-white" : "bg-muted text-muted-foreground"
isConnected(p.id)
? "bg-green-500 text-white"
: "bg-muted text-muted-foreground"
}
>
{isConnected(p.id) ? "Connected" : "Not configured"}
</Badge>
}
onClick={() => navigate({ to: "/settings/$provider", params: { provider: p.id } })}
onClick={() =>
navigate({
to: "/settings/$provider",
params: { provider: p.id },
})
}
/>
))}
</div>
@@ -129,7 +148,12 @@ export function SettingsList() {
<ListItem
link
title={t("settings.data")}
onClick={() => navigate({ to: "/settings/$provider", params: { provider: "data" } })}
onClick={() =>
navigate({
to: "/settings/$provider",
params: { provider: "data" },
})
}
/>
</div>
</div>

View File

@@ -30,7 +30,9 @@ export function SteamSettings() {
return (
<>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t("settings.steam.instructions")}</p>
<p className="text-sm text-muted-foreground">
{t("settings.steam.instructions")}
</p>
<a
href="https://steamcommunity.com/dev/apikey"
target="_blank"
@@ -44,7 +46,9 @@ export function SteamSettings() {
<div className="mt-4 space-y-3">
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
<label className="space-y-1">
<span className="text-sm font-medium">{t("settings.steam.steamId")}</span>
<span className="text-sm font-medium">
{t("settings.steam.steamId")}
</span>
<Input
type="text"
value={steamId}
@@ -54,7 +58,9 @@ export function SteamSettings() {
</label>
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
<label className="space-y-1">
<span className="text-sm font-medium">{t("settings.steam.apiKey")}</span>
<span className="text-sm font-medium">
{t("settings.steam.apiKey")}
</span>
<Input
type="password"
value={apiKey}
@@ -87,7 +93,10 @@ export function SteamSettings() {
{lastSync && (
<div className="mt-4 divide-y rounded-lg border bg-card">
<ListItem title={t("settings.lastSync")} after={new Date(lastSync).toLocaleString()} />
<ListItem
title={t("settings.lastSync")}
after={new Date(lastSync).toLocaleString()}
/>
</div>
)}
</>

View File

@@ -18,7 +18,9 @@ export function useDataManagement() {
config: config.rows,
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" })
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url

View File

@@ -1,5 +1,10 @@
import { t } from "@/shared/i18n"
import { Outlet, createRootRoute, useNavigate, useRouterState } from "@tanstack/react-router"
import {
Outlet,
createRootRoute,
useNavigate,
useRouterState,
} from "@tanstack/react-router"
import { Gamepad2, Library, ListMusic, Settings } from "lucide-react"
export const Route = createRootRoute({

View File

@@ -11,21 +11,39 @@ export const Route = createFileRoute("/discover/")({
})
function DiscoverPage() {
const { unseenGames, isDone, progress, totalCount, seenCount, swipeRight, swipeLeft, reset } =
useDiscover()
const {
unseenGames,
isDone,
progress,
totalCount,
seenCount,
swipeRight,
swipeLeft,
reset,
} = useDiscover()
return (
<div className="mx-auto flex h-full max-w-lg flex-col p-4">
<DiscoverProgress progress={progress} seenCount={seenCount} totalCount={totalCount} />
<DiscoverProgress
progress={progress}
seenCount={seenCount}
totalCount={totalCount}
/>
{isDone ? (
<DiscoverDone seenCount={seenCount} onReset={reset} />
) : unseenGames.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">{t("discover.empty")}</p>
<p className="py-8 text-center text-muted-foreground">
{t("discover.empty")}
</p>
) : (
<>
<div className="mt-4 min-h-0 flex-1">
<CardStack games={unseenGames} onSwipeLeft={swipeLeft} onSwipeRight={swipeRight} />
<CardStack
games={unseenGames}
onSwipeLeft={swipeLeft}
onSwipeRight={swipeRight}
/>
</div>
<div className="shrink-0 py-4">
<SwipeButtons

View File

@@ -10,17 +10,34 @@ export const Route = createFileRoute("/library/")({
})
function LibraryPage() {
const { games, totalCount, totalPlaytime, hasMore, loadMore, loading, reload } = useLibrary()
const {
games,
totalCount,
totalPlaytime,
hasMore,
loadMore,
loading,
reload,
} = useLibrary()
if (loading) {
return <div className="p-4 text-center text-muted-foreground">{t("general.loading")}</div>
return (
<div className="p-4 text-center text-muted-foreground">
{t("general.loading")}
</div>
)
}
return (
<div className="mx-auto max-w-lg p-4">
<LibraryHeader totalCount={totalCount} totalPlaytime={totalPlaytime} />
<LibrarySearch />
<LibraryList games={games} hasMore={hasMore} loadMore={loadMore} onUpdate={reload} />
<LibraryList
games={games}
hasMore={hasMore}
loadMore={loadMore}
onUpdate={reload}
/>
</div>
)
}

View File

@@ -11,7 +11,10 @@ function PlaylistDetailPage() {
return (
<div className="mx-auto max-w-lg p-4">
<Link to="/playlists" className="mb-4 flex items-center gap-1 text-sm text-muted-foreground">
<Link
to="/playlists"
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>

View File

@@ -22,7 +22,11 @@ function ProviderSettingsPage() {
return (
<div>
<header className="flex items-center gap-2 px-2 pt-4 pb-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate({ to: "/settings" })}>
<Button
variant="ghost"
size="icon-sm"
onClick={() => navigate({ to: "/settings" })}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-xl font-bold">{titles[provider] ?? provider}</h1>

View File

@@ -10,7 +10,8 @@ const badgeVariants = cva(
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
@@ -30,7 +31,8 @@ function Badge({
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (

View File

@@ -14,8 +14,10 @@ const buttonVariants = cva(
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {

View File

@@ -52,14 +52,23 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -72,4 +81,12 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -5,19 +5,27 @@ import type * as React from "react"
import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils"
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
@@ -92,7 +100,10 @@ function DialogFooter({
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
@@ -105,7 +116,10 @@ function DialogFooter({
)
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"

View File

@@ -6,20 +6,29 @@ import type * as React from "react"
import { cn } from "@/shared/lib/utils"
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
@@ -42,8 +51,12 @@ function DropdownMenuContent({
)
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
@@ -98,7 +111,12 @@ function DropdownMenuCheckboxItem({
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
@@ -136,7 +154,10 @@ function DropdownMenuLabel({
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
)
@@ -155,17 +176,25 @@ function DropdownMenuSeparator({
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}

View File

@@ -36,10 +36,16 @@ export function ListItem({
{media && <div className="shrink-0">{media}</div>}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{title}</div>
{subtitle && <div className="truncate text-xs text-muted-foreground">{subtitle}</div>}
{subtitle && (
<div className="truncate text-xs text-muted-foreground">
{subtitle}
</div>
)}
</div>
{after && <div className="shrink-0">{after}</div>}
{link && <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
{link && (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
</Comp>
)
}

View File

@@ -6,15 +6,21 @@ import type * as React from "react"
import { cn } from "@/shared/lib/utils"
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
@@ -81,7 +87,10 @@ function SelectContent({
)
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
@@ -138,7 +147,10 @@ function SelectScrollUpButton({
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
@@ -153,7 +165,10 @@ function SelectScrollDownButton({
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />

View File

@@ -10,15 +10,21 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
@@ -99,7 +105,10 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
)
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"

View File

@@ -14,7 +14,10 @@ function Tabs({
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
)
@@ -39,7 +42,8 @@ function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
@@ -50,7 +54,10 @@ function TabsList({
)
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
@@ -66,7 +73,10 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
)
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"

View File

@@ -26,7 +26,9 @@ export function useGame(id: string) {
const reload = useCallback(async () => {
const db = await getDb()
const result = await db.query<Game>("SELECT * FROM games WHERE id = $1", [id])
const result = await db.query<Game>("SELECT * FROM games WHERE id = $1", [
id,
])
setGame(result.rows[0] ?? null)
setLoading(false)
}, [id])
@@ -39,7 +41,9 @@ export function useGame(id: string) {
}
export function usePlaylists() {
const [playlists, setPlaylists] = useState<(Playlist & { game_count: number })[]>([])
const [playlists, setPlaylists] = useState<
(Playlist & { game_count: number })[]
>([])
const [loading, setLoading] = useState(true)
const reload = useCallback(async () => {
@@ -72,7 +76,10 @@ export function usePlaylist(id: string) {
const reload = useCallback(async () => {
const db = await getDb()
const pResult = await db.query<Playlist>("SELECT * FROM playlists WHERE id = $1", [id])
const pResult = await db.query<Playlist>(
"SELECT * FROM playlists WHERE id = $1",
[id],
)
setPlaylist(pResult.rows[0] ?? null)
const gResult = await db.query<Game>(
@@ -101,7 +108,10 @@ export function useConfig<T = unknown>(key: string) {
useEffect(() => {
async function load() {
const db = await getDb()
const result = await db.query<{ value: T }>("SELECT value FROM config WHERE key = $1", [key])
const result = await db.query<{ value: T }>(
"SELECT value FROM config WHERE key = $1",
[key],
)
setValue(result.rows[0]?.value ?? null)
}
load()
@@ -123,7 +133,10 @@ export function useSaveConfig() {
export function useSaveGamesBySource() {
return useCallback(
async (_source: string, games: Omit<Game, "rating" | "game_state" | "is_favorite">[]) => {
async (
_source: string,
games: Omit<Game, "rating" | "game_state" | "is_favorite">[],
) => {
const db = await getDb()
for (const game of games) {
await db.query(
@@ -168,7 +181,10 @@ export function useUpdateGame() {
fields.push("updated_at = NOW()")
values.push(id)
await db.query(`UPDATE games SET ${fields.join(", ")} WHERE id = $${idx}`, values)
await db.query(
`UPDATE games SET ${fields.join(", ")} WHERE id = $${idx}`,
values,
)
}, [])
}
@@ -184,27 +200,36 @@ export function usePlaylistMutations() {
const removeGame = useCallback(async (playlistId: string, gameId: string) => {
const db = await getDb()
await db.query("DELETE FROM playlist_games WHERE playlist_id = $1 AND game_id = $2", [
playlistId,
gameId,
])
await db.query(
"DELETE FROM playlist_games WHERE playlist_id = $1 AND game_id = $2",
[playlistId, gameId],
)
}, [])
const createPlaylist = useCallback(async (name: string): Promise<string> => {
const db = await getDb()
const id = `custom-${Date.now()}`
await db.query("INSERT INTO playlists (id, name, is_static) VALUES ($1, $2, FALSE)", [id, name])
await db.query(
"INSERT INTO playlists (id, name, is_static) VALUES ($1, $2, FALSE)",
[id, name],
)
return id
}, [])
const renamePlaylist = useCallback(async (id: string, name: string) => {
const db = await getDb()
await db.query("UPDATE playlists SET name = $1 WHERE id = $2 AND is_static = FALSE", [name, id])
await db.query(
"UPDATE playlists SET name = $1 WHERE id = $2 AND is_static = FALSE",
[name, id],
)
}, [])
const deletePlaylist = useCallback(async (id: string) => {
const db = await getDb()
await db.query("DELETE FROM playlists WHERE id = $1 AND is_static = FALSE", [id])
await db.query(
"DELETE FROM playlists WHERE id = $1 AND is_static = FALSE",
[id],
)
}, [])
return { addGame, removeGame, createPlaylist, renamePlaylist, deletePlaylist }

View File

@@ -18,7 +18,10 @@ export function setLocale(locale: Locale) {
currentLocale = locale
}
export function t(key: TranslationKey, params?: Record<string, string | number>): string {
export function t(
key: TranslationKey,
params?: Record<string, string | number>,
): string {
const value = translations[currentLocale][key] ?? translations.en[key] ?? key
if (!params) return value
return Object.entries(params).reduce<string>(

View File

@@ -8,7 +8,8 @@ export const de: Record<TranslationKey, string> = {
"library.title": "Bibliothek",
"library.search": "Spiele suchen...",
"library.empty": "Noch keine Spiele. Füge Anbieter in den Einstellungen hinzu.",
"library.empty":
"Noch keine Spiele. Füge Anbieter in den Einstellungen hinzu.",
"library.games": "Spiele",
"library.hours": "Stunden gespielt",
"library.sort.title": "Titel",
@@ -44,7 +45,8 @@ export const de: Record<TranslationKey, string> = {
"settings.data.export": "Daten exportieren",
"settings.data.import": "Daten importieren",
"settings.data.clear": "Alle Daten löschen",
"settings.data.clearConfirm": "Bist du sicher? Alle Spiele und Playlisten werden gelöscht.",
"settings.data.clearConfirm":
"Bist du sicher? Alle Spiele und Playlisten werden gelöscht.",
"settings.app": "App",
"settings.updateAvailable": "Update verfügbar",
"settings.appUpToDate": "App ist aktuell",

View File

@@ -47,7 +47,8 @@ export const en = {
"settings.data.export": "Export Data",
"settings.data.import": "Import Data",
"settings.data.clear": "Clear All Data",
"settings.data.clearConfirm": "Are you sure? This will delete all games and playlists.",
"settings.data.clearConfirm":
"Are you sure? This will delete all games and playlists.",
"settings.app": "App",
"settings.updateAvailable": "Update available",
"settings.appUpToDate": "App is up to date",

View File

@@ -0,0 +1,8 @@
import { hc } from "hono/client"
import type { AppType } from "../../../server/app"
const baseUrl =
import.meta.env.VITE_API_URL ||
`${import.meta.env.BASE_URL.replace(/\/$/, "")}/api`
export const api = hc<AppType>(baseUrl)

View File

@@ -14,14 +14,21 @@ interface SyncStore {
steam: SourceState
gog: SourceState
syncSteam: (config: { apiKey: string; steamId: string }) => Promise<void>
connectGog: (
code: string,
) => Promise<{ access_token: string; refresh_token: string; user_id: string } | null>
connectGog: (code: string) => Promise<{
access_token: string
refresh_token: string
user_id: string
} | null>
syncGogGames: (accessToken: string, refreshToken: string) => Promise<void>
clearError: (source: "steam" | "gog") => void
}
const initial: SourceState = { syncing: false, error: null, lastCount: null, progress: null }
const initial: SourceState = {
syncing: false,
error: null,
lastCount: null,
progress: null,
}
async function enrichAfterSync(
source: "steam" | "gog",
@@ -45,7 +52,9 @@ async function enrichAfterSync(
}))
// Single round trip: resolve canonical IDs + fetch metadata
const res = await api.igdb.resolve.$post({ json: { games: gamesToResolve } })
const res = await api.igdb.resolve.$post({
json: { games: gamesToResolve },
})
if (!res.ok) return
const data = await res.json()
@@ -53,11 +62,10 @@ async function enrichAfterSync(
// Apply canonical IDs
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,
])
await db.query(
"UPDATE games SET canonical_id = $1 WHERE source = $2 AND source_id = $3",
[g.canonicalId, g.source, g.sourceId],
)
}
}
@@ -83,7 +91,10 @@ async function enrichAfterSync(
)
}
} catch (err) {
console.error("[sync] enrichment failed (non-fatal):", (err as Error).message)
console.error(
"[sync] enrichment failed (non-fatal):",
(err as Error).message,
)
}
}
@@ -147,9 +158,19 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
syncSteam: async (config) => {
if (get().steam.syncing) return
set({ steam: { syncing: true, error: null, lastCount: null, progress: "fetching" } })
set({
steam: {
syncing: true,
error: null,
lastCount: null,
progress: "fetching",
},
})
try {
await saveConfig("steam", { apiKey: config.apiKey, steamId: config.steamId })
await saveConfig("steam", {
apiKey: config.apiKey,
steamId: config.steamId,
})
const res = await api.steam.games.$post({
json: { apiKey: config.apiKey, steamId: config.steamId },
@@ -180,21 +201,42 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
}))
await saveGamesBySource("steam", dbGames, (current, total) => {
set({ steam: { ...get().steam, progress: `saving:${current}:${total}` } })
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 } })
set({
steam: {
syncing: false,
error: null,
lastCount: data.count,
progress: null,
},
})
} catch (err) {
set({
steam: { syncing: false, error: (err as Error).message, lastCount: null, progress: null },
steam: {
syncing: false,
error: (err as Error).message,
lastCount: null,
progress: null,
},
})
}
},
connectGog: async (code) => {
if (get().gog.syncing) return null
set({ gog: { syncing: true, error: null, lastCount: null, progress: "fetching" } })
set({
gog: {
syncing: true,
error: null,
lastCount: null,
progress: "fetching",
},
})
try {
const res = await api.gog.auth.$post({ json: { code } })
if (!res.ok) {
@@ -216,11 +258,18 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
userId: tokens.user_id,
})
set({ gog: { syncing: false, error: null, lastCount: null, progress: null } })
set({
gog: { syncing: false, error: null, lastCount: null, progress: null },
})
return tokens
} catch (err) {
set({
gog: { syncing: false, error: (err as Error).message, lastCount: null, progress: null },
gog: {
syncing: false,
error: (err as Error).message,
lastCount: null,
progress: null,
},
})
return null
}
@@ -228,7 +277,14 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
syncGogGames: async (accessToken, refreshToken) => {
if (get().gog.syncing) return
set({ gog: { syncing: true, error: null, lastCount: null, progress: "fetching" } })
set({
gog: {
syncing: true,
error: null,
lastCount: null,
progress: "fetching",
},
})
try {
const res = await api.gog.games.$post({
json: { accessToken, refreshToken },
@@ -270,10 +326,22 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
})
await saveConfig("gog_last_sync", new Date().toISOString())
await enrichAfterSync("gog", set, get)
set({ gog: { syncing: false, error: null, lastCount: data.count, progress: null } })
set({
gog: {
syncing: false,
error: null,
lastCount: data.count,
progress: null,
},
})
} catch (err) {
set({
gog: { syncing: false, error: (err as Error).message, lastCount: null, progress: null },
gog: {
syncing: false,
error: (err as Error).message,
lastCount: null,
progress: null,
},
})
}
},

View File

@@ -1,5 +1,6 @@
const CLIENT_ID = "46899977096215655"
const CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"
const CLIENT_SECRET =
"9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"
const REDIRECT_URI = "https://embed.gog.com/on_login_success?origin=client"
export async function exchangeGogCode(code: string) {
@@ -76,7 +77,9 @@ export async function fetchGogGames(accessToken: string, refreshToken: string) {
}
if (!response.ok) {
throw new Error(`GOG API Error: ${response.status} ${response.statusText}`)
throw new Error(
`GOG API Error: ${response.status} ${response.statusText}`,
)
}
const data = await response.json()
@@ -86,7 +89,9 @@ export async function fetchGogGames(accessToken: string, refreshToken: string) {
}
const games = allProducts
.filter((product): product is GogProduct & { title: string } => Boolean(product.title))
.filter((product): product is GogProduct & { title: string } =>
Boolean(product.title),
)
.map((product) => ({
id: `gog-${product.id}`,
title: product.title,

View File

@@ -6,10 +6,15 @@ interface CacheEntry {
igdbId: number | null
}
export async function getCacheEntries(keys: string[]): Promise<Map<string, CacheEntry>> {
export async function getCacheEntries(
keys: string[],
): Promise<Map<string, CacheEntry>> {
if (keys.length === 0) return new Map()
const rows = await db
.select({ cacheKey: igdbResolutions.cacheKey, igdbId: igdbResolutions.igdbId })
.select({
cacheKey: igdbResolutions.cacheKey,
igdbId: igdbResolutions.igdbId,
})
.from(igdbResolutions)
.where(inArray(igdbResolutions.cacheKey, keys))
const result = new Map<string, CacheEntry>()
@@ -20,7 +25,12 @@ export async function getCacheEntries(keys: string[]): Promise<Map<string, Cache
}
export async function setCacheEntries(
entries: Array<{ cacheKey: string; source: string; sourceId: string; igdbId: number | null }>,
entries: Array<{
cacheKey: string
source: string
sourceId: string
igdbId: number | null
}>,
) {
if (entries.length === 0) return
await db

View File

@@ -13,7 +13,9 @@ export interface IgdbMetadata {
developers: string[]
}
export async function getMetadataBatch(canonicalIds: string[]): Promise<Map<string, IgdbMetadata>> {
export async function getMetadataBatch(
canonicalIds: string[],
): Promise<Map<string, IgdbMetadata>> {
if (canonicalIds.length === 0) return new Map()
const rows = await db
.select()

View File

@@ -1,8 +1,17 @@
import { zValidator } from "@hono/zod-validator"
import { Hono } from "hono"
import { z } from "zod"
import { fetchAndCacheImage, hasImage, isValidSize, readImage } from "./image-cache.ts"
import { enrichAndFetchMetadata, enrichGamesWithIgdb, fetchMetadataForGames } from "./service.ts"
import {
fetchAndCacheImage,
hasImage,
isValidSize,
readImage,
} from "./image-cache.ts"
import {
enrichAndFetchMetadata,
enrichGamesWithIgdb,
fetchMetadataForGames,
} from "./service.ts"
const enrichInput = z.object({
games: z.array(

View File

@@ -1,6 +1,10 @@
import { env } from "../../shared/lib/env.ts"
import { getCacheEntries, setCacheEntries } from "./cache.ts"
import { type IgdbMetadata, getMetadataBatch, setMetadataBatch } from "./metadata-cache.ts"
import {
type IgdbMetadata,
getMetadataBatch,
setMetadataBatch,
} from "./metadata-cache.ts"
const SOURCE_URL_PREFIX: Record<string, string> = {
steam: "https://store.steampowered.com/app/",
@@ -26,7 +30,9 @@ async function getIgdbToken(): Promise<string | null> {
const response = await fetch(url, { method: "POST" })
if (!response.ok) {
console.error(`[IGDB] Twitch auth failed: ${response.status} ${response.statusText}`)
console.error(
`[IGDB] Twitch auth failed: ${response.status} ${response.statusText}`,
)
return null
}
@@ -37,7 +43,10 @@ async function getIgdbToken(): Promise<string | null> {
return twitchToken
}
async function igdbRequest(endpoint: string, query: string): Promise<unknown[]> {
async function igdbRequest(
endpoint: string,
query: string,
): Promise<unknown[]> {
const token = await getIgdbToken()
if (!token) return []
@@ -62,7 +71,10 @@ async function igdbRequest(endpoint: string, query: string): Promise<unknown[]>
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
async function batchResolve(source: string, sourceIds: string[]): Promise<Map<string, number>> {
async function batchResolve(
source: string,
sourceIds: string[],
): Promise<Map<string, number>> {
const results = new Map<string, number>()
const urlPrefix = SOURCE_URL_PREFIX[source]
if (!urlPrefix) return results
@@ -138,14 +150,24 @@ export async function enrichGamesWithIgdb<T extends GameForEnrichment>(
const resolved = await batchResolve(source, sourceIds)
for (const [uid, igdbId] of resolved) {
const entry = { cacheKey: `${source}:${uid}`, source, sourceId: uid, igdbId }
const entry = {
cacheKey: `${source}:${uid}`,
source,
sourceId: uid,
igdbId,
}
newEntries.push(entry)
cached.set(entry.cacheKey, { igdbId })
}
for (const uid of sourceIds) {
if (!resolved.has(uid)) {
const entry = { cacheKey: `${source}:${uid}`, source, sourceId: uid, igdbId: null }
const entry = {
cacheKey: `${source}:${uid}`,
source,
sourceId: uid,
igdbId: null,
}
newEntries.push(entry)
cached.set(entry.cacheKey, { igdbId: null })
}
@@ -157,7 +179,10 @@ export async function enrichGamesWithIgdb<T extends GameForEnrichment>(
console.log(`[IGDB] Resolved ${newEntries.length} new games`)
}
} catch (err) {
console.error("[IGDB] Enrichment failed (non-fatal):", (err as Error).message)
console.error(
"[IGDB] Enrichment failed (non-fatal):",
(err as Error).message,
)
}
return games.map((game) => {

View File

@@ -21,7 +21,9 @@ export const steamRouter = new Hono()
return c.text("Invalid app ID", 400)
}
try {
const bytes = hasIcon(appId) ? readIcon(appId) : await fetchAndCacheIcon(appId)
const bytes = hasIcon(appId)
? readIcon(appId)
: await fetchAndCacheIcon(appId)
return new Response(bytes.buffer as ArrayBuffer, {
headers: {
"Content-Type": "image/jpeg",

View File

@@ -5,8 +5,13 @@ interface SteamGame {
rtime_last_played?: number
}
async function resolveVanityUrl(apiKey: string, vanityName: string): Promise<string> {
const url = new URL("https://api.steampowered.com/ISteamUser/ResolveVanityURL/v1/")
async function resolveVanityUrl(
apiKey: string,
vanityName: string,
): Promise<string> {
const url = new URL(
"https://api.steampowered.com/ISteamUser/ResolveVanityURL/v1/",
)
url.searchParams.set("key", apiKey)
url.searchParams.set("vanityurl", vanityName)
@@ -43,7 +48,9 @@ export async function fetchSteamGames(apiKey: string, rawSteamId: string) {
steamId = await resolveVanityUrl(apiKey, steamId)
}
const url = new URL("https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/")
const url = new URL(
"https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/",
)
url.searchParams.set("key", apiKey)
url.searchParams.set("steamid", steamId)
url.searchParams.set("include_appinfo", "true")
@@ -57,7 +64,9 @@ export async function fetchSteamGames(apiKey: string, rawSteamId: string) {
throw new Error("Unauthorized. Check your API key and Steam ID.")
}
if (!response.ok) {
throw new Error(`Steam API error: ${response.status} ${response.statusText}`)
throw new Error(
`Steam API error: ${response.status} ${response.statusText}`,
)
}
const text = await response.text()
@@ -65,7 +74,9 @@ export async function fetchSteamGames(apiKey: string, rawSteamId: string) {
try {
data = JSON.parse(text)
} catch {
throw new Error("Steam API returned an unexpected response. Check your credentials.")
throw new Error(
"Steam API returned an unexpected response. Check your credentials.",
)
}
const rawGames: SteamGame[] = data.response?.games ?? []

Some files were not shown because too many files have changed in this diff Show More