- Svelte 52.8%
- TypeScript 40.7%
- JavaScript 2.7%
- CSS 1.4%
- Dockerfile 0.9%
- Other 1.5%
|
Some checks failed
CI/CD / build-hltb (push) Has been skipped
CI/CD / scan-hltb (push) Has been skipped
CI/CD / build-postgres (push) Has been skipped
CI/CD / scan-postgres (push) Has been skipped
CI/CD / build-app (push) Has been skipped
CI/CD / build-container (push) Has been skipped
CI/CD / scan-app (push) Has been skipped
CI/CD / changelog-update (push) Has been skipped
CI/CD / release (push) Has been skipped
CI/CD / audit-python (push) Failing after 47s
CI/CD / lint (push) Failing after 23s
CI/CD / audit-pnpm (push) Failing after 23s
CI/CD / test (push) Failing after 24s
CI/CD / detect-changes (push) Failing after 1m18s
|
||
|---|---|---|
| .beads | ||
| .forgejo/workflows | ||
| docker/postgres | ||
| drizzle | ||
| hltb-service | ||
| messages | ||
| project.inlang | ||
| scripts | ||
| src | ||
| static | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| .npmrc | ||
| .trivyignore | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| docker-compose.dev.yml | ||
| docker-compose.hltb.yml | ||
| docker-compose.migrate.yml | ||
| docker-compose.prod.yml | ||
| Dockerfile | ||
| drizzle.config.js | ||
| eslint.config.js | ||
| LICENSE | ||
| package.json | ||
| PLAN.md | ||
| playwright.config.ts | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| prettier.config.js | ||
| README.md | ||
| svelte.config.js | ||
| tsconfig.json | ||
| vite.config.ts | ||
| vitest.config.ts | ||
Pile of Shame
A self-hosted PWA for managing your gaming backlog as a Kanban board.
Features: Kanban board (Backlog / Next / Playing / Paused / Done) · Steam library import · Game metadata via IGDB/RAWG · Game time estimates via HowLongToBeat (IGDB fallback) · Cover art with grayscale→color progress effect · Configurable column limits · Multi-user with local accounts + OAuth (Google, Twitch, Steam) + custom OIDC (Keycloak, Authentik, …) · Full offline PWA with background sync · Material Design 3 · Dark/light mode · i18n (de/en)
Tech Stack
| Area | Technology |
|---|---|
| Framework | SvelteKit (Node adapter) |
| Language | TypeScript (strict mode) |
| Styling | Tailwind CSS v4 |
| Design System | Material Design 3 (custom components, no external lib) |
| Database | PostgreSQL 16 |
| ORM | Drizzle ORM |
| Auth | Better-Auth |
| i18n | Paraglide JS (compile-time, type-safe) |
| Drag & Drop | svelte-dnd-action |
| PWA | @vite-pwa/sveltekit + Workbox |
| Offline queue | idb (IndexedDB) |
| Testing | Vitest + Playwright |
| Deployment | Docker + Docker Compose |
Requirements
- Node.js 22+
- PostgreSQL 16+
- Docker + Docker Compose (for containerized setup)
Environment Variables
Copy .env.example to .env and fill in the values:
cp .env.example .env
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string |
BETTER_AUTH_SECRET |
Yes | Random secret (openssl rand -base64 32) |
BETTER_AUTH_URL |
Yes | Full base URL of the app (e.g. https://example.com) |
ENABLED_AUTH_PROVIDERS |
Yes | Comma-separated: local, google, twitch, steam, and/or custom OIDC provider IDs |
TWITCH_CLIENT_ID |
No | Twitch app credentials – also enables IGDB game search |
TWITCH_CLIENT_SECRET |
No | |
GOOGLE_CLIENT_ID |
No | Google OAuth credentials |
GOOGLE_CLIENT_SECRET |
No | |
STEAM_API_KEY |
No | Steam Web API key – enables Steam library import |
RAWG_API_KEY |
No | RAWG.io fallback when IGDB is not configured |
HLTB_SERVICE_URL |
No | URL of the optional HLTB microservice (see HLTB Service) |
UPLOAD_DIR |
Yes | Path for user-uploaded cover images (default /app/uploads) |
MAX_UPLOAD_SIZE_MB |
No | Upload size limit in MB (default 5) |
ALLOW_PUBLIC_REGISTRATION |
No | Set to true to allow public sign-up (default false) |
SSO_AUTO_CREATE_ACCOUNT |
No | Set to false to require a pre-existing account for SSO login (default true) |
Note on IGDB: IGDB uses Twitch Client Credentials (server-to-server). Users do not need a Twitch account unless Twitch SSO is explicitly enabled.
Development
Local (no Docker)
# Install dependencies
npm install
# Start dev server with hot reload on http://localhost:5173
npm run dev
# Type-check
npm run check
# Lint
npm run lint
# Format
npm run format
With Docker
# Build and start dev container (hot reload on http://localhost:5173)
docker compose -f docker-compose.dev.yml up --build
The following paths are bind-mounted – changes reload instantly without rebuilding the image:
src/, static/, messages/, project.inlang/, svelte.config.js, vite.config.ts
Database
# Generate migration files after schema changes
npm run db:generate
# Apply migrations
npm run db:migrate
# Open Drizzle Studio (visual DB browser on http://localhost:4983)
npm run db:studio
Management CLI
A built-in CLI for managing users, database, and the application. It runs inside the Docker Compose network so the database never needs to be exposed on a host port.
# Production stack
docker compose -f docker-compose.prod.yml --profile manage run --rm manage
# Development stack
docker compose -f docker-compose.dev.yml --profile manage run --rm manage
# Pass a command directly (skips interactive menu)
docker compose -f docker-compose.prod.yml --profile manage run --rm manage user list
docker compose -f docker-compose.prod.yml --profile manage run --rm manage db migrate
docker compose -f docker-compose.prod.yml --profile manage run --rm manage setup
Available commands:
| Command | Description |
|---|---|
setup |
First-time guided setup (env · migrations · seed · user) |
env |
Validate environment configuration |
user list |
List all users |
user create |
Create a new user |
user reset |
Reset a user's password |
user info |
Show user details |
user delete |
Delete a user and all their data |
user promote |
Grant admin rights to a user |
user demote |
Revoke admin rights from a user |
db migrate |
Apply database migrations |
db generate |
Generate migration files from schema |
db seed |
Seed predefined tags |
db backup |
Dump database to SQL file (pg_dump) |
db restore |
Restore database from SQL file |
db studio |
Open Drizzle Studio |
db cache |
Clear IGDB cache |
app |
Start / stop / logs (Docker) |
Testing
# Unit tests (Vitest)
npm run test
# Unit tests in watch mode
npm run test:watch
# Coverage report
npm run test:coverage
# E2E tests (Playwright – requires a running app)
npm run test:e2e
Production
Docker Compose (recommended)
# 1. Copy and configure environment
cp .env.example .env
# Edit .env – at minimum set BETTER_AUTH_SECRET and BETTER_AUTH_URL
# 2. Build and start
docker compose -f docker-compose.prod.yml up -d --build
# 3. View logs
docker compose -f docker-compose.prod.yml logs -f app
# Stop
docker compose -f docker-compose.prod.yml down
App runs on http://localhost:3000 by default.
Manual build
npm ci
npm run build
NODE_ENV=production DATABASE_URL=... node build
HLTB Service
Game time estimates are provided by an optional Python microservice (hltb-service/) that queries HowLongToBeat using the howlongtobeatpy package. When the service is unavailable, the app falls back to IGDB's game_time_to_beats endpoint.
Enabling the HLTB service
Merge docker-compose.hltb.yml into your existing Compose stack:
# Development
docker compose -f docker-compose.dev.yml -f docker-compose.hltb.yml up --build
# Production
docker compose -f docker-compose.prod.yml -f docker-compose.hltb.yml up --build
The overlay file automatically sets HLTB_SERVICE_URL=http://hltb:8080 on the app service. No manual env var changes are needed.
Standalone (without Docker)
cd hltb-service
pip install -r requirements.txt
uvicorn app:app --host 0.0.0.0 --port 8080
Then set HLTB_SERVICE_URL=http://localhost:8080 in your .env.
API
| Endpoint | Description |
|---|---|
GET /search?q=<title> |
Returns { main, extra, completionist } in hours |
GET /health |
Health check |
Project Structure
src/
├── lib/
│ ├── components/
│ │ ├── board/ # KanbanBoard, KanbanColumn, board-utils
│ │ ├── card/ # GameCard, CardProgressBar, card-utils
│ │ ├── modals/ # AddGameModal, EditGameModal
│ │ └── ui/ # Button, Input, Modal, SyncIndicator
│ ├── server/
│ │ ├── db/ # Drizzle schema, migrations, seed
│ │ ├── auth.ts # Better-Auth config
│ │ ├── hltb.ts # HLTB microservice client (optional)
│ │ ├── igdb.ts # IGDB client + PostgreSQL cache (search + time-to-beat fallback)
│ │ ├── oidc.ts # Custom OIDC provider config (parsed from env)
│ │ ├── rawg.ts # RAWG fallback search
│ │ └── steam.ts # Steam library import
│ ├── client/
│ │ ├── offline-queue.ts # IndexedDB mutation queue
│ │ └── sync.ts # Replay pending mutations on reconnect
│ └── types.ts
├── routes/
│ ├── api/ # REST endpoints (games, tags, search, import, auth)
│ ├── auth/ # Login, register, Steam OAuth pages
│ ├── settings/ # Account + board settings
│ └── +page.svelte # Kanban board (root)
├── app.css # Material Design 3 tokens + Tailwind theme
└── service-worker.ts # Workbox PWA + background sync
messages/
├── en.json # English strings
└── de.json # German strings
hltb-service/ # Optional Python microservice for HowLongToBeat lookups
├── app.py # FastAPI application
├── requirements.txt
└── Dockerfile
Auth Providers
| Provider | Setup |
|---|---|
| Local | Always available – email + password |
Google Cloud Console → OAuth 2.0 Client ID, redirect URI: {BASE_URL}/api/auth/callback/google |
|
| Twitch | Twitch Dev Console → redirect URI: {BASE_URL}/api/auth/callback/twitch. Same credentials enable IGDB game search. |
| Steam | Steam API Key – enables OpenID login + library import |
| Custom OIDC | Any OIDC-compliant provider (Keycloak, Authentik, …) – see below |
Multiple providers can be linked to a single account via the account settings page.
Custom OIDC Providers (Keycloak, Authentik, …)
Any OIDC-compliant identity provider can be added. Add its identifier to ENABLED_AUTH_PROVIDERS, then set the three required env vars. The discovery document is fetched automatically from {ISSUER_URL}/.well-known/openid-configuration.
Env var pattern (replace {ID} with the provider identifier in SCREAMING_SNAKE_CASE):
OIDC_{ID}_CLIENT_ID=...
OIDC_{ID}_CLIENT_SECRET=...
OIDC_{ID}_ISSUER_URL=...
OIDC_{ID}_DISPLAY_NAME=... # optional – button label, defaults to capitalized ID
Keycloak
- In Keycloak Admin → your realm → Clients → Create client
- Client ID:
pile-of-shame, Client authentication: on, Standard flow: on - Add redirect URI:
{BASE_URL}/api/auth/callback/keycloak - Copy the client secret from the Credentials tab
ENABLED_AUTH_PROVIDERS=local,keycloak
OIDC_KEYCLOAK_CLIENT_ID=pile-of-shame
OIDC_KEYCLOAK_CLIENT_SECRET=<client-secret>
OIDC_KEYCLOAK_ISSUER_URL=https://keycloak.example.com/realms/myrealm
OIDC_KEYCLOAK_DISPLAY_NAME=Keycloak
Authentik
- In Authentik Admin → Applications → Create an application
- Choose or create an OAuth2/OpenID Provider
- Set redirect URI:
{BASE_URL}/api/auth/callback/authentik - Set scopes:
openid,profile,email - Copy the Client ID and Client Secret from the provider
ENABLED_AUTH_PROVIDERS=local,authentik
OIDC_AUTHENTIK_CLIENT_ID=<client-id>
OIDC_AUTHENTIK_CLIENT_SECRET=<client-secret>
# The slug is the URL-identifier of your Authentik application provider
OIDC_AUTHENTIK_ISSUER_URL=https://auth.example.com/application/o/<slug>
OIDC_AUTHENTIK_DISPLAY_NAME=Authentik
Multiple OIDC providers can be active simultaneously:
ENABLED_AUTH_PROVIDERS=local,google,keycloak,authentik
Internationalisation (i18n)
The app uses Paraglide JS for compile-time, type-safe i18n. Translations live in messages/ as plain JSON files and are compiled into strongly-typed TypeScript at build time – no runtime lookups, full IDE autocompletion.
Supported locales
| Code | Language |
|---|---|
en |
English (base locale) |
de |
German |
The active locale is determined by the URL prefix (/en/, /de/). The base locale (en) is also reachable at /.
Message files
Each locale has a flat JSON file at messages/{locale}.json:
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"greeting": "Hello",
"welcome_user": "Welcome, {name}!"
}
- Keys are
snake_casestrings - Values are plain strings; placeholders use
{variableName}syntax - The
$schemaline must be present
Adding or editing messages
-
Add the key to both locale files –
messages/en.jsonandmessages/de.json// messages/en.json "my_new_key": "Some text" // messages/de.json "my_new_key": "Irgendein Text" -
Recompile Paraglide – this regenerates the typed message functions in
src/lib/paraglide/:npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglideThe dev server (
npm run dev) compiles automatically via the Vite plugin, so the manual step is only needed when running outside of Vite (e.g. standalone scripts or after pulling changes in Docker).
Using messages in components
Import the generated message module and call functions by key:
<script lang="ts">
import * as m from '$paraglide/messages.js';
</script>
<h1>{m.greeting()}</h1><p>{m.welcome_user({ name: 'Alice' })}</p>
- Every key becomes a zero-argument or typed-argument function – TypeScript will error if you forget a required placeholder or mistype a key
- The
$paraglidealias resolves tosrc/lib/paraglide(configured insvelte.config.js)
Using messages in server-side code
The same import works in +page.server.ts, +server.ts, and any server utility:
import * as m from '$paraglide/messages.js';
return fail(400, { error: m.error_generic() });
Adding a new locale
-
Add the locale code to
project.inlang/settings.json:"locales": ["en", "de", "fr"] -
Create
messages/fr.jsonwith all keys translated -
Recompile Paraglide (see above)
-
Paraglide and SvelteKit's routing will pick up the new locale automatically
Docker Stages
| Stage | Used by | Purpose |
|---|---|---|
deps |
internal | npm ci only – shared base layer |
dev |
docker-compose.dev.yml |
Vite dev server, source bind-mounted for hot reload |
manage |
--profile manage |
Management CLI + migration scripts |
builder |
internal | Full production build |
runner |
docker-compose.prod.yml |
Lean runtime image, non-root user |
Compose overlays
| File | Purpose |
|---|---|
docker-compose.dev.yml |
Development stack |
docker-compose.prod.yml |
Production stack |
docker-compose.migrate.yml |
Database migrations (auto-included) |
docker-compose.hltb.yml |
Optional HLTB microservice |