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
|
# Server
|
||||||
PORT=3001
|
PORT=3001
|
||||||
ALLOWED_ORIGIN=https://serve.uber.space
|
ALLOWED_ORIGIN=http://localhost:5173
|
||||||
TWITCH_CLIENT_ID=
|
TWITCH_CLIENT_ID=
|
||||||
TWITCH_CLIENT_SECRET=
|
TWITCH_CLIENT_SECRET=
|
||||||
|
DATABASE_URL=postgresql://localhost:5432/whattoplay
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -3,7 +3,6 @@ node_modules
|
|||||||
.claude
|
.claude
|
||||||
|
|
||||||
# Override global gitignore exclusions
|
# Override global gitignore exclusions
|
||||||
!/server/
|
|
||||||
!/src/
|
!/src/
|
||||||
!**/lib/
|
!**/lib/
|
||||||
|
|
||||||
@@ -14,9 +13,7 @@ node_modules
|
|||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# IGDB cache (generated at runtime)
|
# IGDB cache (generated at runtime)
|
||||||
server/data/igdb-cache.json
|
src/server/data/
|
||||||
server/data/igdb-metadata.json
|
|
||||||
server/data/igdb-images/
|
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist
|
dist
|
||||||
@@ -24,9 +21,13 @@ build
|
|||||||
.vite
|
.vite
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
|
# Database
|
||||||
|
drizzle/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
# bun
|
# bun
|
||||||
bun.lock
|
bun.lock
|
||||||
|
.mise.local.toml
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
[tools]
|
[tools]
|
||||||
node = "22"
|
bun = "1.3.0"
|
||||||
bun = "latest"
|
|
||||||
|
|||||||
7
.vscode/tasks.json
vendored
7
.vscode/tasks.json
vendored
@@ -5,13 +5,10 @@
|
|||||||
"label": "vite: dev server",
|
"label": "vite: dev server",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "npm",
|
"command": "npm",
|
||||||
"args": [
|
"args": ["run", "dev"],
|
||||||
"run",
|
|
||||||
"dev"
|
|
||||||
],
|
|
||||||
"isBackground": true,
|
"isBackground": true,
|
||||||
"problemMatcher": [],
|
"problemMatcher": [],
|
||||||
"group": "build"
|
"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",
|
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": ["src/routeTree.gen.ts"]
|
"ignore": ["src/client/routeTree.gen.ts", "drizzle/"]
|
||||||
},
|
},
|
||||||
"organizeImports": {
|
"organizeImports": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "tab",
|
"indentStyle": "tab",
|
||||||
"lineWidth": 100
|
"lineWidth": 80
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"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"
|
ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_HTML_DIR"
|
||||||
rsync -avz --delete dist/ "$UBERSPACE_HOST:$REMOTE_HTML_DIR/"
|
rsync -avz --delete dist/ "$UBERSPACE_HOST:$REMOTE_HTML_DIR/"
|
||||||
|
|
||||||
echo "==> syncing server to $REMOTE_SERVICE_DIR/"
|
echo "==> syncing project 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"
|
ssh "$UBERSPACE_HOST" "mkdir -p $REMOTE_SERVICE_DIR"
|
||||||
rsync -avz --delete \
|
rsync -avz --delete \
|
||||||
server/src/ "$UBERSPACE_HOST:$REMOTE_SERVICE_DIR/src/"
|
--exclude='.git/' \
|
||||||
rsync -avz --delete \
|
--exclude='.env' \
|
||||||
server/drizzle/ "$UBERSPACE_HOST:$REMOTE_SERVICE_DIR/drizzle/"
|
--exclude='dist/' \
|
||||||
rsync -avz \
|
--exclude='node_modules/' \
|
||||||
server/package.json server/drizzle.config.ts "$UBERSPACE_HOST:$REMOTE_SERVICE_DIR/"
|
--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"
|
ssh "$UBERSPACE_HOST" "cd $REMOTE_SERVICE_DIR && bun install"
|
||||||
|
|
||||||
echo "==> creating .env if missing..."
|
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..."
|
echo "==> setting up systemd service..."
|
||||||
ssh "$UBERSPACE_HOST" "mkdir -p ~/.config/systemd/user"
|
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]
|
[Unit]
|
||||||
Description=WhatToPlay API server
|
Description=WhatToPlay API server
|
||||||
After=network.target
|
After=network.target postgresql.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=/usr/bin/bun run src/index.ts
|
Type=simple
|
||||||
WorkingDirectory=%h/services/whattoplay
|
WorkingDirectory=/home/serve/services/whattoplay
|
||||||
Restart=always
|
EnvironmentFile=/home/serve/services/whattoplay/.env
|
||||||
|
ExecStart=/usr/bin/bun run src/server/index.ts
|
||||||
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
EnvironmentFile=%h/services/whattoplay/.env
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit"
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
schema: "./src/shared/db/schema/*",
|
schema: "./src/server/shared/db/schema/*",
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL ?? "",
|
url: process.env.DATABASE_URL ?? "",
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/client/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -5,24 +5,29 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"dev:all": "bun run dev & bun run dev:server",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"start": "bun run src/server/index.ts",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"db:generate": "bunx drizzle-kit generate",
|
||||||
|
"db:migrate": "bunx drizzle-kit migrate",
|
||||||
"prepare": "simple-git-hooks"
|
"prepare": "simple-git-hooks"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "^2.4.5",
|
|
||||||
"@electric-sql/pglite": "^0.2.17",
|
"@electric-sql/pglite": "^0.2.17",
|
||||||
|
"@hono/zod-validator": "^0.5.0",
|
||||||
"@tanstack/react-form": "^1.0.0",
|
"@tanstack/react-form": "^1.0.0",
|
||||||
"@tanstack/react-router": "^1.114.0",
|
"@tanstack/react-router": "^1.114.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
"hono": "^4.7.0",
|
"hono": "^4.7.0",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
|
"postgres": "^3.4.8",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -35,10 +40,12 @@
|
|||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@tanstack/router-plugin": "^1.114.0",
|
"@tanstack/router-plugin": "^1.114.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@types/bun": "^1.3.10",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.4.0",
|
"@vitejs/plugin-react": "^4.4.0",
|
||||||
|
"drizzle-kit": "^0.31.9",
|
||||||
"lint-staged": "^15.0.0",
|
"lint-staged": "^15.0.0",
|
||||||
"simple-git-hooks": "^2.11.0",
|
"simple-git-hooks": "^2.11.0",
|
||||||
"tailwindcss": "^4.0.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 {
|
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)
|
if (game.source === "steam") return getSteamHeaderImage(game.source_id)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -22,7 +23,11 @@ interface CardStackProps {
|
|||||||
onSwipeRight: () => void
|
onSwipeRight: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardStack({ games, onSwipeLeft, onSwipeRight }: CardStackProps) {
|
export function CardStack({
|
||||||
|
games,
|
||||||
|
onSwipeLeft,
|
||||||
|
onSwipeRight,
|
||||||
|
}: CardStackProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const topGame = games[0]
|
const topGame = games[0]
|
||||||
|
|
||||||
@@ -6,7 +6,11 @@ interface DiscoverProgressProps {
|
|||||||
totalCount: number
|
totalCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiscoverProgress({ progress, seenCount, totalCount }: DiscoverProgressProps) {
|
export function DiscoverProgress({
|
||||||
|
progress,
|
||||||
|
seenCount,
|
||||||
|
totalCount,
|
||||||
|
}: DiscoverProgressProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<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) {
|
export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
|
||||||
const imageUrl = game.source === "steam" ? getSteamHeaderImage(game.source_id) : null
|
const imageUrl =
|
||||||
const dotColor = game.game_state !== "not_set" ? gameStateColors[game.game_state] : null
|
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 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 (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-lg">
|
<div className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-lg">
|
||||||
{imageUrl && (
|
{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="shrink-0 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -37,7 +44,9 @@ export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
|
|||||||
{game.source}
|
{game.source}
|
||||||
</Badge>
|
</Badge>
|
||||||
{dotColor && (
|
{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 && (
|
{rating != null && (
|
||||||
<Badge variant="outline" className="ml-auto shrink-0">
|
<Badge variant="outline" className="ml-auto shrink-0">
|
||||||
@@ -55,7 +64,9 @@ export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{game.summary && (
|
{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 && (
|
{!game.summary && game.playtime_hours > 0 && (
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
@@ -5,10 +5,13 @@ import { useDiscoverStore } from "../store"
|
|||||||
|
|
||||||
export function useDiscover() {
|
export function useDiscover() {
|
||||||
const { games: allGames } = useGames()
|
const { games: allGames } = useGames()
|
||||||
const { games: wantToPlayGames, reload: reloadWtp } = usePlaylist("want-to-play")
|
const { games: wantToPlayGames, reload: reloadWtp } =
|
||||||
const { games: notIntGames, reload: reloadNi } = usePlaylist("not-interesting")
|
usePlaylist("want-to-play")
|
||||||
|
const { games: notIntGames, reload: reloadNi } =
|
||||||
|
usePlaylist("not-interesting")
|
||||||
const { addGame } = usePlaylistMutations()
|
const { addGame } = usePlaylistMutations()
|
||||||
const { currentIndex, setCurrentIndex, updateShuffledGames, reset } = useDiscoverStore()
|
const { currentIndex, setCurrentIndex, updateShuffledGames, reset } =
|
||||||
|
useDiscoverStore()
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
const [localSeenIds, setLocalSeenIds] = useState<Set<string>>(new Set())
|
const [localSeenIds, setLocalSeenIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
@@ -32,7 +35,9 @@ export function useDiscover() {
|
|||||||
const currentGame: Game | null = unseenGames[currentIndex] ?? null
|
const currentGame: Game | null = unseenGames[currentIndex] ?? null
|
||||||
const isDone = ready && unseenGames.length === 0
|
const isDone = ready && unseenGames.length === 0
|
||||||
const progress =
|
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(() => {
|
const swipeRight = useCallback(() => {
|
||||||
if (!currentGame) return
|
if (!currentGame) return
|
||||||
@@ -16,7 +16,11 @@ interface DiscoverState {
|
|||||||
reset: () => void
|
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}`
|
return `${games.length}:${seenIds.size}:${seed}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,7 +8,11 @@ interface FavoriteButtonProps {
|
|||||||
onChange?: () => void
|
onChange?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FavoriteButton({ gameId, isFavorite, onChange }: FavoriteButtonProps) {
|
export function FavoriteButton({
|
||||||
|
gameId,
|
||||||
|
isFavorite,
|
||||||
|
onChange,
|
||||||
|
}: FavoriteButtonProps) {
|
||||||
const updateGame = useUpdateGame()
|
const updateGame = useUpdateGame()
|
||||||
const { addGame, removeGame } = usePlaylistMutations()
|
const { addGame, removeGame } = usePlaylistMutations()
|
||||||
|
|
||||||
@@ -24,15 +24,29 @@ export function GameCard({ game, onUpdate }: GameCardProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
<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>}
|
{game.last_played && <span>Last: {game.last_played}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FavoriteButton gameId={game.id} isFavorite={game.is_favorite} onChange={onUpdate} />
|
<FavoriteButton
|
||||||
|
gameId={game.id}
|
||||||
|
isFavorite={game.is_favorite}
|
||||||
|
onChange={onUpdate}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center justify-between">
|
<div className="mt-3 flex items-center justify-between">
|
||||||
<StarRating gameId={game.id} rating={game.rating} onChange={onUpdate} />
|
<StarRating
|
||||||
<GameStateSelect gameId={game.id} state={game.game_state} onChange={onUpdate} />
|
gameId={game.id}
|
||||||
|
rating={game.rating}
|
||||||
|
onChange={onUpdate}
|
||||||
|
/>
|
||||||
|
<GameStateSelect
|
||||||
|
gameId={game.id}
|
||||||
|
state={game.game_state}
|
||||||
|
onChange={onUpdate}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -33,19 +33,28 @@ export function GameDetail({ gameId }: GameDetailProps) {
|
|||||||
if (loading) return null
|
if (loading) return null
|
||||||
|
|
||||||
if (!game) {
|
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} />
|
return <GameDetailContent game={game} onUpdate={reload} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => void }) {
|
function GameDetailContent({
|
||||||
const imageUrl = game.source === "steam" ? getSteamHeaderImage(game.source_id) : null
|
game,
|
||||||
|
onUpdate,
|
||||||
|
}: { game: Game; onUpdate: () => void }) {
|
||||||
|
const imageUrl =
|
||||||
|
game.source === "steam" ? getSteamHeaderImage(game.source_id) : null
|
||||||
const genres = parseJsonArray(game.genres)
|
const genres = parseJsonArray(game.genres)
|
||||||
const developers = parseJsonArray(game.developers)
|
const developers = parseJsonArray(game.developers)
|
||||||
const screenshots = parseJsonArray(game.screenshots)
|
const screenshots = parseJsonArray(game.screenshots)
|
||||||
const videoIds = parseJsonArray(game.video_ids)
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -66,7 +75,9 @@ function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => voi
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
|
<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 && (
|
{game.last_played && (
|
||||||
<span>
|
<span>
|
||||||
{t("game.lastPlayed")}: {game.last_played}
|
{t("game.lastPlayed")}: {game.last_played}
|
||||||
@@ -74,7 +85,11 @@ function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => voi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FavoriteButton gameId={game.id} isFavorite={game.is_favorite} onChange={onUpdate} />
|
<FavoriteButton
|
||||||
|
gameId={game.id}
|
||||||
|
isFavorite={game.is_favorite}
|
||||||
|
onChange={onUpdate}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{genres.length > 0 && (
|
{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 justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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>}
|
{rating != null && <Badge variant="outline">{rating}%</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<GameStateSelect gameId={game.id} state={game.game_state} onChange={onUpdate} />
|
<GameStateSelect
|
||||||
|
gameId={game.id}
|
||||||
|
state={game.game_state}
|
||||||
|
onChange={onUpdate}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(developers.length > 0 || game.release_date) && (
|
{(developers.length > 0 || game.release_date) && (
|
||||||
@@ -124,7 +147,9 @@ function GameDetailContent({ game, onUpdate }: { game: Game; onUpdate: () => voi
|
|||||||
|
|
||||||
{screenshots.length > 0 && (
|
{screenshots.length > 0 && (
|
||||||
<div>
|
<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">
|
<div className="flex snap-x gap-2 overflow-x-auto pb-2">
|
||||||
{screenshots.map((id) => (
|
{screenshots.map((id) => (
|
||||||
<img
|
<img
|
||||||
@@ -25,7 +25,11 @@ export function GameListItem({ game, onClick }: GameListItemProps) {
|
|||||||
<ListItem
|
<ListItem
|
||||||
link
|
link
|
||||||
title={game.title}
|
title={game.title}
|
||||||
subtitle={game.playtime_hours > 0 ? formatPlaytime(game.playtime_hours) : undefined}
|
subtitle={
|
||||||
|
game.playtime_hours > 0
|
||||||
|
? formatPlaytime(game.playtime_hours)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
media={
|
media={
|
||||||
coverUrl ? (
|
coverUrl ? (
|
||||||
<img src={coverUrl} alt="" className={imgClass} />
|
<img src={coverUrl} alt="" className={imgClass} />
|
||||||
@@ -42,13 +46,17 @@ export function GameListItem({ game, onClick }: GameListItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GameListItemAfter({ game }: { game: Game }) {
|
function GameListItemAfter({ game }: { game: Game }) {
|
||||||
const ratingText = game.rating >= 0 ? `★ ${Math.round(game.rating / 2)}/5` : null
|
const ratingText =
|
||||||
const dotColor = game.game_state !== "not_set" ? gameStateColors[game.game_state] : null
|
game.rating >= 0 ? `★ ${Math.round(game.rating / 2)}/5` : null
|
||||||
|
const dotColor =
|
||||||
|
game.game_state !== "not_set" ? gameStateColors[game.game_state] : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
{ratingText && <span>{ratingText}</span>}
|
{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>}
|
{game.is_favorite && <span className="text-red-500">♥</span>}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -10,7 +10,11 @@ interface GameStateSelectProps {
|
|||||||
|
|
||||||
const states = Object.keys(gameStateLabels) as GameState[]
|
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 updateGame = useUpdateGame()
|
||||||
|
|
||||||
const handleChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
@@ -52,7 +52,10 @@ export function StarRating({ gameId, rating, onChange }: StarRatingProps) {
|
|||||||
) : halfFilled ? (
|
) : halfFilled ? (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Star className="h-5 w-5 text-muted-foreground/30" />
|
<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" />
|
<Star className="h-5 w-5 fill-current" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6,7 +6,10 @@ interface LibraryHeaderProps {
|
|||||||
totalPlaytime: number
|
totalPlaytime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibraryHeader({ totalCount, totalPlaytime }: LibraryHeaderProps) {
|
export function LibraryHeader({
|
||||||
|
totalCount,
|
||||||
|
totalPlaytime,
|
||||||
|
}: LibraryHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h1 className="text-2xl font-bold">{t("library.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("library.title")}</h1>
|
||||||
@@ -30,7 +30,11 @@ export function LibraryList({ games, hasMore, loadMore }: LibraryListProps) {
|
|||||||
}, [hasMore, loadMore])
|
}, [hasMore, loadMore])
|
||||||
|
|
||||||
if (games.length === 0) {
|
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 (
|
return (
|
||||||
@@ -40,7 +44,9 @@ export function LibraryList({ games, hasMore, loadMore }: LibraryListProps) {
|
|||||||
<GameListItem
|
<GameListItem
|
||||||
key={game.id}
|
key={game.id}
|
||||||
game={game}
|
game={game}
|
||||||
onClick={() => navigate({ to: "/games/$gameId", params: { gameId: game.id } })}
|
onClick={() =>
|
||||||
|
navigate({ to: "/games/$gameId", params: { gameId: game.id } })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -5,8 +5,14 @@ import { ArrowDownAZ, ArrowUpAZ } from "lucide-react"
|
|||||||
import { startTransition } from "react"
|
import { startTransition } from "react"
|
||||||
|
|
||||||
export function LibrarySearch() {
|
export function LibrarySearch() {
|
||||||
const { searchText, setSearchText, sortBy, setSortBy, sortDirection, toggleSortDirection } =
|
const {
|
||||||
useUiStore()
|
searchText,
|
||||||
|
setSearchText,
|
||||||
|
sortBy,
|
||||||
|
setSortBy,
|
||||||
|
sortDirection,
|
||||||
|
toggleSortDirection,
|
||||||
|
} = useUiStore()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex gap-2">
|
<div className="mb-4 flex gap-2">
|
||||||
@@ -19,7 +25,9 @@ export function LibrarySearch() {
|
|||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) =>
|
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"
|
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
|
: game.last_played
|
||||||
: existing.last_played || game.last_played,
|
: existing.last_played || game.last_played,
|
||||||
rating: existing.rating >= 0 ? existing.rating : game.rating,
|
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,
|
is_favorite: existing.is_favorite || game.is_favorite,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -48,7 +51,9 @@ export function useLibrary() {
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const result = searchText
|
const result = searchText
|
||||||
? merged.filter((g) => g.title.toLowerCase().includes(searchText.toLowerCase()))
|
? merged.filter((g) =>
|
||||||
|
g.title.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
)
|
||||||
: merged.slice()
|
: merged.slice()
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
const dir = sortDirection === "asc" ? 1 : -1
|
const dir = sortDirection === "asc" ? 1 : -1
|
||||||
@@ -67,7 +72,10 @@ export function useLibrary() {
|
|||||||
return result
|
return result
|
||||||
}, [merged, searchText, sortBy, sortDirection])
|
}, [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(() => {
|
const loadMore = useCallback(() => {
|
||||||
setVisibleCount((c) => Math.min(c + BATCH_SIZE, filtered.length))
|
setVisibleCount((c) => Math.min(c + BATCH_SIZE, filtered.length))
|
||||||
@@ -110,7 +110,9 @@ export function PlaylistDetail({ playlistId }: PlaylistDetailProps) {
|
|||||||
|
|
||||||
{games.length === 0 ? (
|
{games.length === 0 ? (
|
||||||
<div>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y rounded-lg border bg-card">
|
<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">
|
<div className="min-w-0 flex-1">
|
||||||
<GameListItem
|
<GameListItem
|
||||||
game={game}
|
game={game}
|
||||||
onClick={() => navigate({ to: "/games/$gameId", params: { gameId: game.id } })}
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/games/$gameId",
|
||||||
|
params: { gameId: game.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -5,7 +5,10 @@ import { t } from "@/shared/i18n"
|
|||||||
import { useNavigate } from "@tanstack/react-router"
|
import { useNavigate } from "@tanstack/react-router"
|
||||||
import { Heart, ListMusic, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"
|
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,
|
favorites: Heart,
|
||||||
"want-to-play": ThumbsUp,
|
"want-to-play": ThumbsUp,
|
||||||
"not-interesting": ThumbsDown,
|
"not-interesting": ThumbsDown,
|
||||||
@@ -41,9 +44,16 @@ export function PlaylistsList() {
|
|||||||
link
|
link
|
||||||
title={p.name}
|
title={p.name}
|
||||||
media={<Icon className="h-5 w-5 text-muted-foreground" />}
|
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={() =>
|
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" />}
|
media={<ListMusic className="h-5 w-5 text-muted-foreground" />}
|
||||||
after={
|
after={
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => handleDelete(e, p.id)}
|
onClick={(e) => handleDelete(e, p.id)}
|
||||||
@@ -71,7 +83,10 @@ export function PlaylistsList() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onClick={() =>
|
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) {
|
export function usePlaylistDetail(id: string) {
|
||||||
const { playlist, games, loading, reload } = usePlaylist(id)
|
const { playlist, games, loading, reload } = usePlaylist(id)
|
||||||
const { addGame, removeGame, renamePlaylist, deletePlaylist } = usePlaylistMutations()
|
const { addGame, removeGame, renamePlaylist, deletePlaylist } =
|
||||||
|
usePlaylistMutations()
|
||||||
const { games: allGames } = useGames()
|
const { games: allGames } = useGames()
|
||||||
const [searchText, setSearchText] = useState("")
|
const [searchText, setSearchText] = useState("")
|
||||||
|
|
||||||
@@ -49,14 +49,24 @@ export function DataSettings() {
|
|||||||
<ListItem
|
<ListItem
|
||||||
title={t("settings.data.import")}
|
title={t("settings.data.import")}
|
||||||
after={
|
after={
|
||||||
<Button size="sm" variant="outline" onClick={() => fileRef.current?.click()}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
{t("settings.data.import")}
|
{t("settings.data.import")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="mt-4 divide-y rounded-lg border bg-card">
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -84,7 +94,9 @@ export function DataSettings() {
|
|||||||
<DialogContent showCloseButton={false}>
|
<DialogContent showCloseButton={false}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("settings.data.clear")}</DialogTitle>
|
<DialogTitle>{t("settings.data.clear")}</DialogTitle>
|
||||||
<DialogDescription>{t("settings.data.clearConfirm")}</DialogDescription>
|
<DialogDescription>
|
||||||
|
{t("settings.data.clearConfirm")}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
<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"
|
"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() {
|
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 saveConfig = useSaveConfig()
|
||||||
const lastSync = useConfig<string>("gog_last_sync")
|
const lastSync = useConfig<string>("gog_last_sync")
|
||||||
const { syncing, error, lastCount, progress } = useSyncStore((s) => s.gog)
|
const { syncing, error, lastCount, progress } = useSyncStore((s) => s.gog)
|
||||||
@@ -61,7 +65,9 @@ export function GogSettings() {
|
|||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
|
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
|
||||||
<label className="space-y-1">
|
<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
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={code}
|
value={code}
|
||||||
@@ -93,7 +99,11 @@ export function GogSettings() {
|
|||||||
<Button onClick={handleSync} disabled={syncing}>
|
<Button onClick={handleSync} disabled={syncing}>
|
||||||
{syncing ? t("settings.syncing") : t("settings.steam.sync")}
|
{syncing ? t("settings.syncing") : t("settings.steam.sync")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" className="text-red-500" onClick={handleDisconnect}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-500"
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
>
|
||||||
{t("settings.gog.disconnect")}
|
{t("settings.gog.disconnect")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -19,7 +19,9 @@ export function SettingsList() {
|
|||||||
const gogConfig = useConfig<{ accessToken: string }>("gog")
|
const gogConfig = useConfig<{ accessToken: string }>("gog")
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [testState, setTestState] = useState<"idle" | "testing" | "ok" | "failed">("idle")
|
const [testState, setTestState] = useState<
|
||||||
|
"idle" | "testing" | "ok" | "failed"
|
||||||
|
>("idle")
|
||||||
|
|
||||||
const {
|
const {
|
||||||
needRefresh: [needRefresh],
|
needRefresh: [needRefresh],
|
||||||
@@ -53,19 +55,27 @@ export function SettingsList() {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="divide-y rounded-lg border bg-card">
|
<div className="divide-y rounded-lg border bg-card">
|
||||||
<ListItem
|
<ListItem
|
||||||
title={needRefresh ? t("settings.updateAvailable") : t("settings.appUpToDate")}
|
title={
|
||||||
|
needRefresh
|
||||||
|
? t("settings.updateAvailable")
|
||||||
|
: t("settings.appUpToDate")
|
||||||
|
}
|
||||||
after={
|
after={
|
||||||
needRefresh ? (
|
needRefresh ? (
|
||||||
<Button size="sm" onClick={() => updateServiceWorker()}>
|
<Button size="sm" onClick={() => updateServiceWorker()}>
|
||||||
{t("settings.updateApp")}
|
{t("settings.updateApp")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-muted-foreground">{t("settings.upToDate")}</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.upToDate")}
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<h3 className="pt-4 pb-1 text-xs font-medium uppercase text-muted-foreground">
|
||||||
{t("settings.server")}
|
{t("settings.server")}
|
||||||
@@ -75,7 +85,9 @@ export function SettingsList() {
|
|||||||
title={t("settings.connection")}
|
title={t("settings.connection")}
|
||||||
after={
|
after={
|
||||||
<div className="flex items-center gap-2">
|
<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" && (
|
{testState === "ok" && (
|
||||||
<span className="text-sm font-medium text-green-600">
|
<span className="text-sm font-medium text-green-600">
|
||||||
{t("settings.connectionOk")}
|
{t("settings.connectionOk")}
|
||||||
@@ -111,13 +123,20 @@ export function SettingsList() {
|
|||||||
after={
|
after={
|
||||||
<Badge
|
<Badge
|
||||||
className={
|
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"}
|
{isConnected(p.id) ? "Connected" : "Not configured"}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
onClick={() => navigate({ to: "/settings/$provider", params: { provider: p.id } })}
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/settings/$provider",
|
||||||
|
params: { provider: p.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -129,7 +148,12 @@ export function SettingsList() {
|
|||||||
<ListItem
|
<ListItem
|
||||||
link
|
link
|
||||||
title={t("settings.data")}
|
title={t("settings.data")}
|
||||||
onClick={() => navigate({ to: "/settings/$provider", params: { provider: "data" } })}
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/settings/$provider",
|
||||||
|
params: { provider: "data" },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,7 +30,9 @@ export function SteamSettings() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<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
|
<a
|
||||||
href="https://steamcommunity.com/dev/apikey"
|
href="https://steamcommunity.com/dev/apikey"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -44,7 +46,9 @@ export function SteamSettings() {
|
|||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
|
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
|
||||||
<label className="space-y-1">
|
<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
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={steamId}
|
value={steamId}
|
||||||
@@ -54,7 +58,9 @@ export function SteamSettings() {
|
|||||||
</label>
|
</label>
|
||||||
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
|
{/* biome-ignore lint/a11y/noLabelWithoutControl: Input is inside the label */}
|
||||||
<label className="space-y-1">
|
<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
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
@@ -87,7 +93,10 @@ export function SteamSettings() {
|
|||||||
|
|
||||||
{lastSync && (
|
{lastSync && (
|
||||||
<div className="mt-4 divide-y rounded-lg border bg-card">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -18,7 +18,9 @@ export function useDataManagement() {
|
|||||||
config: config.rows,
|
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 url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement("a")
|
const a = document.createElement("a")
|
||||||
a.href = url
|
a.href = url
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { t } from "@/shared/i18n"
|
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"
|
import { Gamepad2, Library, ListMusic, Settings } from "lucide-react"
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
@@ -11,21 +11,39 @@ export const Route = createFileRoute("/discover/")({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function DiscoverPage() {
|
function DiscoverPage() {
|
||||||
const { unseenGames, isDone, progress, totalCount, seenCount, swipeRight, swipeLeft, reset } =
|
const {
|
||||||
useDiscover()
|
unseenGames,
|
||||||
|
isDone,
|
||||||
|
progress,
|
||||||
|
totalCount,
|
||||||
|
seenCount,
|
||||||
|
swipeRight,
|
||||||
|
swipeLeft,
|
||||||
|
reset,
|
||||||
|
} = useDiscover()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full max-w-lg flex-col p-4">
|
<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 ? (
|
{isDone ? (
|
||||||
<DiscoverDone seenCount={seenCount} onReset={reset} />
|
<DiscoverDone seenCount={seenCount} onReset={reset} />
|
||||||
) : unseenGames.length === 0 ? (
|
) : 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">
|
<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>
|
||||||
<div className="shrink-0 py-4">
|
<div className="shrink-0 py-4">
|
||||||
<SwipeButtons
|
<SwipeButtons
|
||||||
@@ -10,17 +10,34 @@ export const Route = createFileRoute("/library/")({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function LibraryPage() {
|
function LibraryPage() {
|
||||||
const { games, totalCount, totalPlaytime, hasMore, loadMore, loading, reload } = useLibrary()
|
const {
|
||||||
|
games,
|
||||||
|
totalCount,
|
||||||
|
totalPlaytime,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
|
loading,
|
||||||
|
reload,
|
||||||
|
} = useLibrary()
|
||||||
|
|
||||||
if (loading) {
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-lg p-4">
|
<div className="mx-auto max-w-lg p-4">
|
||||||
<LibraryHeader totalCount={totalCount} totalPlaytime={totalPlaytime} />
|
<LibraryHeader totalCount={totalCount} totalPlaytime={totalPlaytime} />
|
||||||
<LibrarySearch />
|
<LibrarySearch />
|
||||||
<LibraryList games={games} hasMore={hasMore} loadMore={loadMore} onUpdate={reload} />
|
<LibraryList
|
||||||
|
games={games}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadMore={loadMore}
|
||||||
|
onUpdate={reload}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,10 @@ function PlaylistDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-lg p-4">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back
|
Back
|
||||||
</Link>
|
</Link>
|
||||||
@@ -22,7 +22,11 @@ function ProviderSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<header className="flex items-center gap-2 px-2 pt-4 pb-2">
|
<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" />
|
<ChevronLeft className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl font-bold">{titles[provider] ?? provider}</h1>
|
<h1 className="text-xl font-bold">{titles[provider] ?? provider}</h1>
|
||||||
@@ -10,7 +10,8 @@ const badgeVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
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:
|
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",
|
"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:
|
outline:
|
||||||
@@ -30,7 +31,8 @@ function Badge({
|
|||||||
variant = "default",
|
variant = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot.Root : "span"
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
return (
|
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",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
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",
|
"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",
|
secondary:
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
"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",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
@@ -52,14 +52,23 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-action"
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
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">) {
|
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 { Button } from "@/shared/components/ui/button"
|
||||||
import { cn } from "@/shared/lib/utils"
|
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} />
|
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} />
|
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} />
|
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} />
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +100,10 @@ function DialogFooter({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
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}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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 (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
@@ -6,20 +6,29 @@ import type * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
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} />
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
function DropdownMenuPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
}: 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({
|
function DropdownMenuTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
}: 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({
|
function DropdownMenuContent({
|
||||||
@@ -42,8 +51,12 @@ function DropdownMenuContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
function DropdownMenuGroup({
|
||||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
function DropdownMenuItem({
|
||||||
@@ -98,7 +111,12 @@ function DropdownMenuCheckboxItem({
|
|||||||
function DropdownMenuRadioGroup({
|
function DropdownMenuRadioGroup({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
}: 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({
|
function DropdownMenuRadioItem({
|
||||||
@@ -136,7 +154,10 @@ function DropdownMenuLabel({
|
|||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
data-slot="dropdown-menu-label"
|
data-slot="dropdown-menu-label"
|
||||||
data-inset={inset}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -155,17 +176,25 @@ function DropdownMenuSeparator({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="dropdown-menu-shortcut"
|
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}
|
{...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} />
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,10 +36,16 @@ export function ListItem({
|
|||||||
{media && <div className="shrink-0">{media}</div>}
|
{media && <div className="shrink-0">{media}</div>}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium">{title}</div>
|
<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>
|
</div>
|
||||||
{after && <div className="shrink-0">{after}</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>
|
</Comp>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -6,15 +6,21 @@ import type * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
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} />
|
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} />
|
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} />
|
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 (
|
return (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
data-slot="select-label"
|
data-slot="select-label"
|
||||||
@@ -138,7 +147,10 @@ function SelectScrollUpButton({
|
|||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
data-slot="select-scroll-up-button"
|
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}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon className="size-4" />
|
<ChevronUpIcon className="size-4" />
|
||||||
@@ -153,7 +165,10 @@ function SelectScrollDownButton({
|
|||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
data-slot="select-scroll-down-button"
|
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}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-4" />
|
<ChevronDownIcon className="size-4" />
|
||||||
@@ -10,15 +10,21 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
|||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
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} />
|
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} />
|
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} />
|
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 (
|
return (
|
||||||
<SheetPrimitive.Title
|
<SheetPrimitive.Title
|
||||||
data-slot="sheet-title"
|
data-slot="sheet-title"
|
||||||
@@ -14,7 +14,10 @@ function Tabs({
|
|||||||
data-slot="tabs"
|
data-slot="tabs"
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -39,7 +42,8 @@ function TabsList({
|
|||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
|
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||||
|
VariantProps<typeof tabsListVariants>) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
data-slot="tabs-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 (
|
return (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
data-slot="tabs-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 (
|
return (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
data-slot="tabs-content"
|
data-slot="tabs-content"
|
||||||
@@ -26,7 +26,9 @@ export function useGame(id: string) {
|
|||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
const db = await getDb()
|
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)
|
setGame(result.rows[0] ?? null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [id])
|
}, [id])
|
||||||
@@ -39,7 +41,9 @@ export function useGame(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePlaylists() {
|
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 [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
@@ -72,7 +76,10 @@ export function usePlaylist(id: string) {
|
|||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
const db = await getDb()
|
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)
|
setPlaylist(pResult.rows[0] ?? null)
|
||||||
|
|
||||||
const gResult = await db.query<Game>(
|
const gResult = await db.query<Game>(
|
||||||
@@ -101,7 +108,10 @@ export function useConfig<T = unknown>(key: string) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const db = await getDb()
|
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)
|
setValue(result.rows[0]?.value ?? null)
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
@@ -123,7 +133,10 @@ export function useSaveConfig() {
|
|||||||
|
|
||||||
export function useSaveGamesBySource() {
|
export function useSaveGamesBySource() {
|
||||||
return useCallback(
|
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()
|
const db = await getDb()
|
||||||
for (const game of games) {
|
for (const game of games) {
|
||||||
await db.query(
|
await db.query(
|
||||||
@@ -168,7 +181,10 @@ export function useUpdateGame() {
|
|||||||
fields.push("updated_at = NOW()")
|
fields.push("updated_at = NOW()")
|
||||||
values.push(id)
|
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 removeGame = useCallback(async (playlistId: string, gameId: string) => {
|
||||||
const db = await getDb()
|
const db = await getDb()
|
||||||
await db.query("DELETE FROM playlist_games WHERE playlist_id = $1 AND game_id = $2", [
|
await db.query(
|
||||||
playlistId,
|
"DELETE FROM playlist_games WHERE playlist_id = $1 AND game_id = $2",
|
||||||
gameId,
|
[playlistId, gameId],
|
||||||
])
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const createPlaylist = useCallback(async (name: string): Promise<string> => {
|
const createPlaylist = useCallback(async (name: string): Promise<string> => {
|
||||||
const db = await getDb()
|
const db = await getDb()
|
||||||
const id = `custom-${Date.now()}`
|
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
|
return id
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const renamePlaylist = useCallback(async (id: string, name: string) => {
|
const renamePlaylist = useCallback(async (id: string, name: string) => {
|
||||||
const db = await getDb()
|
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 deletePlaylist = useCallback(async (id: string) => {
|
||||||
const db = await getDb()
|
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 }
|
return { addGame, removeGame, createPlaylist, renamePlaylist, deletePlaylist }
|
||||||
@@ -18,7 +18,10 @@ export function setLocale(locale: Locale) {
|
|||||||
currentLocale = 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
|
const value = translations[currentLocale][key] ?? translations.en[key] ?? key
|
||||||
if (!params) return value
|
if (!params) return value
|
||||||
return Object.entries(params).reduce<string>(
|
return Object.entries(params).reduce<string>(
|
||||||
@@ -8,7 +8,8 @@ export const de: Record<TranslationKey, string> = {
|
|||||||
|
|
||||||
"library.title": "Bibliothek",
|
"library.title": "Bibliothek",
|
||||||
"library.search": "Spiele suchen...",
|
"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.games": "Spiele",
|
||||||
"library.hours": "Stunden gespielt",
|
"library.hours": "Stunden gespielt",
|
||||||
"library.sort.title": "Titel",
|
"library.sort.title": "Titel",
|
||||||
@@ -44,7 +45,8 @@ export const de: Record<TranslationKey, string> = {
|
|||||||
"settings.data.export": "Daten exportieren",
|
"settings.data.export": "Daten exportieren",
|
||||||
"settings.data.import": "Daten importieren",
|
"settings.data.import": "Daten importieren",
|
||||||
"settings.data.clear": "Alle Daten löschen",
|
"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.app": "App",
|
||||||
"settings.updateAvailable": "Update verfügbar",
|
"settings.updateAvailable": "Update verfügbar",
|
||||||
"settings.appUpToDate": "App ist aktuell",
|
"settings.appUpToDate": "App ist aktuell",
|
||||||
@@ -47,7 +47,8 @@ export const en = {
|
|||||||
"settings.data.export": "Export Data",
|
"settings.data.export": "Export Data",
|
||||||
"settings.data.import": "Import Data",
|
"settings.data.import": "Import Data",
|
||||||
"settings.data.clear": "Clear All 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.app": "App",
|
||||||
"settings.updateAvailable": "Update available",
|
"settings.updateAvailable": "Update available",
|
||||||
"settings.appUpToDate": "App is up to date",
|
"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
|
steam: SourceState
|
||||||
gog: SourceState
|
gog: SourceState
|
||||||
syncSteam: (config: { apiKey: string; steamId: string }) => Promise<void>
|
syncSteam: (config: { apiKey: string; steamId: string }) => Promise<void>
|
||||||
connectGog: (
|
connectGog: (code: string) => Promise<{
|
||||||
code: string,
|
access_token: string
|
||||||
) => Promise<{ access_token: string; refresh_token: string; user_id: string } | null>
|
refresh_token: string
|
||||||
|
user_id: string
|
||||||
|
} | null>
|
||||||
syncGogGames: (accessToken: string, refreshToken: string) => Promise<void>
|
syncGogGames: (accessToken: string, refreshToken: string) => Promise<void>
|
||||||
clearError: (source: "steam" | "gog") => 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(
|
async function enrichAfterSync(
|
||||||
source: "steam" | "gog",
|
source: "steam" | "gog",
|
||||||
@@ -45,7 +52,9 @@ async function enrichAfterSync(
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Single round trip: resolve canonical IDs + fetch metadata
|
// 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
|
if (!res.ok) return
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -53,11 +62,10 @@ async function enrichAfterSync(
|
|||||||
// Apply canonical IDs
|
// Apply canonical IDs
|
||||||
for (const g of data.games) {
|
for (const g of data.games) {
|
||||||
if (g.canonicalId) {
|
if (g.canonicalId) {
|
||||||
await db.query("UPDATE games SET canonical_id = $1 WHERE source = $2 AND source_id = $3", [
|
await db.query(
|
||||||
g.canonicalId,
|
"UPDATE games SET canonical_id = $1 WHERE source = $2 AND source_id = $3",
|
||||||
g.source,
|
[g.canonicalId, g.source, g.sourceId],
|
||||||
g.sourceId,
|
)
|
||||||
])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +91,10 @@ async function enrichAfterSync(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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) => {
|
syncSteam: async (config) => {
|
||||||
if (get().steam.syncing) return
|
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 {
|
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({
|
const res = await api.steam.games.$post({
|
||||||
json: { apiKey: config.apiKey, steamId: config.steamId },
|
json: { apiKey: config.apiKey, steamId: config.steamId },
|
||||||
@@ -180,21 +201,42 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
await saveGamesBySource("steam", dbGames, (current, total) => {
|
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 saveConfig("steam_last_sync", new Date().toISOString())
|
||||||
await enrichAfterSync("steam", set, get)
|
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) {
|
} catch (err) {
|
||||||
set({
|
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) => {
|
connectGog: async (code) => {
|
||||||
if (get().gog.syncing) return null
|
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 {
|
try {
|
||||||
const res = await api.gog.auth.$post({ json: { code } })
|
const res = await api.gog.auth.$post({ json: { code } })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -216,11 +258,18 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
|
|||||||
userId: tokens.user_id,
|
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
|
return tokens
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
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
|
return null
|
||||||
}
|
}
|
||||||
@@ -228,7 +277,14 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
|
|||||||
|
|
||||||
syncGogGames: async (accessToken, refreshToken) => {
|
syncGogGames: async (accessToken, refreshToken) => {
|
||||||
if (get().gog.syncing) return
|
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 {
|
try {
|
||||||
const res = await api.gog.games.$post({
|
const res = await api.gog.games.$post({
|
||||||
json: { accessToken, refreshToken },
|
json: { accessToken, refreshToken },
|
||||||
@@ -270,10 +326,22 @@ export const useSyncStore = create<SyncStore>((set, get) => ({
|
|||||||
})
|
})
|
||||||
await saveConfig("gog_last_sync", new Date().toISOString())
|
await saveConfig("gog_last_sync", new Date().toISOString())
|
||||||
await enrichAfterSync("gog", set, get)
|
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) {
|
} catch (err) {
|
||||||
set({
|
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_ID = "46899977096215655"
|
||||||
const CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"
|
const CLIENT_SECRET =
|
||||||
|
"9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"
|
||||||
const REDIRECT_URI = "https://embed.gog.com/on_login_success?origin=client"
|
const REDIRECT_URI = "https://embed.gog.com/on_login_success?origin=client"
|
||||||
|
|
||||||
export async function exchangeGogCode(code: string) {
|
export async function exchangeGogCode(code: string) {
|
||||||
@@ -76,7 +77,9 @@ export async function fetchGogGames(accessToken: string, refreshToken: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
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()
|
const data = await response.json()
|
||||||
@@ -86,7 +89,9 @@ export async function fetchGogGames(accessToken: string, refreshToken: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const games = allProducts
|
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) => ({
|
.map((product) => ({
|
||||||
id: `gog-${product.id}`,
|
id: `gog-${product.id}`,
|
||||||
title: product.title,
|
title: product.title,
|
||||||
@@ -6,10 +6,15 @@ interface CacheEntry {
|
|||||||
igdbId: number | null
|
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()
|
if (keys.length === 0) return new Map()
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ cacheKey: igdbResolutions.cacheKey, igdbId: igdbResolutions.igdbId })
|
.select({
|
||||||
|
cacheKey: igdbResolutions.cacheKey,
|
||||||
|
igdbId: igdbResolutions.igdbId,
|
||||||
|
})
|
||||||
.from(igdbResolutions)
|
.from(igdbResolutions)
|
||||||
.where(inArray(igdbResolutions.cacheKey, keys))
|
.where(inArray(igdbResolutions.cacheKey, keys))
|
||||||
const result = new Map<string, CacheEntry>()
|
const result = new Map<string, CacheEntry>()
|
||||||
@@ -20,7 +25,12 @@ export async function getCacheEntries(keys: string[]): Promise<Map<string, Cache
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setCacheEntries(
|
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
|
if (entries.length === 0) return
|
||||||
await db
|
await db
|
||||||
@@ -13,7 +13,9 @@ export interface IgdbMetadata {
|
|||||||
developers: string[]
|
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()
|
if (canonicalIds.length === 0) return new Map()
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
import { zValidator } from "@hono/zod-validator"
|
import { zValidator } from "@hono/zod-validator"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { fetchAndCacheImage, hasImage, isValidSize, readImage } from "./image-cache.ts"
|
import {
|
||||||
import { enrichAndFetchMetadata, enrichGamesWithIgdb, fetchMetadataForGames } from "./service.ts"
|
fetchAndCacheImage,
|
||||||
|
hasImage,
|
||||||
|
isValidSize,
|
||||||
|
readImage,
|
||||||
|
} from "./image-cache.ts"
|
||||||
|
import {
|
||||||
|
enrichAndFetchMetadata,
|
||||||
|
enrichGamesWithIgdb,
|
||||||
|
fetchMetadataForGames,
|
||||||
|
} from "./service.ts"
|
||||||
|
|
||||||
const enrichInput = z.object({
|
const enrichInput = z.object({
|
||||||
games: z.array(
|
games: z.array(
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { env } from "../../shared/lib/env.ts"
|
import { env } from "../../shared/lib/env.ts"
|
||||||
import { getCacheEntries, setCacheEntries } from "./cache.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> = {
|
const SOURCE_URL_PREFIX: Record<string, string> = {
|
||||||
steam: "https://store.steampowered.com/app/",
|
steam: "https://store.steampowered.com/app/",
|
||||||
@@ -26,7 +30,9 @@ async function getIgdbToken(): Promise<string | null> {
|
|||||||
|
|
||||||
const response = await fetch(url, { method: "POST" })
|
const response = await fetch(url, { method: "POST" })
|
||||||
if (!response.ok) {
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +43,10 @@ async function getIgdbToken(): Promise<string | null> {
|
|||||||
return twitchToken
|
return twitchToken
|
||||||
}
|
}
|
||||||
|
|
||||||
async function igdbRequest(endpoint: string, query: string): Promise<unknown[]> {
|
async function igdbRequest(
|
||||||
|
endpoint: string,
|
||||||
|
query: string,
|
||||||
|
): Promise<unknown[]> {
|
||||||
const token = await getIgdbToken()
|
const token = await getIgdbToken()
|
||||||
if (!token) return []
|
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))
|
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 results = new Map<string, number>()
|
||||||
const urlPrefix = SOURCE_URL_PREFIX[source]
|
const urlPrefix = SOURCE_URL_PREFIX[source]
|
||||||
if (!urlPrefix) return results
|
if (!urlPrefix) return results
|
||||||
@@ -138,14 +150,24 @@ export async function enrichGamesWithIgdb<T extends GameForEnrichment>(
|
|||||||
const resolved = await batchResolve(source, sourceIds)
|
const resolved = await batchResolve(source, sourceIds)
|
||||||
|
|
||||||
for (const [uid, igdbId] of resolved) {
|
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)
|
newEntries.push(entry)
|
||||||
cached.set(entry.cacheKey, { igdbId })
|
cached.set(entry.cacheKey, { igdbId })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const uid of sourceIds) {
|
for (const uid of sourceIds) {
|
||||||
if (!resolved.has(uid)) {
|
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)
|
newEntries.push(entry)
|
||||||
cached.set(entry.cacheKey, { igdbId: null })
|
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`)
|
console.log(`[IGDB] Resolved ${newEntries.length} new games`)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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) => {
|
return games.map((game) => {
|
||||||
@@ -21,7 +21,9 @@ export const steamRouter = new Hono()
|
|||||||
return c.text("Invalid app ID", 400)
|
return c.text("Invalid app ID", 400)
|
||||||
}
|
}
|
||||||
try {
|
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, {
|
return new Response(bytes.buffer as ArrayBuffer, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "image/jpeg",
|
"Content-Type": "image/jpeg",
|
||||||
@@ -5,8 +5,13 @@ interface SteamGame {
|
|||||||
rtime_last_played?: number
|
rtime_last_played?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveVanityUrl(apiKey: string, vanityName: string): Promise<string> {
|
async function resolveVanityUrl(
|
||||||
const url = new URL("https://api.steampowered.com/ISteamUser/ResolveVanityURL/v1/")
|
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("key", apiKey)
|
||||||
url.searchParams.set("vanityurl", vanityName)
|
url.searchParams.set("vanityurl", vanityName)
|
||||||
|
|
||||||
@@ -43,7 +48,9 @@ export async function fetchSteamGames(apiKey: string, rawSteamId: string) {
|
|||||||
steamId = await resolveVanityUrl(apiKey, steamId)
|
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("key", apiKey)
|
||||||
url.searchParams.set("steamid", steamId)
|
url.searchParams.set("steamid", steamId)
|
||||||
url.searchParams.set("include_appinfo", "true")
|
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.")
|
throw new Error("Unauthorized. Check your API key and Steam ID.")
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
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()
|
const text = await response.text()
|
||||||
@@ -65,7 +74,9 @@ export async function fetchSteamGames(apiKey: string, rawSteamId: string) {
|
|||||||
try {
|
try {
|
||||||
data = JSON.parse(text)
|
data = JSON.parse(text)
|
||||||
} catch {
|
} 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 ?? []
|
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