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:
@@ -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
9
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
[tools]
|
||||
node = "22"
|
||||
bun = "latest"
|
||||
bun = "1.3.0"
|
||||
|
||||
7
.vscode/tasks.json
vendored
7
.vscode/tasks.json
vendored
@@ -5,13 +5,10 @@
|
||||
"label": "vite: dev server",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"args": ["run", "dev"],
|
||||
"isBackground": true,
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
55
CLAUDE.md
Normal file
55
CLAUDE.md
Normal 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
|
||||
```
|
||||
@@ -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,
|
||||
|
||||
33
deploy.sh
33
deploy.sh
@@ -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
|
||||
|
||||
@@ -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 ?? "",
|
||||
@@ -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>
|
||||
|
||||
11
package.json
11
package.json
@@ -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",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
PORT=3001
|
||||
ALLOWED_ORIGIN=http://localhost:5173
|
||||
TWITCH_CLIENT_ID=
|
||||
TWITCH_CLIENT_SECRET=
|
||||
DATABASE_URL=postgresql://localhost:5432/whattoplay
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1772543801794,
|
||||
"tag": "0000_heavy_lila_cheney",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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">
|
||||
@@ -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">
|
||||
@@ -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
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>) => {
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
>
|
||||
@@ -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))
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@@ -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("")
|
||||
|
||||
@@ -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)}>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
@@ -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
|
||||
@@ -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({
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 (
|
||||
@@ -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: {
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 }
|
||||
@@ -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>(
|
||||
@@ -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",
|
||||
@@ -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",
|
||||
8
src/client/shared/lib/api.ts
Normal file
8
src/client/shared/lib/api.ts
Normal 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)
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
@@ -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) => {
|
||||
@@ -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",
|
||||
@@ -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
Reference in New Issue
Block a user