Add a small, tested base for all three clients, and Gitea CI/release workflows

internal/client (new): a typed Go HTTP client for the API - login,
register, logout, me, and full CRUD for games/platforms - shared by the
TUI and the Fyne GUI so request-building, auth and error handling (an
APIError with the HTTP status) live in one tested place instead of being
duplicated. 8 tests against an httptest server covering success, 401, 403
and that the token is stored/cleared at the right times.

cmd/tui (rewrite): drops the old unauthenticated read-only viewer. Now a
login screen (bubbles/textinput) -> a scrollable games table
(bubbles/table) with 'r' to refresh, built as an explicit state machine
(screenLogin/screenLoading/screenGames) so Update's transitions are
testable without a real terminal. 9 tests driving Update() directly.

cmd/gui (rewrite): drops raylib for Fyne. Same shape as the TUI - a login
form, then a games table, with networking on a goroutine and fyne.Do for
the UI updates. The table-cell and login-validation logic are plain
functions with no Fyne/display dependency, so they're tested directly (7
tests) rather than needing Fyne's headless test driver.

internal/api: tagged every operation (Auth/Platforms/Games/Misc) so the
generated OpenAPI spec groups into named services instead of one bucket;
api/openapi.json regenerated. Added CORS middleware (wildcard origin, no
credentials needed since clients use a bearer token, not the cookie) -
needed for the new web client's browser requests to the API's a different
origin.

web/ (new): an Angular 22 + Material app, API client generated from
api/openapi.json via ng-openapi-gen (services: true for per-resource
classes - AuthService/GamesService/PlatformsService - instead of one
generic Api facade). A Session service holds the token/user as signals,
persists the token to localStorage, and is the only thing that calls the
generated AuthService; an HttpInterceptorFn attaches the bearer token to
every request; a CanActivateFn guard protects /games. Login and Games are
standalone, lazy-loaded route components. 21 tests (Vitest, Angular's new
default - no browser/Karma needed) covering the service, interceptor,
guard and both components, run via `npm test` exactly as CI will call it.

Justfile: fixed run-tui/run-gui, which used `go run .../main.go` - broken
now that both packages span multiple files, since that only compiles the
one named file. Added web-install/web-dev/web-build/web-test.

.gitea/workflows/ci.yml (moved from .github, which Gitea doesn't read):
on push to main/develop - gofmt, go vet, go build, go test, go mod tidy
diff (with the Fyne build's system deps installed), sqlc diff, and the
web app's build+test.

.gitea/workflows/release.yml (new): on a vX.Y.Z tag, cross-compiles
api+tui for linux/darwin/windows (amd64+arm64, pure Go, no cgo), builds
the Fyne GUI for linux/amd64 only (cgo + its OpenGL/X11 link means it
can't cross-compile from one Linux runner the way api/tui do, and this
repo has no registered macOS/Windows runner to build it on natively -
documented in the workflow's comments), builds the web app, and creates a
Gitea release with all of it attached via the Gitea API. Written to
Gitea's documented GitHub-Actions-compatible syntax but not executable
from here - there's no Gitea server in this environment to run it
against, unlike everything else in this commit, which was.

Verified: every Go package builds/vets/tests clean; sqlc diff and go mod
tidy -diff are clean; the Angular app builds for production and its test
suite passes via `npm test`/`npm run build` (the exact commands CI runs),
including after a clean `npm ci` from the committed lockfile; the new CORS
config was checked with real preflight and GET requests against a running
server, not just read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Sonnet 5 committed 2026-10-03 12:16:59 +02:00
1 parent 384c4a7217
commit 9972537b63
97 files changed
+11810 -336

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
name: CI
on:
push:
branches: [main, develop]
jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# cmd/gui (Fyne) uses cgo and links against the system's OpenGL/X11
# libraries, so building or testing ./... needs these present even
# though the GUI itself never opens a window in CI.
- name: Install system dependencies for the Fyne GUI build
run: |
sudo apt-get update
sudo apt-get install -y libgl1-mesa-dev xorg-dev
- name: gofmt
run: |
fmt_out="$(gofmt -l .)"
if [ -n "$fmt_out" ]; then
echo "The following files are not gofmt'd:"
echo "$fmt_out"
exit 1
fi
- name: go vet
run: go vet ./...
- name: go build
run: go build ./...
- name: go test
run: go test ./...
- name: go mod tidy (no diff expected)
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
sqlc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: sqlc-dev/setup-sqlc@v4
with:
sqlc-version: "1.31.1"
- name: sqlc diff (generated code must match the schema/queries)
run: sqlc diff
web:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Test
run: npm test
+168
View File
@@ -0,0 +1,168 @@
name: Release
# A pushed tag like v1.2.3 builds every client and attaches the result to a
# Gitea release of the same name. Tag a commit that has already passed CI
# on main; this workflow doesn't re-run those checks.
on:
push:
tags: ["v*.*.*"]
jobs:
# cmd/api and cmd/tui are pure Go (no cgo), so they cross-compile cleanly
# from one Linux runner for every OS/arch below.
build-go:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: windows
goarch: amd64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build api and tui
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
mkdir -p dist
ext=""
if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi
go build -o "dist/completed-api${ext}" ./cmd/api
go build -o "dist/completed-tui${ext}" ./cmd/tui
- name: Archive
run: |
cd dist
base="completed_${{ matrix.goos }}_${{ matrix.goarch }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
zip "../${base}.zip" completed-api.exe completed-tui.exe
else
tar czf "../${base}.tar.gz" completed-api completed-tui
fi
- uses: actions/upload-artifact@v4
with:
name: completed_${{ matrix.goos }}_${{ matrix.goarch }}
path: completed_${{ matrix.goos }}_${{ matrix.goarch }}.*
retention-days: 7
# cmd/gui (Fyne) uses cgo and links the system's native GL/windowing
# libraries, so - unlike build-go above - it can't be cross-compiled for
# other operating systems from this one Linux runner. This job only
# produces a linux/amd64 build. A macOS or Windows build of the GUI would
# need this workflow extended with a job running on a matching
# self-hosted runner (there isn't one registered for this repo today).
build-gui-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1-mesa-dev xorg-dev
- name: Build gui
run: |
mkdir -p dist
go build -o dist/completed-gui ./cmd/gui
- name: Archive
run: tar czf completed-gui_linux_amd64.tar.gz -C dist completed-gui
- uses: actions/upload-artifact@v4
with:
name: completed-gui_linux_amd64
path: completed-gui_linux_amd64.tar.gz
retention-days: 7
build-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build
working-directory: web
run: |
npm ci
npm run build
- name: Archive
run: tar czf completed-web_dist.tar.gz -C web/dist/web .
- uses: actions/upload-artifact@v4
with:
name: completed-web_dist
path: completed-web_dist.tar.gz
retention-days: 7
release:
needs: [build-go, build-gui-linux, build-web]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Create the Gitea release and upload each artifact
env:
# Gitea Actions auto-provides this for the compatibility GITHUB_*
# context; it needs the repo's Settings > Actions permissions set
# to allow writing contents. If your instance doesn't populate it,
# replace with a secret holding a personal/repo access token that
# has repo write access instead.
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
sudo apt-get update && sudo apt-get install -y jq
release_json=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
"${SERVER_URL}/api/v1/repos/${REPO}/releases")
release_id=$(echo "$release_json" | jq -r '.id')
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
echo "Failed to create release: $release_json" >&2
exit 1
fi
for f in artifacts/*; do
name=$(basename "$f")
echo "Uploading $name"
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${f}" \
"${SERVER_URL}/api/v1/repos/${REPO}/releases/${release_id}/assets?name=${name}"
done
-39
View File
@@ -1,39 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: go vet
run: go vet ./...
- name: go test
run: go test ./...
- name: go mod tidy (no diff expected)
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
sqlc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: sqlc-dev/setup-sqlc@v4
with:
sqlc-version: "1.31.1"
- name: sqlc diff (generated code must match the schema/queries)
run: sqlc diff
+22 -4
View File
@@ -12,7 +12,7 @@ build-api:
# Run the API server locally # Run the API server locally
run-api: run-api:
go run ./cmd/api/main.go go run ./cmd/api
# Start the API and Database using Docker Compose # Start the API and Database using Docker Compose
docker-up: docker-up:
@@ -42,17 +42,35 @@ build-tui:
# Run the Terminal UI # Run the Terminal UI
run-tui: run-tui:
go run ./cmd/tui/main.go go run ./cmd/tui
# --- GUI --- # --- GUI ---
# Build the GUI (Raylib) # Build the GUI (Fyne)
build-gui: build-gui:
go build -o bin/gui ./cmd/gui go build -o bin/gui ./cmd/gui
# Run the GUI # Run the GUI
run-gui: run-gui:
go run ./cmd/gui/main.go go run ./cmd/gui
# --- Web ---
# Install web dependencies
web-install:
cd web && npm ci
# Run the web app's dev server
web-dev:
cd web && npm start
# Build the web app for production (output: web/dist/web)
web-build:
cd web && npm run build
# Run the web app's tests
web-test:
cd web && npm test
# --- Database --- # --- Database ---
+42 -9
View File
@@ -5,8 +5,12 @@ your score for it, and whether (and when) you finished it.
## Status ## Status
Each account has its own private collection. The API is usable; there's no Each account has its own private game list, but all accounts share one
GUI yet beyond two read-only clients (see below). platform list (only an admin can add/rename/delete a platform - the first
account ever registered becomes an admin automatically). The API is usable.
There are three small-base clients: a terminal UI, a desktop GUI (Fyne),
and a web app (Angular) — each can log in and show your games, with more
to come.
## Running it ## Running it
@@ -28,7 +32,8 @@ Browse `http://localhost:8080/docs` for the generated API docs.
## Using the API ## Using the API
```sh ```sh
# Register (also logs you in) # Register (also logs you in; the first account ever registered becomes
# an admin, which is required to create platforms - see Status above)
curl -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \ curl -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"a long password","name":"You"}' -d '{"email":"you@example.com","password":"a long password","name":"You"}'
# -> {"token": "...", "user": {...}} # -> {"token": "...", "user": {...}}
@@ -44,19 +49,47 @@ curl localhost:8080/api/games -H "Authorization: Bearer $TOKEN"
A browser-based client can skip the `Authorization` header: register/login A browser-based client can skip the `Authorization` header: register/login
also set an httpOnly `session` cookie. also set an httpOnly `session` cookie.
## Other clients ## Clients
- `just run-tui` — a read-only terminal viewer of `/games` (no login yet) - `just run-tui` — a terminal UI (Bubble Tea). Log in, see your games in a
- `just run-gui` — a read-only raylib desktop viewer of `/games` (no login scrollable table, `r` to refresh.
yet; planned to move to Fyne) - `just run-gui` — a desktop GUI (Fyne). Log in, see your games in a table,
refresh.
- `just web-dev` — a web app (Angular + Material) at `http://localhost:4200`,
proxying nothing itself; it talks directly to the API at `localhost:8080`
(CORS is permissive enough for local dev as-is). Log in, see your games.
All three point at `http://localhost:8080` by default. The TUI and GUI read
`COMPLETED_API_URL` to use a different API; the web app's URL is a build-time
constant in `web/src/app/app.config.ts`.
Each client is a small base - listing is there, but adding/editing a game
or managing platforms isn't yet.
## Development ## Development
```sh ```sh
just test # go test ./... just test # go test ./...
just sqlc # regenerate internal/db after changing db/query.sql or a migration just sqlc # regenerate internal/db after changing db/query.sql or a migration
just openapi # regenerate api/openapi.json after changing a route
just web-test # web app unit tests (Vitest)
just web-build # production build of the web app
``` ```
Schema changes go in `db/migrations` as a new `<timestamp>_<name>.up.sql` / Schema changes go in `db/migrations` as a new `<timestamp>_<name>.up.sql` /
`.down.sql` pair; `sqlc.yaml` reads the schema from that directory directly, `.down.sql` pair; `sqlc.yaml` reads the schema from that directory directly,
so there's no separate schema file to keep in sync. so there's no separate schema file to keep in sync.
The TUI and GUI share `internal/client`, a typed Go HTTP client for the API.
The web app's API client (`web/src/app/api/`) is generated from
`api/openapi.json` by `ng-openapi-gen` (`cd web && npx ng-openapi-gen`) and
shouldn't be hand-edited.
## CI/CD
`.gitea/workflows/ci.yml` builds and tests everything (Go + web) on every
push to `main` or `develop`. `.gitea/workflows/release.yml` builds all three
clients and attaches them to a Gitea release when a `vX.Y.Z` tag is pushed;
see that file's comments for its one real limitation (the desktop GUI is
only built for linux/amd64, since it needs cgo and this repo has no
registered macOS/Windows runner to cross-build it on).
+1 -1
View File
@@ -1 +1 @@
{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"Line truncated {"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"Line truncated
+9
View File
@@ -131,6 +131,15 @@ func main() {
e := echo.New() e := echo.New()
e.Use(middleware.Logger()) e.Use(middleware.Logger())
e.Use(middleware.Recover()) e.Use(middleware.Recover())
// The web client (e.g. the Angular dev server on localhost:4200) calls
// this API from a different origin. It authenticates with a bearer
// token, not cookies, so this doesn't need AllowCredentials - which
// means a wildcard origin is safe here. Tighten AllowOrigins to the
// real deployed origin(s) if this ever serves more than local dev.
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAuthorization},
}))
humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0")) humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0"))
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"context"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
"completed/internal/client"
"completed/internal/model"
)
// guiApp holds the window and the single client all screens share. It's a
// small base: log in, see your games in a table, refresh. Adding/editing
// games is for a later pass.
type guiApp struct {
client *client.Client
window fyne.Window
}
func newGUIApp(c *client.Client, w fyne.Window) *guiApp {
return &guiApp{client: c, window: w}
}
// showLogin builds and displays the login form. It's the window's initial
// content.
func (a *guiApp) showLogin() {
email := widget.NewEntry()
email.SetPlaceHolder("email")
password := widget.NewPasswordEntry()
password.SetPlaceHolder("password")
status := widget.NewLabel("")
form := widget.NewForm(
widget.NewFormItem("Email", email),
widget.NewFormItem("Password", password),
)
form.SubmitText = "Log in"
form.OnSubmit = func() {
if err := validateLogin(email.Text, password.Text); err != nil {
status.SetText(err.Error())
return
}
status.SetText("Logging in...")
a.login(email.Text, password.Text, status)
}
a.window.SetContent(container.NewVBox(
widget.NewLabelWithStyle("Completed", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
form,
status,
))
}
// login runs the login request off the UI goroutine, then either shows the
// games screen or reports the error back on the login form - both via
// fyne.Do, since Fyne widgets may only be touched from the UI goroutine.
func (a *guiApp) login(email, password string, status *widget.Label) {
go func() {
_, err := a.client.Login(context.Background(), email, password)
if err != nil {
fyne.Do(func() { status.SetText(err.Error()) })
return
}
fyne.Do(a.showGames)
}()
}
// showGames displays the games table and fetches the current list.
func (a *guiApp) showGames() {
var games []model.Game
table := widget.NewTable(
func() (int, int) { return gameTableSize(games) },
func() fyne.CanvasObject { return widget.NewLabel("") },
func(id widget.TableCellID, obj fyne.CanvasObject) {
obj.(*widget.Label).SetText(gameCellText(games, id.Row, id.Col))
},
)
for i := range gameColumns {
table.SetColumnWidth(i, 120)
}
status := widget.NewLabel("Loading...")
refresh := widget.NewButton("Refresh", func() {
a.fetchGames(table, &games, status)
})
a.window.SetContent(container.NewBorder(
container.NewVBox(
widget.NewLabelWithStyle("Completed", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
container.NewHBox(refresh, status),
),
nil, nil, nil,
table,
))
a.fetchGames(table, &games, status)
}
func (a *guiApp) fetchGames(table *widget.Table, games *[]model.Game, status *widget.Label) {
go func() {
result, err := a.client.ListGames(context.Background())
fyne.Do(func() {
if err != nil {
status.SetText("Error: " + err.Error())
dialog.ShowError(err, a.window)
return
}
*games = result
status.SetText("")
table.Refresh()
})
}()
}
+12 -135
View File
@@ -1,145 +1,22 @@
package main package main
import ( import (
"encoding/json" "os"
"fmt"
"net/http"
"time"
rl "github.com/gen2brain/raylib-go/raylib" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"completed/internal/client"
) )
type Game struct {
ID int64 `json:"id"`
Name string `json:"name"`
PlatformID int64 `json:"platform_id"`
Score int32 `json:"score"`
ReleaseYear string `json:"release_year"`
Finished string `json:"finished,omitempty"`
}
type GamesResponse struct {
Body []Game `json:"body"`
}
func main() { func main() {
rl.InitWindow(800, 600, "My Game Collection - Raylib") fyneApp := app.New()
defer rl.CloseWindow() window := fyneApp.NewWindow("Completed")
window.Resize(fyne.NewSize(700, 500))
rl.SetTargetFPS(60) c := client.New(os.Getenv("COMPLETED_API_URL"))
a := newGUIApp(c, window)
a.showLogin()
loading := false window.ShowAndRun()
var err error
var games []Game
// Channel to receive data from background goroutine
dataChan := make(chan []Game)
errChan := make(chan error)
func() {
loading = true
go fetchGames(dataChan, errChan)
}()
for !rl.WindowShouldClose() {
rl.BeginDrawing()
rl.ClearBackground(rl.RayWhite)
// Header
rl.DrawRectangle(0, 0, 800, 60, rl.NewColor(100, 100, 255, 255))
rl.DrawText("My Game Collection", 20, 15, 30, rl.White)
startY := 80
if loading {
rl.DrawText("Loading games...", 20, int32(startY), 20, rl.Gray)
// Simple spinner animation
if int(rl.GetTime()*4)%2 == 0 {
rl.DrawText(".", 180, int32(startY), 20, rl.Gray)
}
} else if err != nil {
rl.DrawText(fmt.Sprintf("Error: %v", err), 20, int32(startY), 20, rl.Red)
rl.DrawText("Press SPACE to retry", 20, int32(startY)+30, 20, rl.DarkGray)
} else {
if len(games) == 0 {
rl.DrawText("No games found in database.", 20, int32(startY), 20, rl.DarkGray)
} else {
for i, g := range games {
yPos := int32(startY + (i * 40))
// Row background (alternating)
if i%2 == 0 {
rl.DrawRectangle(20, yPos-5, 760, 35, rl.NewColor(240, 240, 240, 255))
}
// Game Name
rl.DrawText(g.Name, 30, yPos, 20, rl.Black)
// Year
rl.DrawText(g.ReleaseYear, 350, yPos, 20, rl.DarkGray)
// Score
scoreColor := rl.Orange
if g.Score >= 90 {
scoreColor = rl.Green
} else if g.Score < 70 {
scoreColor = rl.Red
}
rl.DrawText(fmt.Sprintf("%d", g.Score), 500, yPos, 20, scoreColor)
// Finished Status
if g.Finished != "" {
rl.DrawText("Finished", 600, yPos, 20, rl.NewColor(0, 200, 0, 255))
}
}
}
}
// Footer / Help
rl.DrawText("Press SPACE to refresh", 20, 570, 10, rl.Gray)
rl.EndDrawing()
// Inputs
if rl.IsKeyPressed(rl.KeySpace) && !loading {
loading = true
err = nil
games = nil
go fetchGames(dataChan, errChan)
}
// Check channels (non-blocking)
select {
case data := <-dataChan:
loading = false
games = data
case e := <-errChan:
loading = false
err = e
default:
}
}
}
func fetchGames(dataChan chan<- []Game, errChan chan<- error) {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("http://localhost:8080/games")
if err != nil {
errChan <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errChan <- fmt.Errorf("status code: %d", resp.StatusCode)
return
}
var data GamesResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
errChan <- err
return
}
dataChan <- data.Body
} }
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"fmt"
"completed/internal/model"
)
// gameColumns are the games table's column headers, in order. Kept next to
// gameCellText so the two can't drift apart.
var gameColumns = []string{"Name", "Platform", "Score", "Released", "Finished"}
// gameTableSize returns the (rows, cols) Fyne's widget.Table needs from its
// Length function: one row per game, plus a header row.
func gameTableSize(games []model.Game) (rows, cols int) {
return len(games) + 1, len(gameColumns)
}
// gameCellText returns the text for row/col in the games table. row 0 is
// the header; row i (i>0) is games[i-1]. It has no Fyne dependency, so it's
// tested directly without needing any widget or display.
func gameCellText(games []model.Game, row, col int) string {
if row == 0 {
return gameColumns[col]
}
g := games[row-1]
switch col {
case 0:
return g.Name
case 1:
return g.PlatformName
case 2:
return fmt.Sprintf("%d", g.Score)
case 3:
return g.ReleaseDate
case 4:
if g.Finished != "" {
return "✓"
}
return ""
default:
return ""
}
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"testing"
"completed/internal/model"
)
func TestGameTableSize(t *testing.T) {
games := []model.Game{{Name: "Doom"}, {Name: "Half-Life"}}
rows, cols := gameTableSize(games)
if rows != 3 {
t.Errorf("rows = %d, want 3 (header + 2 games)", rows)
}
if cols != len(gameColumns) {
t.Errorf("cols = %d, want %d", cols, len(gameColumns))
}
}
func TestGameTableSizeEmpty(t *testing.T) {
rows, _ := gameTableSize(nil)
if rows != 1 {
t.Errorf("rows = %d, want 1 (header only)", rows)
}
}
func TestGameCellTextHeader(t *testing.T) {
for col, want := range gameColumns {
if got := gameCellText(nil, 0, col); got != want {
t.Errorf("gameCellText(nil, 0, %d) = %q, want %q", col, got, want)
}
}
}
func TestGameCellTextDataRows(t *testing.T) {
games := []model.Game{
{Name: "Doom", PlatformName: "PC", Score: 95, ReleaseDate: "1993-12-10"},
{Name: "Half-Life", PlatformName: "PC", Score: 96, ReleaseDate: "1998-11-19", Finished: "1999-01-01"},
}
tests := []struct {
row, col int
want string
}{
{1, 0, "Doom"},
{1, 1, "PC"},
{1, 2, "95"},
{1, 3, "1993-12-10"},
{1, 4, ""},
{2, 0, "Half-Life"},
{2, 4, "✓"},
}
for _, tt := range tests {
if got := gameCellText(games, tt.row, tt.col); got != tt.want {
t.Errorf("gameCellText(games, %d, %d) = %q, want %q", tt.row, tt.col, got, tt.want)
}
}
}
+16
View File
@@ -0,0 +1,16 @@
package main
import "errors"
// validateLogin checks the login form before it's sent to the server, so a
// blank field shows an immediate, specific error instead of an HTTP
// round trip followed by a generic one.
func validateLogin(email, password string) error {
if email == "" {
return errors.New("email is required")
}
if password == "" {
return errors.New("password is required")
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
package main
import "testing"
func TestValidateLogin(t *testing.T) {
tests := []struct {
name, email, password string
wantErr bool
}{
{"valid", "a@example.com", "secret", false},
{"empty email", "", "secret", true},
{"empty password", "a@example.com", "", true},
{"both empty", "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateLogin(tt.email, tt.password)
if (err != nil) != tt.wantErr {
t.Errorf("validateLogin(%q, %q) error = %v, wantErr %v", tt.email, tt.password, err, tt.wantErr)
}
})
}
}
+6 -136
View File
@@ -1,150 +1,20 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"net/http"
"os" "os"
"strings"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"completed/internal/client"
) )
// Styles
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1)
itemStyle = lipgloss.NewStyle().
PaddingLeft(2)
scoreStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFD700")) // Gold color
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF5555")).
Bold(true)
)
type Game struct {
ID int64 `json:"id"`
Name string `json:"name"`
PlatformID int64 `json:"platform_id"`
Score int32 `json:"score"`
ReleaseYear string `json:"release_year"`
Finished string `json:"finished,omitempty"`
}
type GamesResponse struct {
Body []Game `json:"body"`
}
type Model struct {
spinner spinner.Model
loading bool
err error
games []Game
quitting bool
}
func InitialModel() Model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return Model{spinner: s, loading: true}
}
type loadedMsg []Game
type errMsg error
func fetchGames() tea.Msg {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("http://localhost:8080/games")
if err != nil {
return errMsg(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errMsg(fmt.Errorf("status code: %d", resp.StatusCode))
}
var data GamesResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errMsg(err)
}
return loadedMsg(data.Body)
}
func (m Model) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, fetchGames)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
m.quitting = true
return m, tea.Quit
}
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case loadedMsg:
m.loading = false
m.games = msg
return m, nil
case errMsg:
m.loading = false
m.err = msg
return m, nil
}
return m, nil
}
func (m Model) View() string {
if m.quitting {
return "Bye!\n"
}
s := titleStyle.Render("My Game Collection") + "\n\n"
if m.loading {
s += fmt.Sprintf("%s Loading games...", m.spinner.View())
} else if m.err != nil {
s += errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
s += "\nMake sure the API is running on localhost:8080"
} else {
if len(m.games) == 0 {
s += "No games found."
} else {
for _, g := range m.games {
score := scoreStyle.Render(fmt.Sprintf("★ %d", g.Score))
line := fmt.Sprintf("• %s (%s) - %s", g.Name, g.ReleaseYear, score)
if g.Finished != "" {
line += " [✓ Finished]"
}
s += itemStyle.Render(line) + "\n"
}
}
}
s += strings.Repeat("\n", 2) + lipgloss.NewStyle().Foreground(lipgloss.Color("#555")).Render("Press 'q' to quit")
return s
}
func main() { func main() {
p := tea.NewProgram(InitialModel()) c := client.New(os.Getenv("COMPLETED_API_URL"))
p := tea.NewProgram(NewModel(c))
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err) fmt.Fprintf(os.Stderr, "Alas, there's been an error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
} }
+298
View File
@@ -0,0 +1,298 @@
package main
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"completed/internal/client"
"completed/internal/model"
)
// screen is which part of the app is currently showing. The model only
// ever does one thing at a time, so a single enum (rather than a flag per
// feature) keeps Update's branching simple.
type screen int
const (
screenLogin screen = iota
screenLoading
screenGames
)
// focusField is which of the two login inputs has keyboard focus.
type focusField int
const (
focusEmail focusField = iota
focusPassword
)
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF5555")).
Bold(true)
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#555"))
focusedInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4"))
)
// Model is the TUI's whole state. It's a small base: log in, see your
// games in a scrollable table, refresh. Adding/editing games is for a
// later pass; the point here is a working, tested skeleton the rest of
// the TUI can grow from.
type Model struct {
client *client.Client
screen screen
err error
// Login screen.
emailInput textinput.Model
passwordInput textinput.Model
focus focusField
// Loading screen (used for both the initial post-login fetch and a
// manual refresh).
spinner spinner.Model
// Games screen.
table table.Model
user model.User
}
// NewModel builds the initial Model, showing the login screen.
func NewModel(c *client.Client) Model {
email := textinput.New()
email.Placeholder = "email"
email.Focus()
email.CharLimit = 254
password := textinput.New()
password.Placeholder = "password"
password.EchoMode = textinput.EchoPassword
password.CharLimit = 254
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return Model{
client: c,
screen: screenLogin,
emailInput: email,
passwordInput: password,
spinner: s,
table: newGamesTable(nil),
}
}
func newGamesTable(games []model.Game) table.Model {
columns := []table.Column{
{Title: "Name", Width: 30},
{Title: "Platform", Width: 16},
{Title: "Score", Width: 7},
{Title: "Released", Width: 12},
{Title: "Finished", Width: 10},
}
t := table.New(
table.WithColumns(columns),
table.WithRows(gamesToRows(games)),
table.WithFocused(true),
table.WithHeight(15),
)
return t
}
func gamesToRows(games []model.Game) []table.Row {
rows := make([]table.Row, len(games))
for i, g := range games {
finished := ""
if g.Finished != "" {
finished = "✓"
}
rows[i] = table.Row{g.Name, g.PlatformName, fmt.Sprintf("%d", g.Score), g.ReleaseDate, finished}
}
return rows
}
// --- Messages ---
type loginSucceededMsg struct{ user model.User }
type loginFailedMsg struct{ err error }
type gamesLoadedMsg struct{ games []model.Game }
type gamesLoadFailedMsg struct{ err error }
// --- Commands ---
func doLogin(c *client.Client, email, password string) tea.Cmd {
return func() tea.Msg {
resp, err := c.Login(context.Background(), email, password)
if err != nil {
return loginFailedMsg{err: err}
}
return loginSucceededMsg{user: resp.User}
}
}
func doListGames(c *client.Client) tea.Cmd {
return func() tea.Msg {
games, err := c.ListGames(context.Background())
if err != nil {
return gamesLoadFailedMsg{err: err}
}
return gamesLoadedMsg{games: games}
}
}
// --- bubbletea.Model ---
func (m Model) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, textinput.Blink)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.updateKey(msg)
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case loginSucceededMsg:
m.user = msg.user
m.err = nil
m.screen = screenLoading
return m, doListGames(m.client)
case loginFailedMsg:
m.err = msg.err
return m, nil
case gamesLoadedMsg:
m.table = newGamesTable(msg.games)
m.err = nil
m.screen = screenGames
return m, nil
case gamesLoadFailedMsg:
m.err = msg.err
m.screen = screenGames
return m, nil
}
return m, nil
}
func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
switch m.screen {
case screenLogin:
return m.updateLoginKey(msg)
case screenGames:
if msg.String() == "q" {
return m, tea.Quit
}
if msg.String() == "r" {
m.screen = screenLoading
m.err = nil
return m, doListGames(m.client)
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
return m, nil
}
func (m Model) updateLoginKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "tab", "shift+tab", "down", "up":
if m.focus == focusEmail {
m.focus = focusPassword
m.emailInput.Blur()
m.passwordInput.Focus()
} else {
m.focus = focusEmail
m.passwordInput.Blur()
m.emailInput.Focus()
}
return m, nil
case "enter":
m.err = nil
m.screen = screenLoading
return m, doLogin(m.client, m.emailInput.Value(), m.passwordInput.Value())
}
var cmd tea.Cmd
if m.focus == focusEmail {
m.emailInput, cmd = m.emailInput.Update(msg)
} else {
m.passwordInput, cmd = m.passwordInput.Update(msg)
}
return m, cmd
}
func (m Model) View() string {
switch m.screen {
case screenLogin:
return m.viewLogin()
case screenLoading:
return fmt.Sprintf("%s Loading...\n", m.spinner.View())
case screenGames:
return m.viewGames()
}
return ""
}
func (m Model) viewLogin() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Completed") + "\n\n")
emailLabel, passwordLabel := "Email", "Password"
if m.focus == focusEmail {
emailLabel = focusedInputStyle.Render(emailLabel)
} else {
passwordLabel = focusedInputStyle.Render(passwordLabel)
}
b.WriteString(emailLabel + "\n" + m.emailInput.View() + "\n\n")
b.WriteString(passwordLabel + "\n" + m.passwordInput.View() + "\n\n")
if m.err != nil {
b.WriteString(errorStyle.Render("Error: "+m.err.Error()) + "\n\n")
}
b.WriteString(helpStyle.Render("tab: switch field · enter: log in · ctrl+c: quit"))
return b.String()
}
func (m Model) viewGames() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Completed") + " " + m.user.Name + "\n\n")
if m.err != nil {
b.WriteString(errorStyle.Render("Error: "+m.err.Error()) + "\n\n")
}
b.WriteString(m.table.View() + "\n\n")
b.WriteString(helpStyle.Render("r: refresh · q: quit"))
return b.String()
}
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"errors"
"testing"
tea "github.com/charmbracelet/bubbletea"
"completed/internal/client"
"completed/internal/model"
)
// key builds the tea.KeyMsg Update expects for a named key, matching what
// bubbletea itself constructs from terminal input.
func key(s string) tea.KeyMsg {
switch s {
case "enter":
return tea.KeyMsg{Type: tea.KeyEnter}
case "tab":
return tea.KeyMsg{Type: tea.KeyTab}
case "ctrl+c":
return tea.KeyMsg{Type: tea.KeyCtrlC}
default:
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
}
}
func update(t *testing.T, m Model, msg tea.Msg) Model {
t.Helper()
newModel, _ := m.Update(msg)
m, ok := newModel.(Model)
if !ok {
t.Fatalf("Update returned %T, want Model", newModel)
}
return m
}
func TestTypingFillsFocusedField(t *testing.T) {
m := NewModel(client.New(""))
m = update(t, m, key("a"))
m = update(t, m, key("b"))
if got := m.emailInput.Value(); got != "ab" {
t.Errorf("emailInput.Value() = %q, want %q", got, "ab")
}
if got := m.passwordInput.Value(); got != "" {
t.Errorf("passwordInput.Value() = %q, want empty while email is focused", got)
}
m = update(t, m, key("tab"))
m = update(t, m, key("c"))
if got := m.passwordInput.Value(); got != "c" {
t.Errorf("passwordInput.Value() = %q, want %q after tab switches focus", got, "c")
}
}
func TestEnterOnLoginStartsLoading(t *testing.T) {
m := NewModel(client.New(""))
m.emailInput.SetValue("a@example.com")
m.passwordInput.SetValue("secret")
newModel, cmd := m.Update(key("enter"))
m = newModel.(Model)
if m.screen != screenLoading {
t.Errorf("screen = %v, want screenLoading", m.screen)
}
if cmd == nil {
t.Error("expected a command to perform the login request, got nil")
}
}
func TestLoginSucceededTransitionsToLoadingGames(t *testing.T) {
m := NewModel(client.New(""))
m.screen = screenLoading
newModel, cmd := m.Update(loginSucceededMsg{user: model.User{Name: "Alice"}})
m = newModel.(Model)
if m.screen != screenLoading {
t.Errorf("screen = %v, want to stay screenLoading while games load", m.screen)
}
if m.user.Name != "Alice" {
t.Errorf("user.Name = %q, want Alice", m.user.Name)
}
if cmd == nil {
t.Error("expected a command to fetch games, got nil")
}
}
func TestLoginFailedStaysOnLoginWithError(t *testing.T) {
m := NewModel(client.New(""))
m.screen = screenLoading
wantErr := errors.New("invalid email or password")
m = update(t, m, loginFailedMsg{err: wantErr})
if m.err != wantErr {
t.Errorf("err = %v, want %v", m.err, wantErr)
}
}
func TestGamesLoadedPopulatesTableAndSwitchesScreen(t *testing.T) {
m := NewModel(client.New(""))
m.screen = screenLoading
games := []model.Game{
{ID: "g1", Name: "Doom", PlatformName: "PC", Score: 95, ReleaseDate: "1993-12-10"},
{ID: "g2", Name: "Half-Life", PlatformName: "PC", Score: 96, ReleaseDate: "1998-11-19", Finished: "1999-01-01"},
}
m = update(t, m, gamesLoadedMsg{games: games})
if m.screen != screenGames {
t.Errorf("screen = %v, want screenGames", m.screen)
}
rows := m.table.Rows()
if len(rows) != 2 {
t.Fatalf("table has %d rows, want 2", len(rows))
}
if rows[0][0] != "Doom" || rows[0][4] != "" {
t.Errorf("row 0 = %v, want Doom unfinished", rows[0])
}
if rows[1][0] != "Half-Life" || rows[1][4] != "✓" {
t.Errorf("row 1 = %v, want Half-Life finished", rows[1])
}
}
func TestGamesLoadFailedShowsErrorOnGamesScreen(t *testing.T) {
m := NewModel(client.New(""))
m.screen = screenLoading
wantErr := errors.New("network error")
m = update(t, m, gamesLoadFailedMsg{err: wantErr})
if m.screen != screenGames {
t.Errorf("screen = %v, want screenGames (so 'r' can retry)", m.screen)
}
if m.err != wantErr {
t.Errorf("err = %v, want %v", m.err, wantErr)
}
}
func TestRefreshOnGamesScreenReloads(t *testing.T) {
m := NewModel(client.New(""))
m.screen = screenGames
m.table = newGamesTable([]model.Game{{ID: "g1", Name: "Doom"}})
newModel, cmd := m.Update(key("r"))
m = newModel.(Model)
if m.screen != screenLoading {
t.Errorf("screen = %v, want screenLoading", m.screen)
}
if cmd == nil {
t.Error("expected a command to refetch games, got nil")
}
}
func TestQuitOnGamesScreen(t *testing.T) {
m := NewModel(client.New(""))
m.screen = screenGames
_, cmd := m.Update(key("q"))
if cmd == nil {
t.Fatal("expected tea.Quit, got nil")
}
}
func TestCtrlCQuitsFromAnyScreen(t *testing.T) {
for _, s := range []screen{screenLogin, screenLoading, screenGames} {
m := NewModel(client.New(""))
m.screen = s
_, cmd := m.Update(key("ctrl+c"))
if cmd == nil {
t.Errorf("screen %v: expected tea.Quit on ctrl+c, got nil", s)
}
}
}
+29 -4
View File
@@ -3,11 +3,11 @@ module completed
go 1.25.4 go 1.25.4
require ( require (
fyne.io/fyne/v2 v2.8.1
github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/danielgtaylor/huma/v2 v2.34.1 github.com/danielgtaylor/huma/v2 v2.34.1
github.com/gen2brain/raylib-go/raylib v0.55.1
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6 github.com/jackc/pgx/v5 v5.7.6
@@ -17,35 +17,60 @@ require (
) )
require ( require (
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FyshOS/fancyfs v0.0.1 // indirect
github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/ebitengine/purego v0.7.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
github.com/fyne-io/glfw-js v0.4.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.0 // indirect
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a // indirect
github.com/go-text/render v0.2.1 // indirect
github.com/go-text/typesetting v0.3.4 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
+64 -8
View File
@@ -1,9 +1,23 @@
fyne.io/fyne/v2 v2.8.1 h1:EztGuE2W3Qhd0cWVmU+h5rkzNezUD1To6UqsoLQYUIM=
fyne.io/fyne/v2 v2.8.1/go.mod h1:kpeuFrClm0fiAgJYr2soTfwKMT5rzNcSKzmgGjxvHOY=
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 h1:149/+Wa5EsLLXfyj2pdTmvnQf2VIlgCIwSjcCTHYhIo=
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -16,13 +30,16 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ= github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ=
github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk= github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -38,24 +55,46 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gen2brain/raylib-go/raylib v0.55.1 h1:1rdc10WvvYjtj7qijHnV9T38/WuvlT6IIL+PaZ6cNA8= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/gen2brain/raylib-go/raylib v0.55.1/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo=
github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a h1:HWK0MBggT/T6YH7VffE10xBIhqeTq8JzIUPJXrRy87g=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -66,6 +105,10 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -84,8 +127,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
@@ -98,6 +141,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -107,11 +154,16 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -123,6 +175,8 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
@@ -137,6 +191,8 @@ golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+4
View File
@@ -65,6 +65,7 @@ func newSession(ctx context.Context, queries db.Querier, userID uuid.UUID) (stri
func registerAuthRoutes(api huma.API, queries db.Querier) { func registerAuthRoutes(api huma.API, queries db.Querier) {
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "register", OperationID: "register",
Tags: []string{"Auth"},
Method: http.MethodPost, Method: http.MethodPost,
Path: "/api/auth/register", Path: "/api/auth/register",
Summary: "Register a new account", Summary: "Register a new account",
@@ -100,6 +101,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) {
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "login", OperationID: "login",
Tags: []string{"Auth"},
Method: http.MethodPost, Method: http.MethodPost,
Path: "/api/auth/login", Path: "/api/auth/login",
Summary: "Log in to an existing account", Summary: "Log in to an existing account",
@@ -133,6 +135,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) {
logoutOp := huma.Operation{ logoutOp := huma.Operation{
OperationID: "logout", OperationID: "logout",
Tags: []string{"Auth"},
Method: http.MethodPost, Method: http.MethodPost,
Path: "/api/auth/logout", Path: "/api/auth/logout",
Summary: "Log out of the current session", Summary: "Log out of the current session",
@@ -149,6 +152,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) {
meOp := huma.Operation{ meOp := huma.Operation{
OperationID: "get-me", OperationID: "get-me",
Tags: []string{"Auth"},
Method: http.MethodGet, Method: http.MethodGet,
Path: "/api/auth/me", Path: "/api/auth/me",
Summary: "Get the current user", Summary: "Get the current user",
+11
View File
@@ -17,6 +17,7 @@ func RegisterRoutes(api huma.API, queries db.Querier) {
// Root/Hello Route // Root/Hello Route
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "get-hello", OperationID: "get-hello",
Tags: []string{"Misc"},
Method: http.MethodGet, Method: http.MethodGet,
Path: "/hello", Path: "/hello",
Summary: "Say Hello", Summary: "Say Hello",
@@ -52,6 +53,7 @@ func RegisterRoutes(api huma.API, queries db.Querier) {
func registerPlatformRoutes(api huma.API, queries db.Querier) { func registerPlatformRoutes(api huma.API, queries db.Querier) {
listOp := huma.Operation{ listOp := huma.Operation{
OperationID: "list-platforms", OperationID: "list-platforms",
Tags: []string{"Platforms"},
Method: http.MethodGet, Method: http.MethodGet,
Path: "/api/platforms", Path: "/api/platforms",
Summary: "List Platforms", Summary: "List Platforms",
@@ -71,6 +73,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
getOp := huma.Operation{ getOp := huma.Operation{
OperationID: "get-platform", OperationID: "get-platform",
Tags: []string{"Platforms"},
Method: http.MethodGet, Method: http.MethodGet,
Path: "/api/platforms/{id}", Path: "/api/platforms/{id}",
Summary: "Get Platform", Summary: "Get Platform",
@@ -88,6 +91,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
createOp := huma.Operation{ createOp := huma.Operation{
OperationID: "create-platform", OperationID: "create-platform",
Tags: []string{"Platforms"},
Method: http.MethodPost, Method: http.MethodPost,
Path: "/api/platforms", Path: "/api/platforms",
Summary: "Create Platform", Summary: "Create Platform",
@@ -103,6 +107,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
updateOp := huma.Operation{ updateOp := huma.Operation{
OperationID: "update-platform", OperationID: "update-platform",
Tags: []string{"Platforms"},
Method: http.MethodPut, Method: http.MethodPut,
Path: "/api/platforms/{id}", Path: "/api/platforms/{id}",
Summary: "Update Platform", Summary: "Update Platform",
@@ -124,6 +129,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
deleteOp := huma.Operation{ deleteOp := huma.Operation{
OperationID: "delete-platform", OperationID: "delete-platform",
Tags: []string{"Platforms"},
Method: http.MethodDelete, Method: http.MethodDelete,
Path: "/api/platforms/{id}", Path: "/api/platforms/{id}",
Summary: "Delete Platform", Summary: "Delete Platform",
@@ -185,6 +191,7 @@ func parseDate(s string) (pgtype.Date, error) {
func registerGameRoutes(api huma.API, queries db.Querier) { func registerGameRoutes(api huma.API, queries db.Querier) {
listOp := huma.Operation{ listOp := huma.Operation{
OperationID: "list-games", OperationID: "list-games",
Tags: []string{"Games"},
Method: http.MethodGet, Method: http.MethodGet,
Path: "/api/games", Path: "/api/games",
Summary: "List Games", Summary: "List Games",
@@ -212,6 +219,7 @@ func registerGameRoutes(api huma.API, queries db.Querier) {
getOp := huma.Operation{ getOp := huma.Operation{
OperationID: "get-game", OperationID: "get-game",
Tags: []string{"Games"},
Method: http.MethodGet, Method: http.MethodGet,
Path: "/api/games/{id}", Path: "/api/games/{id}",
Summary: "Get Game", Summary: "Get Game",
@@ -237,6 +245,7 @@ func registerGameRoutes(api huma.API, queries db.Querier) {
createOp := huma.Operation{ createOp := huma.Operation{
OperationID: "create-game", OperationID: "create-game",
Tags: []string{"Games"},
Method: http.MethodPost, Method: http.MethodPost,
Path: "/api/games", Path: "/api/games",
Summary: "Create Game", Summary: "Create Game",
@@ -280,6 +289,7 @@ func registerGameRoutes(api huma.API, queries db.Querier) {
updateOp := huma.Operation{ updateOp := huma.Operation{
OperationID: "update-game", OperationID: "update-game",
Tags: []string{"Games"},
Method: http.MethodPut, Method: http.MethodPut,
Path: "/api/games/{id}", Path: "/api/games/{id}",
Summary: "Update Game", Summary: "Update Game",
@@ -327,6 +337,7 @@ func registerGameRoutes(api huma.API, queries db.Querier) {
deleteOp := huma.Operation{ deleteOp := huma.Operation{
OperationID: "delete-game", OperationID: "delete-game",
Tags: []string{"Games"},
Method: http.MethodDelete, Method: http.MethodDelete,
Path: "/api/games/{id}", Path: "/api/games/{id}",
Summary: "Delete Game", Summary: "Delete Game",
+218
View File
@@ -0,0 +1,218 @@
// Package client is a thin, typed HTTP client for the Completed API. It's
// the shared base for the TUI and the Fyne GUI: both hold a *Client and
// call its methods instead of each hand-rolling net/http calls, so request
// building, auth, and error handling live in one tested place.
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"completed/internal/model"
)
// DefaultBaseURL is used when no other base URL is configured. It matches
// cmd/api's own default port.
const DefaultBaseURL = "http://localhost:8080"
// Client talks to the Completed API. The zero value is not usable; create
// one with New. A Client is safe for concurrent use once Token is no
// longer being written concurrently with reads - in practice, callers set
// it only right after Login/Register, before starting concurrent calls.
type Client struct {
BaseURL string
Token string
httpClient *http.Client
}
// New returns a Client pointed at baseURL. An empty baseURL is replaced
// with DefaultBaseURL.
func New(baseURL string) *Client {
if baseURL == "" {
baseURL = DefaultBaseURL
}
return &Client{
BaseURL: baseURL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// APIError is returned for any non-2xx response. It carries the status
// code so callers can distinguish, say, a 401 (show "wrong password") from
// a 500 (show "something went wrong").
type APIError struct {
Status int
Title string
Detail string
}
func (e *APIError) Error() string {
if e.Detail != "" {
return e.Detail
}
if e.Title != "" {
return e.Title
}
return fmt.Sprintf("request failed with status %d", e.Status)
}
// huma's error body shape; see internal/api's ErrorModel (from the huma
// package) in api/openapi.json.
type errorBody struct {
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail"`
}
// do sends an HTTP request and decodes a JSON response into out (if out is
// non-nil). It attaches the bearer token, if set, and turns any non-2xx
// response into an *APIError.
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("encode request body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, bodyReader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 300 {
var eb errorBody
_ = json.Unmarshal(data, &eb) // best-effort; fall back to status alone
return &APIError{Status: resp.StatusCode, Title: eb.Title, Detail: eb.Detail}
}
if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}
// --- Auth ---
func (c *Client) Register(ctx context.Context, email, password, name string) (model.AuthResponse, error) {
var resp model.AuthResponse
err := c.do(ctx, http.MethodPost, "/api/auth/register", model.RegisterInput{
Email: email, Password: password, Name: name,
}, &resp)
if err == nil {
c.Token = resp.Token
}
return resp, err
}
func (c *Client) Login(ctx context.Context, email, password string) (model.AuthResponse, error) {
var resp model.AuthResponse
err := c.do(ctx, http.MethodPost, "/api/auth/login", model.LoginInput{
Email: email, Password: password,
}, &resp)
if err == nil {
c.Token = resp.Token
}
return resp, err
}
func (c *Client) Logout(ctx context.Context) error {
err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil)
c.Token = ""
return err
}
func (c *Client) Me(ctx context.Context) (model.User, error) {
var u model.User
err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u)
return u, err
}
// --- Platforms ---
func (c *Client) ListPlatforms(ctx context.Context) ([]model.Platform, error) {
var platforms []model.Platform
err := c.do(ctx, http.MethodGet, "/api/platforms", nil, &platforms)
return platforms, err
}
func (c *Client) GetPlatform(ctx context.Context, id string) (model.Platform, error) {
var p model.Platform
err := c.do(ctx, http.MethodGet, "/api/platforms/"+id, nil, &p)
return p, err
}
// CreatePlatform requires the current user to be an admin; see
// internal/api/middleware.go's addAdminAuth.
func (c *Client) CreatePlatform(ctx context.Context, name string) (model.Platform, error) {
var p model.Platform
err := c.do(ctx, http.MethodPost, "/api/platforms", model.PlatformInput{Name: name}, &p)
return p, err
}
func (c *Client) UpdatePlatform(ctx context.Context, id, name string) (model.Platform, error) {
var p model.Platform
err := c.do(ctx, http.MethodPut, "/api/platforms/"+id, model.PlatformInput{Name: name}, &p)
return p, err
}
func (c *Client) DeletePlatform(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/platforms/"+id, nil, nil)
}
// --- Games ---
func (c *Client) ListGames(ctx context.Context) ([]model.Game, error) {
var games []model.Game
err := c.do(ctx, http.MethodGet, "/api/games", nil, &games)
return games, err
}
func (c *Client) GetGame(ctx context.Context, id string) (model.Game, error) {
var g model.Game
err := c.do(ctx, http.MethodGet, "/api/games/"+id, nil, &g)
return g, err
}
func (c *Client) CreateGame(ctx context.Context, in model.GameInput) (model.Game, error) {
var g model.Game
err := c.do(ctx, http.MethodPost, "/api/games", in, &g)
return g, err
}
func (c *Client) UpdateGame(ctx context.Context, id string, in model.GameInput) (model.Game, error) {
var g model.Game
err := c.do(ctx, http.MethodPut, "/api/games/"+id, in, &g)
return g, err
}
func (c *Client) DeleteGame(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/games/"+id, nil, nil)
}
+186
View File
@@ -0,0 +1,186 @@
package client
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"completed/internal/model"
)
func newTestServer(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return New(srv.URL), srv
}
func writeJSON(t *testing.T, w http.ResponseWriter, status int, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("encode response: %v", err)
}
}
func TestLoginSuccessStoresToken(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/auth/login" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var in model.LoginInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
t.Fatalf("decode body: %v", err)
}
if in.Email != "alice@example.com" || in.Password != "secret" {
t.Fatalf("unexpected credentials: %+v", in)
}
writeJSON(t, w, http.StatusOK, model.AuthResponse{
Token: "tok-123",
User: model.User{ID: "u1", Email: in.Email, Name: "Alice"},
})
})
resp, err := c.Login(t.Context(), "alice@example.com", "secret")
if err != nil {
t.Fatalf("Login: %v", err)
}
if resp.Token != "tok-123" {
t.Errorf("Token = %q, want tok-123", resp.Token)
}
if c.Token != "tok-123" {
t.Errorf("Client.Token = %q, want tok-123 (Login must store it)", c.Token)
}
}
func TestLoginFailureDoesNotStoreToken(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusUnauthorized, map[string]any{
"title": "Unauthorized", "status": 401, "detail": "invalid email or password",
})
})
c.Token = "" // sanity
_, err := c.Login(t.Context(), "alice@example.com", "wrong")
if err == nil {
t.Fatal("expected an error, got nil")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.Status != http.StatusUnauthorized {
t.Errorf("Status = %d, want 401", apiErr.Status)
}
if apiErr.Error() != "invalid email or password" {
t.Errorf("Error() = %q, want the detail message", apiErr.Error())
}
if c.Token != "" {
t.Errorf("Token = %q, want empty after a failed login", c.Token)
}
}
func TestListGamesSendsBearerToken(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer tok-abc" {
t.Errorf("Authorization header = %q, want %q", got, "Bearer tok-abc")
}
writeJSON(t, w, http.StatusOK, []model.Game{
{ID: "g1", Name: "Doom", PlatformName: "PC", Score: 95, ReleaseDate: "1993-12-10"},
})
})
c.Token = "tok-abc"
games, err := c.ListGames(t.Context())
if err != nil {
t.Fatalf("ListGames: %v", err)
}
if len(games) != 1 || games[0].Name != "Doom" {
t.Errorf("games = %+v, want one game named Doom", games)
}
}
func TestListGamesUnauthorized(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusUnauthorized, map[string]any{
"title": "Unauthorized", "status": 401, "detail": "authentication required",
})
})
// No token set.
_, err := c.ListGames(t.Context())
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.Status != http.StatusUnauthorized {
t.Errorf("Status = %d, want 401", apiErr.Status)
}
}
func TestCreateGameSendsBodyAndParsesResponse(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/games" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var in model.GameInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
t.Fatalf("decode body: %v", err)
}
if in.Name != "Half-Life" || in.Score != 96 {
t.Fatalf("unexpected body: %+v", in)
}
writeJSON(t, w, http.StatusOK, model.Game{
ID: "g2", Name: in.Name, PlatformID: in.PlatformID, Score: in.Score, ReleaseDate: in.ReleaseDate,
})
})
g, err := c.CreateGame(t.Context(), model.GameInput{
Name: "Half-Life", PlatformID: "p1", Score: 96, ReleaseDate: "1998-11-19",
})
if err != nil {
t.Fatalf("CreateGame: %v", err)
}
if g.ID != "g2" {
t.Errorf("ID = %q, want g2", g.ID)
}
}
func TestCreatePlatformForbidden(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusForbidden, map[string]any{
"title": "Forbidden", "status": 403, "detail": "admin access required",
})
})
c.Token = "tok-non-admin"
_, err := c.CreatePlatform(t.Context(), "PC")
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.Status != http.StatusForbidden {
t.Errorf("Status = %d, want 403", apiErr.Status)
}
}
func TestLogoutClearsTokenEvenOnError(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
c.Token = "tok-abc"
_ = c.Logout(t.Context())
if c.Token != "" {
t.Errorf("Token = %q, want empty after Logout regardless of the response", c.Token)
}
}
func TestDefaultBaseURL(t *testing.T) {
c := New("")
if c.BaseURL != DefaultBaseURL {
t.Errorf("BaseURL = %q, want %q", c.BaseURL, DefaultBaseURL)
}
}
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+59
View File
@@ -0,0 +1,59 @@
# Web
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.2.1.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+71
View File
@@ -0,0 +1,71 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"web": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/material-theme.scss", "src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "web:build:production"
},
"development": {
"buildTarget": "web:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "node_modules/ng-openapi-gen/ng-openapi-gen-schema.json",
"input": "../api/openapi.json",
"output": "src/app/api",
"ignoreUnusedModels": false,
"services": true
}
+7131
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "web",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.19.1",
"dependencies": {
"@angular/animations": "^22.2.1",
"@angular/cdk": "^22.2.1",
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/forms": "^22.2.0",
"@angular/material": "^22.2.1",
"@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.2.1",
"@angular/cli": "^22.2.1",
"@angular/compiler-cli": "^22.2.0",
"jsdom": "^30.0.0",
"ng-openapi-gen": "^1.1.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^5.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+27
View File
@@ -0,0 +1,27 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { Injectable } from '@angular/core';
/**
* Provides the ApiConfiguration configuration object with a given root URL.
*/
export function provideApiConfiguration(rootUrl: string) {
var config = new ApiConfiguration();
config.rootUrl = rootUrl;
return {
provide: ApiConfiguration,
useValue: config
};
}
/**
* Global configuration
*/
@Injectable({
providedIn: 'root',
})
export class ApiConfiguration {
rootUrl: string = '';
}
+64
View File
@@ -0,0 +1,64 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { Injectable } from '@angular/core';
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable, firstValueFrom } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { ApiConfiguration } from './api-configuration';
import { StrictHttpResponse } from './strict-http-response';
export type ApiFnOptional<P, R> = (http: HttpClient, rootUrl: string, params?: P, context?: HttpContext) => Observable<StrictHttpResponse<R>>;
export type ApiFnRequired<P, R> = (http: HttpClient, rootUrl: string, params: P, context?: HttpContext) => Observable<StrictHttpResponse<R>>;
/**
* Helper service to call API functions directly
*/
@Injectable({ providedIn: 'root' })
export class Api {
constructor(
private config: ApiConfiguration,
private http: HttpClient
) {
}
private _rootUrl?: string;
/**
* Returns the root url for API operations. If not set directly here,
* will fallback to `ApiConfiguration.rootUrl`.
*/
get rootUrl(): string {
return this._rootUrl || this.config.rootUrl;
}
/**
* Sets the root URL for API operations
*/
set rootUrl(rootUrl: string) {
this._rootUrl = rootUrl;
}
/**
* Executes an API call, returning the response body only
*/
invoke<P, R>(fn: ApiFnRequired<P, R>, params: P, context?: HttpContext): Promise<R>;
invoke<P, R>(fn: ApiFnOptional<P, R>, params?: P, context?: HttpContext): Promise<R>;
async invoke<P, R>(fn: ApiFnRequired<P, R> | ApiFnOptional<P, R>, params: P, context?: HttpContext): Promise<R> {
const resp = this.invoke$Response(fn, params, context);
return (await resp).body;
}
/**
* Executes an API call, returning the entire response
*/
invoke$Response<P, R>(fn: ApiFnRequired<P, R>, params: P, context?: HttpContext): Promise<StrictHttpResponse<R>>;
invoke$Response<P, R>(fn: ApiFnOptional<P, R>, params?: P, context?: HttpContext): Promise<StrictHttpResponse<R>>;
invoke$Response<P, R>(fn: ApiFnRequired<P, R> | ApiFnOptional<P, R>, params: P, context?: HttpContext): Promise<StrictHttpResponse<R>> {
const obs = fn(this.http, this.rootUrl, params, context)
.pipe(
filter(r => r instanceof HttpResponse),
map(r => r as StrictHttpResponse<R>));
return firstValueFrom(obs);
}
}
+35
View File
@@ -0,0 +1,35 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ApiConfiguration } from './api-configuration';
/**
* Base class for services
*/
@Injectable()
export class BaseService {
constructor(
protected config: ApiConfiguration,
protected http: HttpClient
) {
}
private _rootUrl?: string;
/**
* Returns the root url for all operations in this service. If not set directly in this
* service, will fallback to `ApiConfiguration.rootUrl`.
*/
get rootUrl(): string {
return this._rootUrl || this.config.rootUrl;
}
/**
* Sets the root URL for API operations in this service.
*/
set rootUrl(rootUrl: string) {
this._rootUrl = rootUrl;
}
}
+30
View File
@@ -0,0 +1,30 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { User } from '../../models/user';
export interface GetMe$Params {
}
export function getMe(http: HttpClient, rootUrl: string, params?: GetMe$Params, context?: HttpContext): Observable<StrictHttpResponse<User>> {
const rb = new RequestBuilder(rootUrl, getMe.PATH, 'get');
if (params) {
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<User>;
})
);
}
getMe.PATH = '/api/auth/me';
+33
View File
@@ -0,0 +1,33 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { AuthResponse } from '../../models/auth-response';
import { LoginInput } from '../../models/login-input';
export interface Login$Params {
body: LoginInput
}
export function login(http: HttpClient, rootUrl: string, params: Login$Params, context?: HttpContext): Observable<StrictHttpResponse<AuthResponse>> {
const rb = new RequestBuilder(rootUrl, login.PATH, 'post');
if (params) {
rb.body(params.body, 'application/json');
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<AuthResponse>;
})
);
}
login.PATH = '/api/auth/login';
+29
View File
@@ -0,0 +1,29 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
export interface Logout$Params {
}
export function logout(http: HttpClient, rootUrl: string, params?: Logout$Params, context?: HttpContext): Observable<StrictHttpResponse<void>> {
const rb = new RequestBuilder(rootUrl, logout.PATH, 'post');
if (params) {
}
return http.request(
rb.build({ responseType: 'text', accept: '*/*', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return (r as HttpResponse<any>).clone({ body: undefined }) as StrictHttpResponse<void>;
})
);
}
logout.PATH = '/api/auth/logout';
+33
View File
@@ -0,0 +1,33 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { AuthResponse } from '../../models/auth-response';
import { RegisterInput } from '../../models/register-input';
export interface Register$Params {
body: RegisterInput
}
export function register(http: HttpClient, rootUrl: string, params: Register$Params, context?: HttpContext): Observable<StrictHttpResponse<AuthResponse>> {
const rb = new RequestBuilder(rootUrl, register.PATH, 'post');
if (params) {
rb.body(params.body, 'application/json');
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<AuthResponse>;
})
);
}
register.PATH = '/api/auth/register';
+33
View File
@@ -0,0 +1,33 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Game } from '../../models/game';
import { GameInput } from '../../models/game-input';
export interface CreateGame$Params {
body: GameInput
}
export function createGame(http: HttpClient, rootUrl: string, params: CreateGame$Params, context?: HttpContext): Observable<StrictHttpResponse<Game>> {
const rb = new RequestBuilder(rootUrl, createGame.PATH, 'post');
if (params) {
rb.body(params.body, 'application/json');
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<Game>;
})
);
}
createGame.PATH = '/api/games';
+31
View File
@@ -0,0 +1,31 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
export interface DeleteGame$Params {
id: string;
}
export function deleteGame(http: HttpClient, rootUrl: string, params: DeleteGame$Params, context?: HttpContext): Observable<StrictHttpResponse<void>> {
const rb = new RequestBuilder(rootUrl, deleteGame.PATH, 'delete');
if (params) {
rb.path('id', params.id, {});
}
return http.request(
rb.build({ responseType: 'text', accept: '*/*', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return (r as HttpResponse<any>).clone({ body: undefined }) as StrictHttpResponse<void>;
})
);
}
deleteGame.PATH = '/api/games/{id}';
+32
View File
@@ -0,0 +1,32 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Game } from '../../models/game';
export interface GetGame$Params {
id: string;
}
export function getGame(http: HttpClient, rootUrl: string, params: GetGame$Params, context?: HttpContext): Observable<StrictHttpResponse<Game>> {
const rb = new RequestBuilder(rootUrl, getGame.PATH, 'get');
if (params) {
rb.path('id', params.id, {});
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<Game>;
})
);
}
getGame.PATH = '/api/games/{id}';
+30
View File
@@ -0,0 +1,30 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Game } from '../../models/game';
export interface ListGames$Params {
}
export function listGames(http: HttpClient, rootUrl: string, params?: ListGames$Params, context?: HttpContext): Observable<StrictHttpResponse<(Array<Game> | null)>> {
const rb = new RequestBuilder(rootUrl, listGames.PATH, 'get');
if (params) {
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<(Array<Game> | null)>;
})
);
}
listGames.PATH = '/api/games';
+35
View File
@@ -0,0 +1,35 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Game } from '../../models/game';
import { GameInput } from '../../models/game-input';
export interface UpdateGame$Params {
id: string;
body: GameInput
}
export function updateGame(http: HttpClient, rootUrl: string, params: UpdateGame$Params, context?: HttpContext): Observable<StrictHttpResponse<Game>> {
const rb = new RequestBuilder(rootUrl, updateGame.PATH, 'put');
if (params) {
rb.path('id', params.id, {});
rb.body(params.body, 'application/json');
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<Game>;
})
);
}
updateGame.PATH = '/api/games/{id}';
+30
View File
@@ -0,0 +1,30 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { GetHelloResponse } from '../../models/get-hello-response';
export interface GetHello$Params {
}
export function getHello(http: HttpClient, rootUrl: string, params?: GetHello$Params, context?: HttpContext): Observable<StrictHttpResponse<GetHelloResponse>> {
const rb = new RequestBuilder(rootUrl, getHello.PATH, 'get');
if (params) {
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<GetHelloResponse>;
})
);
}
getHello.PATH = '/hello';
@@ -0,0 +1,33 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Platform } from '../../models/platform';
import { PlatformInput } from '../../models/platform-input';
export interface CreatePlatform$Params {
body: PlatformInput
}
export function createPlatform(http: HttpClient, rootUrl: string, params: CreatePlatform$Params, context?: HttpContext): Observable<StrictHttpResponse<Platform>> {
const rb = new RequestBuilder(rootUrl, createPlatform.PATH, 'post');
if (params) {
rb.body(params.body, 'application/json');
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<Platform>;
})
);
}
createPlatform.PATH = '/api/platforms';
@@ -0,0 +1,31 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
export interface DeletePlatform$Params {
id: string;
}
export function deletePlatform(http: HttpClient, rootUrl: string, params: DeletePlatform$Params, context?: HttpContext): Observable<StrictHttpResponse<void>> {
const rb = new RequestBuilder(rootUrl, deletePlatform.PATH, 'delete');
if (params) {
rb.path('id', params.id, {});
}
return http.request(
rb.build({ responseType: 'text', accept: '*/*', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return (r as HttpResponse<any>).clone({ body: undefined }) as StrictHttpResponse<void>;
})
);
}
deletePlatform.PATH = '/api/platforms/{id}';
@@ -0,0 +1,32 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Platform } from '../../models/platform';
export interface GetPlatform$Params {
id: string;
}
export function getPlatform(http: HttpClient, rootUrl: string, params: GetPlatform$Params, context?: HttpContext): Observable<StrictHttpResponse<Platform>> {
const rb = new RequestBuilder(rootUrl, getPlatform.PATH, 'get');
if (params) {
rb.path('id', params.id, {});
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<Platform>;
})
);
}
getPlatform.PATH = '/api/platforms/{id}';
@@ -0,0 +1,30 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Platform } from '../../models/platform';
export interface ListPlatforms$Params {
}
export function listPlatforms(http: HttpClient, rootUrl: string, params?: ListPlatforms$Params, context?: HttpContext): Observable<StrictHttpResponse<(Array<Platform> | null)>> {
const rb = new RequestBuilder(rootUrl, listPlatforms.PATH, 'get');
if (params) {
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<(Array<Platform> | null)>;
})
);
}
listPlatforms.PATH = '/api/platforms';
@@ -0,0 +1,35 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';
import { Platform } from '../../models/platform';
import { PlatformInput } from '../../models/platform-input';
export interface UpdatePlatform$Params {
id: string;
body: PlatformInput
}
export function updatePlatform(http: HttpClient, rootUrl: string, params: UpdatePlatform$Params, context?: HttpContext): Observable<StrictHttpResponse<Platform>> {
const rb = new RequestBuilder(rootUrl, updatePlatform.PATH, 'put');
if (params) {
rb.path('id', params.id, {});
rb.body(params.body, 'application/json');
}
return http.request(
rb.build({ responseType: 'json', accept: 'application/json', context })
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<Platform>;
})
);
}
updatePlatform.PATH = '/api/platforms/{id}';
+33
View File
@@ -0,0 +1,33 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export type { Login$Params as Login$Params } from './fn/auth/login';
export { login as login } from './fn/auth/login';
export type { Logout$Params as Logout$Params } from './fn/auth/logout';
export { logout as logout } from './fn/auth/logout';
export type { GetMe$Params as GetMe$Params } from './fn/auth/get-me';
export { getMe as getMe } from './fn/auth/get-me';
export type { Register$Params as Register$Params } from './fn/auth/register';
export { register as register } from './fn/auth/register';
export type { ListGames$Params as ListGames$Params } from './fn/games/list-games';
export { listGames as listGames } from './fn/games/list-games';
export type { CreateGame$Params as CreateGame$Params } from './fn/games/create-game';
export { createGame as createGame } from './fn/games/create-game';
export type { GetGame$Params as GetGame$Params } from './fn/games/get-game';
export { getGame as getGame } from './fn/games/get-game';
export type { UpdateGame$Params as UpdateGame$Params } from './fn/games/update-game';
export { updateGame as updateGame } from './fn/games/update-game';
export type { DeleteGame$Params as DeleteGame$Params } from './fn/games/delete-game';
export { deleteGame as deleteGame } from './fn/games/delete-game';
export type { ListPlatforms$Params as ListPlatforms$Params } from './fn/platforms/list-platforms';
export { listPlatforms as listPlatforms } from './fn/platforms/list-platforms';
export type { CreatePlatform$Params as CreatePlatform$Params } from './fn/platforms/create-platform';
export { createPlatform as createPlatform } from './fn/platforms/create-platform';
export type { GetPlatform$Params as GetPlatform$Params } from './fn/platforms/get-platform';
export { getPlatform as getPlatform } from './fn/platforms/get-platform';
export type { UpdatePlatform$Params as UpdatePlatform$Params } from './fn/platforms/update-platform';
export { updatePlatform as updatePlatform } from './fn/platforms/update-platform';
export type { DeletePlatform$Params as DeletePlatform$Params } from './fn/platforms/delete-platform';
export { deletePlatform as deletePlatform } from './fn/platforms/delete-platform';
export type { GetHello$Params as GetHello$Params } from './fn/misc/get-hello';
export { getHello as getHello } from './fn/misc/get-hello';
+14
View File
@@ -0,0 +1,14 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export type { AuthResponse } from './models/auth-response';
export type { ErrorDetail } from './models/error-detail';
export type { ErrorModel } from './models/error-model';
export type { Game } from './models/game';
export type { GameInput } from './models/game-input';
export type { GetHelloResponse } from './models/get-hello-response';
export type { LoginInput } from './models/login-input';
export type { Platform } from './models/platform';
export type { PlatformInput } from './models/platform-input';
export type { RegisterInput } from './models/register-input';
export type { User } from './models/user';
+13
View File
@@ -0,0 +1,13 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { User } from '../models/user';
export interface AuthResponse {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
token: string;
user: User;
}
+20
View File
@@ -0,0 +1,20 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface ErrorDetail {
/**
* Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'
*/
location?: string;
/**
* Error message text
*/
message?: string;
/**
* The value at the given location
*/
value?: any;
}
+41
View File
@@ -0,0 +1,41 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { ErrorDetail } from '../models/error-detail';
export interface ErrorModel {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
/**
* A human-readable explanation specific to this occurrence of the problem.
*/
detail?: string;
/**
* Optional list of individual error details
*/
errors?: (Array<ErrorDetail> | null);
/**
* A URI reference that identifies the specific occurrence of the problem.
*/
instance?: string;
/**
* HTTP status code
*/
status?: number;
/**
* A short, human-readable summary of the problem type. This value should not change between occurrences of the error.
*/
title?: string;
/**
* A URI reference to human-readable documentation for the error.
*/
type?: string;
}
+23
View File
@@ -0,0 +1,23 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface GameInput {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
/**
* YYYY-MM-DD (optional)
*/
finished?: string;
name: string;
platform_id: string;
/**
* YYYY-MM-DD
*/
release_date: string;
score: number;
}
+25
View File
@@ -0,0 +1,25 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface Game {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
/**
* YYYY-MM-DD (optional)
*/
finished?: string;
id: string;
name: string;
platform_id: string;
platform_name: string;
/**
* YYYY-MM-DD
*/
release_date: string;
score: number;
}
@@ -0,0 +1,11 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface GetHelloResponse {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
message: string;
}
+12
View File
@@ -0,0 +1,12 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface LoginInput {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
email: string;
password: string;
}
+15
View File
@@ -0,0 +1,15 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface PlatformInput {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
/**
* Name of the platform
*/
name: string;
}
+12
View File
@@ -0,0 +1,12 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface Platform {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
id: string;
name: string;
}
+13
View File
@@ -0,0 +1,13 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface RegisterInput {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
email: string;
name: string;
password: string;
}
+15
View File
@@ -0,0 +1,15 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export interface User {
/**
* A URL to the JSON Schema for this object.
*/
'$schema'?: string;
bio?: string;
email: string;
id: string;
is_admin: boolean;
name: string;
}
+369
View File
@@ -0,0 +1,369 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpRequest, HttpParameterCodec, HttpParams, HttpHeaders, HttpContext } from '@angular/common/http';
/**
* Custom parameter codec to correctly handle the plus sign in parameter
* values. See https://github.com/angular/angular/issues/18261
*/
class ParameterCodec implements HttpParameterCodec {
encodeKey(key: string): string {
return encodeURIComponent(key);
}
encodeValue(value: string): string {
return encodeURIComponent(value);
}
decodeKey(key: string): string {
return decodeURIComponent(key);
}
decodeValue(value: string): string {
return decodeURIComponent(value);
}
}
const ParameterCodecInstance = new ParameterCodec();
/**
* Defines the options for appending a parameter
*/
interface ParameterOptions {
style?: string;
explode?: boolean;
}
/**
* Base class for a parameter
*/
abstract class Parameter {
constructor(public name: string, public value: any, public options: ParameterOptions, defaultStyle: string, defaultExplode: boolean) {
this.options = options || {};
if (this.options.style === null || this.options.style === undefined) {
this.options.style = defaultStyle;
}
if (this.options.explode === null || this.options.explode === undefined) {
this.options.explode = defaultExplode;
}
}
serializeValue(value: any, separator = ','): string {
if (value === null || value === undefined) {
return '';
} else if (value instanceof Array) {
return value.map(v => this.serializeValue(v).split(separator).join(encodeURIComponent(separator))).join(separator);
} else if (typeof value === 'object') {
const array: string[] = [];
for (const key of Object.keys(value)) {
let propVal = value[key];
if (propVal !== null && propVal !== undefined) {
propVal = this.serializeValue(propVal).split(separator).join(encodeURIComponent(separator));
if (this.options.explode) {
array.push(`${key}=${propVal}`);
} else {
array.push(key);
array.push(propVal);
}
}
}
return array.join(separator);
} else {
return String(value);
}
}
}
/**
* A parameter in the operation path
*/
class PathParameter extends Parameter {
constructor(name: string, value: any, options: ParameterOptions) {
super(name, value, options, 'simple', false);
}
append(path: string): string {
let value = this.value;
if (value === null || value === undefined) {
value = '';
}
let prefix = this.options.style === 'label' ? '.' : '';
let separator = this.options.explode ? prefix === '' ? ',' : prefix : ',';
let alreadySerialized = false;
if (this.options.style === 'matrix') {
// The parameter name is just used as prefix, except in some cases...
prefix = `;${this.name}=`;
if (this.options.explode && typeof value === 'object') {
prefix = ';';
if (value instanceof Array) {
// For arrays we have to repeat the name for each element
value = value.map(v => `${this.name}=${this.serializeValue(v, ';')}`);
value = value.join(';');
alreadySerialized = true;
} else {
// For objects we have to put each the key / value pairs
value = this.serializeValue(value, ';');
alreadySerialized = true
}
}
}
value = prefix + (alreadySerialized ? value : this.serializeValue(value, separator));
// Replace both the plain variable and the corresponding variant taking in the prefix and explode into account
path = path.replace(`{${this.name}}`, value);
path = path.replace(`{${prefix}${this.name}${this.options.explode ? '*' : ''}}`, value);
return path;
}
// @ts-ignore
serializeValue(value: any, separator = ','): string {
var result = typeof value === 'string' ? encodeURIComponent(value) : super.serializeValue(value, separator);
result = result.replace(/%3D/g, '=');
result = result.replace(/%3B/g, ';');
result = result.replace(/%2C/g, ',');
return result;
}
}
/**
* A parameter in the query
*/
class QueryParameter extends Parameter {
constructor(name: string, value: any, options: ParameterOptions) {
super(name, value, options, 'form', true);
}
append(params: HttpParams): HttpParams {
if (this.value instanceof Array) {
// Array serialization
if (this.options.explode) {
for (const v of this.value) {
params = params.append(this.name, this.serializeValue(v));
}
} else {
const separator = this.options.style === 'spaceDelimited'
? ' ' : this.options.style === 'pipeDelimited'
? '|' : ',';
return params.append(this.name, this.serializeValue(this.value, separator));
}
} else if (this.value !== null && typeof this.value === 'object') {
// Object serialization
if (this.options.style === 'deepObject') {
// Append a parameter for each key, in the form `name[key]`
for (const key of Object.keys(this.value)) {
const propVal = this.value[key];
if (propVal !== null && propVal !== undefined) {
params = params.append(`${this.name}[${key}]`, this.serializeValue(propVal));
}
}
} else if (this.options.explode) {
// Append a parameter for each key without using the parameter name
for (const key of Object.keys(this.value)) {
const propVal = this.value[key];
if (propVal !== null && propVal !== undefined) {
params = params.append(key, this.serializeValue(propVal));
}
}
} else {
// Append a single parameter whose values are a comma-separated list of key,value,key,value...
const array: any[] = [];
for (const key of Object.keys(this.value)) {
const propVal = this.value[key];
if (propVal !== null && propVal !== undefined) {
array.push(key);
array.push(propVal);
}
}
params = params.append(this.name, this.serializeValue(array));
}
} else if (this.value !== null && this.value !== undefined) {
// Plain value
params = params.append(this.name, this.serializeValue(this.value));
}
return params;
}
}
/**
* A parameter in the HTTP request header
*/
class HeaderParameter extends Parameter {
constructor(name: string, value: any, options: ParameterOptions) {
super(name, value, options, 'simple', false);
}
append(headers: HttpHeaders): HttpHeaders {
if (this.value !== null && this.value !== undefined) {
if (this.value instanceof Array) {
for (const v of this.value) {
headers = headers.append(this.name, this.serializeValue(v));
}
} else {
headers = headers.append(this.name, this.serializeValue(this.value));
}
}
return headers;
}
}
/**
* Helper to build http requests from parameters
*/
export class RequestBuilder {
private _path = new Map<string, PathParameter>();
private _query = new Map<string, QueryParameter>();
private _header = new Map<string, HeaderParameter>();
_bodyContent: any | null;
_bodyContentType?: string;
constructor(
public rootUrl: string,
public operationPath: string,
public method: string) {
}
/**
* Sets a path parameter
*/
path(name: string, value: any, options?: ParameterOptions): void {
this._path.set(name, new PathParameter(name, value, options || {}));
}
/**
* Sets a query parameter
*/
query(name: string, value: any, options?: ParameterOptions): void {
this._query.set(name, new QueryParameter(name, value, options || {}));
}
/**
* Sets a header parameter
*/
header(name: string, value: any, options?: ParameterOptions): void {
this._header.set(name, new HeaderParameter(name, value, options || {}));
}
/**
* Sets the body content, along with the content type
*/
body(value: any, contentType = 'application/json'): void {
if (value instanceof Blob) {
this._bodyContentType = value.type;
} else {
this._bodyContentType = contentType;
}
if (this._bodyContentType === 'application/x-www-form-urlencoded' && value !== null && typeof value === 'object') {
// Handle URL-encoded data
const pairs: Array<[string, string]> = [];
for (const key of Object.keys(value)) {
let val = value[key];
if (!(val instanceof Array)) {
val = [val];
}
for (const v of val) {
const formValue = this.formDataValue(v);
if (formValue !== null) {
pairs.push([key, formValue]);
}
}
}
this._bodyContent = pairs.map(p => `${encodeURIComponent(p[0])}=${encodeURIComponent(p[1])}`).join('&');
} else if (this._bodyContentType === 'multipart/form-data') {
// Handle multipart form data
const formData = new FormData();
if (value !== null && value !== undefined) {
for (const key of Object.keys(value)) {
const val = value[key];
if (val instanceof Array) {
for (const v of val) {
const toAppend = this.formDataValue(v);
if (toAppend !== null) {
formData.append(key, toAppend);
}
}
} else {
const toAppend = this.formDataValue(val);
if (toAppend !== null) {
formData.set(key, toAppend);
}
}
}
}
this._bodyContent = formData;
} else {
// The body is the plain content
this._bodyContent = value;
}
}
private formDataValue(value: any): any {
if (value === null || value === undefined) {
return null;
}
if (value instanceof Blob) {
return value;
}
if (typeof value === 'object') {
return new Blob([JSON.stringify(value)], {type: 'application/json'})
}
return String(value);
}
/**
* Builds the request with the current set parameters
*/
build<T = any>(options?: {
/** Which content types to accept */
accept?: string;
/** The expected response type */
responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';
/** Whether to report progress on uploads / downloads */
reportProgress?: boolean;
/** Allow passing HttpContext for HttpClient */
context?: HttpContext;
}): HttpRequest<T> {
options = options || {};
// Path parameters
let path = this.operationPath;
for (const pathParam of this._path.values()) {
path = pathParam.append(path);
}
const url = this.rootUrl + path;
// Query parameters
let httpParams = new HttpParams({
encoder: ParameterCodecInstance
});
for (const queryParam of this._query.values()) {
httpParams = queryParam.append(httpParams);
}
// Header parameters
let httpHeaders = new HttpHeaders();
if (options.accept) {
httpHeaders = httpHeaders.append('Accept', options.accept);
}
for (const headerParam of this._header.values()) {
httpHeaders = headerParam.append(httpHeaders);
}
// Request content headers
if (this._bodyContentType && !(this._bodyContent instanceof FormData)) {
httpHeaders = httpHeaders.set('Content-Type', this._bodyContentType);
}
// Perform the request
return new HttpRequest<T>(this.method.toUpperCase(), url, this._bodyContent, {
params: httpParams,
headers: httpHeaders,
responseType: options.responseType,
reportProgress: options.reportProgress,
context: options.context
});
}
}
+7
View File
@@ -0,0 +1,7 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
export { AuthService } from './services/auth.service';
export { GamesService } from './services/games.service';
export { PlatformsService } from './services/platforms.service';
export { MiscService } from './services/misc.service';
+161
View File
@@ -0,0 +1,161 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { BaseService } from '../base-service';
import { ApiConfiguration } from '../api-configuration';
import { StrictHttpResponse } from '../strict-http-response';
import { AuthResponse } from '../models/auth-response';
import { getMe } from '../fn/auth/get-me';
import { GetMe$Params } from '../fn/auth/get-me';
import { login } from '../fn/auth/login';
import { Login$Params } from '../fn/auth/login';
import { logout } from '../fn/auth/logout';
import { Logout$Params } from '../fn/auth/logout';
import { register } from '../fn/auth/register';
import { Register$Params } from '../fn/auth/register';
import { User } from '../models/user';
@Injectable({ providedIn: 'root' })
export class AuthService extends BaseService {
constructor(config: ApiConfiguration, http: HttpClient) {
super(config, http);
}
/** Path part for operation `login()` */
static readonly LoginPath = '/api/auth/login';
/**
* Log in to an existing account.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `login()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
login$Response(params: Login$Params, context?: HttpContext): Promise<StrictHttpResponse<AuthResponse>> {
const obs = login(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Log in to an existing account.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `login$Response()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
login(params: Login$Params, context?: HttpContext): Promise<AuthResponse> {
const resp = this.login$Response(params, context);
return resp.then((r: StrictHttpResponse<AuthResponse>): AuthResponse => r.body);
}
/** Path part for operation `logout()` */
static readonly LogoutPath = '/api/auth/logout';
/**
* Log out of the current session.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `logout()` instead.
*
* This method doesn't expect any request body.
*/
logout$Response(params?: Logout$Params, context?: HttpContext): Promise<StrictHttpResponse<void>> {
const obs = logout(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Log out of the current session.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `logout$Response()` instead.
*
* This method doesn't expect any request body.
*/
logout(params?: Logout$Params, context?: HttpContext): Promise<void> {
const resp = this.logout$Response(params, context);
return resp.then((r: StrictHttpResponse<void>): void => r.body);
}
/** Path part for operation `getMe()` */
static readonly GetMePath = '/api/auth/me';
/**
* Get the current user.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `getMe()` instead.
*
* This method doesn't expect any request body.
*/
getMe$Response(params?: GetMe$Params, context?: HttpContext): Promise<StrictHttpResponse<User>> {
const obs = getMe(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Get the current user.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `getMe$Response()` instead.
*
* This method doesn't expect any request body.
*/
getMe(params?: GetMe$Params, context?: HttpContext): Promise<User> {
const resp = this.getMe$Response(params, context);
return resp.then((r: StrictHttpResponse<User>): User => r.body);
}
/** Path part for operation `register()` */
static readonly RegisterPath = '/api/auth/register';
/**
* Register a new account.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `register()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
register$Response(params: Register$Params, context?: HttpContext): Promise<StrictHttpResponse<AuthResponse>> {
const obs = register(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Register a new account.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `register$Response()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
register(params: Register$Params, context?: HttpContext): Promise<AuthResponse> {
const resp = this.register$Response(params, context);
return resp.then((r: StrictHttpResponse<AuthResponse>): AuthResponse => r.body);
}
}
+195
View File
@@ -0,0 +1,195 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { BaseService } from '../base-service';
import { ApiConfiguration } from '../api-configuration';
import { StrictHttpResponse } from '../strict-http-response';
import { createGame } from '../fn/games/create-game';
import { CreateGame$Params } from '../fn/games/create-game';
import { deleteGame } from '../fn/games/delete-game';
import { DeleteGame$Params } from '../fn/games/delete-game';
import { Game } from '../models/game';
import { getGame } from '../fn/games/get-game';
import { GetGame$Params } from '../fn/games/get-game';
import { listGames } from '../fn/games/list-games';
import { ListGames$Params } from '../fn/games/list-games';
import { updateGame } from '../fn/games/update-game';
import { UpdateGame$Params } from '../fn/games/update-game';
@Injectable({ providedIn: 'root' })
export class GamesService extends BaseService {
constructor(config: ApiConfiguration, http: HttpClient) {
super(config, http);
}
/** Path part for operation `listGames()` */
static readonly ListGamesPath = '/api/games';
/**
* List Games.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `listGames()` instead.
*
* This method doesn't expect any request body.
*/
listGames$Response(params?: ListGames$Params, context?: HttpContext): Promise<StrictHttpResponse<(Array<Game> | null)>> {
const obs = listGames(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* List Games.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `listGames$Response()` instead.
*
* This method doesn't expect any request body.
*/
listGames(params?: ListGames$Params, context?: HttpContext): Promise<(Array<Game> | null)> {
const resp = this.listGames$Response(params, context);
return resp.then((r: StrictHttpResponse<(Array<Game> | null)>): (Array<Game> | null) => r.body);
}
/** Path part for operation `createGame()` */
static readonly CreateGamePath = '/api/games';
/**
* Create Game.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `createGame()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
createGame$Response(params: CreateGame$Params, context?: HttpContext): Promise<StrictHttpResponse<Game>> {
const obs = createGame(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Create Game.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `createGame$Response()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
createGame(params: CreateGame$Params, context?: HttpContext): Promise<Game> {
const resp = this.createGame$Response(params, context);
return resp.then((r: StrictHttpResponse<Game>): Game => r.body);
}
/** Path part for operation `getGame()` */
static readonly GetGamePath = '/api/games/{id}';
/**
* Get Game.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `getGame()` instead.
*
* This method doesn't expect any request body.
*/
getGame$Response(params: GetGame$Params, context?: HttpContext): Promise<StrictHttpResponse<Game>> {
const obs = getGame(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Get Game.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `getGame$Response()` instead.
*
* This method doesn't expect any request body.
*/
getGame(params: GetGame$Params, context?: HttpContext): Promise<Game> {
const resp = this.getGame$Response(params, context);
return resp.then((r: StrictHttpResponse<Game>): Game => r.body);
}
/** Path part for operation `updateGame()` */
static readonly UpdateGamePath = '/api/games/{id}';
/**
* Update Game.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `updateGame()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
updateGame$Response(params: UpdateGame$Params, context?: HttpContext): Promise<StrictHttpResponse<Game>> {
const obs = updateGame(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Update Game.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `updateGame$Response()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
updateGame(params: UpdateGame$Params, context?: HttpContext): Promise<Game> {
const resp = this.updateGame$Response(params, context);
return resp.then((r: StrictHttpResponse<Game>): Game => r.body);
}
/** Path part for operation `deleteGame()` */
static readonly DeleteGamePath = '/api/games/{id}';
/**
* Delete Game.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `deleteGame()` instead.
*
* This method doesn't expect any request body.
*/
deleteGame$Response(params: DeleteGame$Params, context?: HttpContext): Promise<StrictHttpResponse<void>> {
const obs = deleteGame(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Delete Game.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `deleteGame$Response()` instead.
*
* This method doesn't expect any request body.
*/
deleteGame(params: DeleteGame$Params, context?: HttpContext): Promise<void> {
const resp = this.deleteGame$Response(params, context);
return resp.then((r: StrictHttpResponse<void>): void => r.body);
}
}
+55
View File
@@ -0,0 +1,55 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { BaseService } from '../base-service';
import { ApiConfiguration } from '../api-configuration';
import { StrictHttpResponse } from '../strict-http-response';
import { GetHelloResponse } from '../models/get-hello-response';
import { getHello } from '../fn/misc/get-hello';
import { GetHello$Params } from '../fn/misc/get-hello';
@Injectable({ providedIn: 'root' })
export class MiscService extends BaseService {
constructor(config: ApiConfiguration, http: HttpClient) {
super(config, http);
}
/** Path part for operation `getHello()` */
static readonly GetHelloPath = '/hello';
/**
* Say Hello.
*
* Returns a friendly hello message.
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `getHello()` instead.
*
* This method doesn't expect any request body.
*/
getHello$Response(params?: GetHello$Params, context?: HttpContext): Promise<StrictHttpResponse<GetHelloResponse>> {
const obs = getHello(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Say Hello.
*
* Returns a friendly hello message.
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `getHello$Response()` instead.
*
* This method doesn't expect any request body.
*/
getHello(params?: GetHello$Params, context?: HttpContext): Promise<GetHelloResponse> {
const resp = this.getHello$Response(params, context);
return resp.then((r: StrictHttpResponse<GetHelloResponse>): GetHelloResponse => r.body);
}
}
@@ -0,0 +1,195 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpClient, HttpContext } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { BaseService } from '../base-service';
import { ApiConfiguration } from '../api-configuration';
import { StrictHttpResponse } from '../strict-http-response';
import { createPlatform } from '../fn/platforms/create-platform';
import { CreatePlatform$Params } from '../fn/platforms/create-platform';
import { deletePlatform } from '../fn/platforms/delete-platform';
import { DeletePlatform$Params } from '../fn/platforms/delete-platform';
import { getPlatform } from '../fn/platforms/get-platform';
import { GetPlatform$Params } from '../fn/platforms/get-platform';
import { listPlatforms } from '../fn/platforms/list-platforms';
import { ListPlatforms$Params } from '../fn/platforms/list-platforms';
import { Platform } from '../models/platform';
import { updatePlatform } from '../fn/platforms/update-platform';
import { UpdatePlatform$Params } from '../fn/platforms/update-platform';
@Injectable({ providedIn: 'root' })
export class PlatformsService extends BaseService {
constructor(config: ApiConfiguration, http: HttpClient) {
super(config, http);
}
/** Path part for operation `listPlatforms()` */
static readonly ListPlatformsPath = '/api/platforms';
/**
* List Platforms.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `listPlatforms()` instead.
*
* This method doesn't expect any request body.
*/
listPlatforms$Response(params?: ListPlatforms$Params, context?: HttpContext): Promise<StrictHttpResponse<(Array<Platform> | null)>> {
const obs = listPlatforms(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* List Platforms.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `listPlatforms$Response()` instead.
*
* This method doesn't expect any request body.
*/
listPlatforms(params?: ListPlatforms$Params, context?: HttpContext): Promise<(Array<Platform> | null)> {
const resp = this.listPlatforms$Response(params, context);
return resp.then((r: StrictHttpResponse<(Array<Platform> | null)>): (Array<Platform> | null) => r.body);
}
/** Path part for operation `createPlatform()` */
static readonly CreatePlatformPath = '/api/platforms';
/**
* Create Platform.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `createPlatform()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
createPlatform$Response(params: CreatePlatform$Params, context?: HttpContext): Promise<StrictHttpResponse<Platform>> {
const obs = createPlatform(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Create Platform.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `createPlatform$Response()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
createPlatform(params: CreatePlatform$Params, context?: HttpContext): Promise<Platform> {
const resp = this.createPlatform$Response(params, context);
return resp.then((r: StrictHttpResponse<Platform>): Platform => r.body);
}
/** Path part for operation `getPlatform()` */
static readonly GetPlatformPath = '/api/platforms/{id}';
/**
* Get Platform.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `getPlatform()` instead.
*
* This method doesn't expect any request body.
*/
getPlatform$Response(params: GetPlatform$Params, context?: HttpContext): Promise<StrictHttpResponse<Platform>> {
const obs = getPlatform(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Get Platform.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `getPlatform$Response()` instead.
*
* This method doesn't expect any request body.
*/
getPlatform(params: GetPlatform$Params, context?: HttpContext): Promise<Platform> {
const resp = this.getPlatform$Response(params, context);
return resp.then((r: StrictHttpResponse<Platform>): Platform => r.body);
}
/** Path part for operation `updatePlatform()` */
static readonly UpdatePlatformPath = '/api/platforms/{id}';
/**
* Update Platform.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `updatePlatform()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
updatePlatform$Response(params: UpdatePlatform$Params, context?: HttpContext): Promise<StrictHttpResponse<Platform>> {
const obs = updatePlatform(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Update Platform.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `updatePlatform$Response()` instead.
*
* This method sends `application/json` and handles request body of type `application/json`.
*/
updatePlatform(params: UpdatePlatform$Params, context?: HttpContext): Promise<Platform> {
const resp = this.updatePlatform$Response(params, context);
return resp.then((r: StrictHttpResponse<Platform>): Platform => r.body);
}
/** Path part for operation `deletePlatform()` */
static readonly DeletePlatformPath = '/api/platforms/{id}';
/**
* Delete Platform.
*
*
*
* This method provides access to the full `HttpResponse`, allowing access to response headers.
* To access only the response body, use `deletePlatform()` instead.
*
* This method doesn't expect any request body.
*/
deletePlatform$Response(params: DeletePlatform$Params, context?: HttpContext): Promise<StrictHttpResponse<void>> {
const obs = deletePlatform(this.http, this.rootUrl, params, context);
return firstValueFrom(obs);
}
/**
* Delete Platform.
*
*
*
* This method provides access only to the response body.
* To access the full response (for headers, for example), `deletePlatform$Response()` instead.
*
* This method doesn't expect any request body.
*/
deletePlatform(params: DeletePlatform$Params, context?: HttpContext): Promise<void> {
const resp = this.deletePlatform$Response(params, context);
return resp.then((r: StrictHttpResponse<void>): void => r.body);
}
}
+11
View File
@@ -0,0 +1,11 @@
/* eslint-disable */
/* Code generated by ng-openapi-gen DO NOT EDIT. */
import { HttpResponse } from '@angular/common/http';
/**
* Constrains the http response to not have the body defined as `T | null`, but `T` only.
*/
export type StrictHttpResponse<T> = HttpResponse<T> & {
readonly body: T;
}
+24
View File
@@ -0,0 +1,24 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth-interceptor';
import { provideApiConfiguration } from './api/api-configuration';
// The API's base URL. There's no server-side config to read it from at
// runtime in this small base (a plain SPA build), so it's a build-time
// constant; swap to Angular's fileReplacements if a later build needs a
// different URL per environment.
const API_BASE_URL = 'http://localhost:8080';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideAnimationsAsync(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
provideApiConfiguration(API_BASE_URL),
],
};
+16
View File
@@ -0,0 +1,16 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/auth-guard';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'games' },
{
path: 'login',
loadComponent: () => import('./features/login/login').then((m) => m.Login),
},
{
path: 'games',
loadComponent: () => import('./features/games/games').then((m) => m.Games),
canActivate: [authGuard],
},
];
+18
View File
@@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
expect(fixture.componentInstance).toBeTruthy();
});
});
+9
View File
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
imports: [RouterOutlet],
selector: 'app-root',
template: '<router-outlet />',
})
export class App {}
+43
View File
@@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { Router, UrlTree } from '@angular/router';
import { provideRouter } from '@angular/router';
import { authGuard } from './auth-guard';
import { Session } from './session';
import { provideApiConfiguration } from '../api/api-configuration';
describe('authGuard', () => {
let session: Session;
let router: Router;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideApiConfiguration('http://api.test'),
provideRouter([]),
],
});
session = TestBed.inject(Session);
router = TestBed.inject(Router);
});
function runGuard() {
return TestBed.runInInjectionContext(() => authGuard({} as any, {} as any));
}
it('allows navigation when authenticated', () => {
session.token.set('tok-abc');
expect(runGuard()).toBe(true);
});
it('redirects to /login when not authenticated', () => {
const result = runGuard();
expect(result).not.toBe(true);
expect((result as UrlTree).toString()).toBe(router.createUrlTree(['/login']).toString());
});
});
+12
View File
@@ -0,0 +1,12 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Session } from './session';
/** Redirects to /login unless a session token is present. */
export const authGuard: CanActivateFn = () => {
if (inject(Session).isAuthenticated()) {
return true;
}
return inject(Router).createUrlTree(['/login']);
};
+45
View File
@@ -0,0 +1,45 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { authInterceptor } from './auth-interceptor';
import { Session } from './session';
import { provideApiConfiguration } from '../api/api-configuration';
describe('authInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let session: Session;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
provideApiConfiguration('http://api.test'),
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
session = TestBed.inject(Session);
});
afterEach(() => httpMock.verify());
it('attaches the bearer token when a session is present', () => {
session.token.set('tok-abc');
http.get('/whatever').subscribe();
const req = httpMock.expectOne('/whatever');
expect(req.request.headers.get('Authorization')).toBe('Bearer tok-abc');
});
it('sends no Authorization header when logged out', () => {
http.get('/whatever').subscribe();
const req = httpMock.expectOne('/whatever');
expect(req.request.headers.has('Authorization')).toBe(false);
});
});
+13
View File
@@ -0,0 +1,13 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Session } from './session';
/** Attaches the stored session token to every outgoing API request. */
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(Session).token();
if (!token) {
return next(req);
}
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};
+90
View File
@@ -0,0 +1,90 @@
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { Session } from './session';
import { provideApiConfiguration } from '../api/api-configuration';
describe('Session', () => {
let service: Session;
let httpMock: HttpTestingController;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), provideApiConfiguration('http://api.test')],
});
service = TestBed.inject(Session);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('starts logged out with no stored token', () => {
expect(service.isAuthenticated()).toBe(false);
expect(service.user()).toBeNull();
});
it('restores the token from a previous session', () => {
localStorage.setItem('completed.token', 'tok-123');
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), provideApiConfiguration('http://api.test')],
});
const restored = TestBed.inject(Session);
expect(restored.isAuthenticated()).toBe(true);
});
it('login stores the token and user, and persists to localStorage', async () => {
const promise = service.login('alice@example.com', 'secret');
const req = httpMock.expectOne('http://api.test/api/auth/login');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ email: 'alice@example.com', password: 'secret' });
req.flush({ token: 'tok-abc', user: { id: 'u1', email: 'alice@example.com', name: 'Alice', is_admin: false } });
await promise;
expect(service.token()).toBe('tok-abc');
expect(service.user()?.name).toBe('Alice');
expect(service.isAdmin()).toBe(false);
expect(localStorage.getItem('completed.token')).toBe('tok-abc');
});
it('login failure leaves the session logged out', async () => {
const promise = service.login('alice@example.com', 'wrong');
const req = httpMock.expectOne('http://api.test/api/auth/login');
req.flush({ title: 'Unauthorized', status: 401, detail: 'invalid email or password' }, { status: 401, statusText: 'Unauthorized' });
await expect(promise).rejects.toBeTruthy();
expect(service.isAuthenticated()).toBe(false);
});
it('logout clears the session even if the request fails', async () => {
service.token.set('tok-abc');
service.user.set({ id: 'u1', email: 'a@example.com', name: 'Alice', is_admin: false });
const promise = service.logout();
const req = httpMock.expectOne('http://api.test/api/auth/logout');
req.flush(null, { status: 500, statusText: 'Internal Server Error' });
await expect(promise).rejects.toBeTruthy();
expect(service.isAuthenticated()).toBe(false);
expect(service.user()).toBeNull();
expect(localStorage.getItem('completed.token')).toBeNull();
});
it('restore() clears an invalid stored token instead of leaving a broken session', async () => {
service.token.set('tok-stale');
const promise = service.restore();
const req = httpMock.expectOne('http://api.test/api/auth/me');
req.flush({ title: 'Unauthorized', status: 401, detail: 'session expired' }, { status: 401, statusText: 'Unauthorized' });
await promise;
expect(service.isAuthenticated()).toBe(false);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { Service, computed, inject, signal } from '@angular/core';
import { AuthService } from '../api/services/auth.service';
import { User } from '../api/models/user';
/** Where the token is kept so a page refresh doesn't log the user out. */
const STORAGE_KEY = 'completed.token';
/**
* Holds the current login state (token + user) and is the one place that
* talks to the generated AuthService. Components read `user()` /
* `isAdmin()` and call `login`/`register`/`logout`; nothing else touches
* localStorage or the auth endpoints directly.
*/
@Service()
export class Session {
private readonly auth = inject(AuthService);
readonly user = signal<User | null>(null);
readonly token = signal<string | null>(this.readStoredToken());
readonly isAuthenticated = computed(() => this.token() !== null);
readonly isAdmin = computed(() => this.user()?.is_admin ?? false);
// A stored token with no user yet (e.g. after a page refresh) is
// resolved lazily by whoever needs `user()` first - in this small base,
// the games page calls `restore()` on init.
private readStoredToken(): string | null {
try {
return localStorage.getItem(STORAGE_KEY);
} catch {
// localStorage can throw in some privacy modes; fall back to
// "logged out" rather than crashing the app.
return null;
}
}
private storeToken(token: string | null): void {
try {
if (token) {
localStorage.setItem(STORAGE_KEY, token);
} else {
localStorage.removeItem(STORAGE_KEY);
}
} catch {
// Same as above: a storage failure shouldn't break login/logout.
}
}
/** Re-fetches the current user for a token restored from storage. */
async restore(): Promise<void> {
if (!this.token() || this.user()) {
return;
}
try {
const user = await this.auth.getMe();
this.user.set(user);
} catch {
// The stored token is no longer valid (expired/revoked); drop it so
// the app shows the login screen instead of a broken "logged in".
this.clear();
}
}
async login(email: string, password: string): Promise<void> {
const resp = await this.auth.login({ body: { email, password } });
this.token.set(resp.token);
this.user.set(resp.user);
this.storeToken(resp.token);
}
async register(email: string, password: string, name: string): Promise<void> {
const resp = await this.auth.register({ body: { email, password, name } });
this.token.set(resp.token);
this.user.set(resp.user);
this.storeToken(resp.token);
}
async logout(): Promise<void> {
try {
await this.auth.logout();
} finally {
this.clear();
}
}
private clear(): void {
this.token.set(null);
this.user.set(null);
this.storeToken(null);
}
}
+26
View File
@@ -0,0 +1,26 @@
.spacer {
flex: 1 1 auto;
}
.user-name {
margin-right: 1rem;
opacity: 0.8;
}
.content {
padding: 1.5rem;
}
.games-table {
width: 100%;
}
.centered {
display: flex;
justify-content: center;
padding-top: 3rem;
}
.error {
color: var(--mat-sys-error, #b3261e);
}
+51
View File
@@ -0,0 +1,51 @@
<mat-toolbar color="primary">
<span>Completed</span>
<span class="spacer"></span>
@if (session.user()) {
<span class="user-name">{{ session.user()!.name }}</span>
}
<button mat-icon-button (click)="refresh()" aria-label="Refresh" [disabled]="loading()">
<mat-icon>refresh</mat-icon>
</button>
<button mat-button (click)="logout()">Log out</button>
</mat-toolbar>
<div class="content">
@if (loading()) {
<div class="centered"><mat-spinner /></div>
} @else if (error()) {
<p class="error">{{ error() }}</p>
} @else if (games().length === 0) {
<p>No games yet.</p>
} @else {
<table mat-table [dataSource]="games()" class="games-table">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let g">{{ g.name }}</td>
</ng-container>
<ng-container matColumnDef="platform_name">
<th mat-header-cell *matHeaderCellDef>Platform</th>
<td mat-cell *matCellDef="let g">{{ g.platform_name }}</td>
</ng-container>
<ng-container matColumnDef="score">
<th mat-header-cell *matHeaderCellDef>Score</th>
<td mat-cell *matCellDef="let g">{{ g.score }}</td>
</ng-container>
<ng-container matColumnDef="release_date">
<th mat-header-cell *matHeaderCellDef>Released</th>
<td mat-cell *matCellDef="let g">{{ g.release_date }}</td>
</ng-container>
<ng-container matColumnDef="finished">
<th mat-header-cell *matHeaderCellDef>Finished</th>
<td mat-cell *matCellDef="let g">{{ g.finished ? '✓' : '' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
}
</div>
+109
View File
@@ -0,0 +1,109 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Router, provideRouter } from '@angular/router';
import { Games } from './games';
import { Session } from '../../core/session';
import { provideApiConfiguration } from '../../api/api-configuration';
describe('Games', () => {
let component: Games;
let fixture: ComponentFixture<Games>;
let httpMock: HttpTestingController;
let session: Session;
let router: Router;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [Games],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideApiConfiguration('http://api.test'),
provideRouter([]),
],
}).compileComponents();
fixture = TestBed.createComponent(Games);
component = fixture.componentInstance;
httpMock = TestBed.inject(HttpTestingController);
session = TestBed.inject(Session);
router = TestBed.inject(Router);
});
afterEach(() => httpMock.verify());
// Setting both token and user means `session.restore()` (called by
// ngOnInit) finds a user already present and skips its own HTTP call,
// so each test only has to deal with the request it's actually testing.
function logIn(): void {
session.token.set('tok-abc');
session.user.set({ id: 'u1', email: 'alice@example.com', name: 'Alice', is_admin: false });
}
// ngOnInit awaits session.restore() before calling refresh(), and
// restore() returns early (no HTTP call) once a user is already set -
// but that early return is still through an async function, so it still
// takes one microtask tick before ngOnInit resumes far enough to
// actually dispatch the /api/games request.
async function flushMicrotask(): Promise<void> {
await Promise.resolve();
}
it('loads games on init', async () => {
logIn();
const initPromise = component.ngOnInit();
await flushMicrotask();
const req = httpMock.expectOne('http://api.test/api/games');
req.flush([{ id: 'g1', name: 'Doom', platform_id: 'p1', platform_name: 'PC', score: 95, release_date: '1993-12-10' }]);
await initPromise;
expect(component.games()).toHaveLength(1);
expect(component.games()[0].name).toBe('Doom');
expect(component.error()).toBeNull();
expect(component.loading()).toBe(false);
});
it('shows an error when the request fails', async () => {
logIn();
const initPromise = component.ngOnInit();
await flushMicrotask();
const req = httpMock.expectOne('http://api.test/api/games');
req.flush({ title: 'Internal Server Error', status: 500 }, { status: 500, statusText: 'Internal Server Error' });
await initPromise;
expect(component.error()).toContain('Could not load games');
expect(component.games()).toHaveLength(0);
});
it('refresh() re-fetches the list', async () => {
logIn();
const initPromise = component.ngOnInit();
await flushMicrotask();
httpMock.expectOne('http://api.test/api/games').flush([]);
await initPromise;
const refreshPromise = component.refresh();
const req = httpMock.expectOne('http://api.test/api/games');
req.flush([{ id: 'g1', name: 'Half-Life', platform_id: 'p1', platform_name: 'PC', score: 96, release_date: '1998-11-19' }]);
await refreshPromise;
expect(component.games()).toHaveLength(1);
});
it('logout() clears the session and navigates to /login', async () => {
logIn();
const navigateSpy = vi.spyOn(router, 'navigateByUrl');
const logoutPromise = component.logout();
httpMock.expectOne('http://api.test/api/auth/logout').flush(null);
await logoutPromise;
expect(session.isAuthenticated()).toBe(false);
expect(navigateSpy).toHaveBeenCalledWith('/login');
});
});
+58
View File
@@ -0,0 +1,58 @@
import { Component, OnInit, signal } from '@angular/core';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTableModule } from '@angular/material/table';
import { MatToolbarModule } from '@angular/material/toolbar';
import { GamesService } from '../../api/services/games.service';
import { Game } from '../../api/models/game';
import { Session } from '../../core/session';
@Component({
imports: [
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatTableModule,
MatToolbarModule,
],
selector: 'app-games',
styleUrl: './games.css',
templateUrl: './games.html',
})
export class Games implements OnInit {
readonly columns = ['name', 'platform_name', 'score', 'release_date', 'finished'];
readonly games = signal<Game[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
constructor(
readonly session: Session,
private readonly gamesService: GamesService,
private readonly router: Router,
) {}
async ngOnInit(): Promise<void> {
await this.session.restore();
await this.refresh();
}
async refresh(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
this.games.set((await this.gamesService.listGames()) ?? []);
} catch {
this.error.set('Could not load games. Check your connection and try again.');
} finally {
this.loading.set(false);
}
}
async logout(): Promise<void> {
await this.session.logout();
this.router.navigateByUrl('/login');
}
}
+18
View File
@@ -0,0 +1,18 @@
.login-page {
display: flex;
justify-content: center;
padding-top: 10vh;
}
.login-card {
width: 100%;
max-width: 360px;
}
.full-width {
width: 100%;
}
.error {
color: var(--mat-sys-error, #b3261e);
}
+32
View File
@@ -0,0 +1,32 @@
<div class="login-page">
<mat-card class="login-card">
<mat-card-header>
<mat-card-title>Completed</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="submit()">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Email</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Password</mat-label>
<input matInput type="password" formControlName="password" autocomplete="current-password" />
</mat-form-field>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<button mat-flat-button color="primary" type="submit" [disabled]="form.invalid || submitting()">
@if (submitting()) {
<mat-spinner diameter="20" />
} @else {
Log in
}
</button>
</form>
</mat-card-content>
</mat-card>
</div>
+84
View File
@@ -0,0 +1,84 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Router, provideRouter } from '@angular/router';
import { Login } from './login';
import { provideApiConfiguration } from '../../api/api-configuration';
describe('Login', () => {
let component: Login;
let fixture: ComponentFixture<Login>;
let httpMock: HttpTestingController;
let router: Router;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [Login],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideApiConfiguration('http://api.test'),
provideRouter([]),
],
}).compileComponents();
fixture = TestBed.createComponent(Login);
component = fixture.componentInstance;
httpMock = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
await fixture.whenStable();
});
afterEach(() => httpMock.verify());
it('is invalid with empty fields', () => {
expect(component.form.invalid).toBe(true);
});
it('is valid with a well-formed email and non-empty password', () => {
component.form.setValue({ email: 'alice@example.com', password: 'secret' });
expect(component.form.valid).toBe(true);
});
it('rejects a malformed email', () => {
component.form.setValue({ email: 'not-an-email', password: 'secret' });
expect(component.form.valid).toBe(false);
});
it('on success, logs in and navigates to /games', async () => {
const navigateSpy = vi.spyOn(router, 'navigateByUrl');
component.form.setValue({ email: 'alice@example.com', password: 'secret' });
const submitPromise = component.submit();
const req = httpMock.expectOne('http://api.test/api/auth/login');
req.flush({ token: 'tok-abc', user: { id: 'u1', email: 'alice@example.com', name: 'Alice', is_admin: false } });
await submitPromise;
expect(navigateSpy).toHaveBeenCalledWith('/games');
expect(component.error()).toBeNull();
});
it('on failure, shows an error and does not navigate', async () => {
const navigateSpy = vi.spyOn(router, 'navigateByUrl');
component.form.setValue({ email: 'alice@example.com', password: 'wrong' });
const submitPromise = component.submit();
const req = httpMock.expectOne('http://api.test/api/auth/login');
req.flush(
{ title: 'Unauthorized', status: 401, detail: 'invalid email or password' },
{ status: 401, statusText: 'Unauthorized' },
);
await submitPromise;
expect(navigateSpy).not.toHaveBeenCalled();
expect(component.error()).toBe('invalid email or password');
});
it('does nothing when the form is invalid', async () => {
component.form.setValue({ email: '', password: '' });
await component.submit();
httpMock.expectNone('http://api.test/api/auth/login');
});
});
+68
View File
@@ -0,0 +1,68 @@
import { Component, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Session } from '../../core/session';
@Component({
imports: [
ReactiveFormsModule,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
],
selector: 'app-login',
styleUrl: './login.css',
templateUrl: './login.html',
})
export class Login {
readonly form = new FormBuilder().group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required],
});
readonly submitting = signal(false);
readonly error = signal<string | null>(null);
constructor(
private readonly session: Session,
private readonly router: Router,
) {}
async submit(): Promise<void> {
if (this.form.invalid || this.submitting()) {
return;
}
const { email, password } = this.form.getRawValue();
this.submitting.set(true);
this.error.set(null);
try {
await this.session.login(email!, password!);
this.router.navigateByUrl('/games');
} catch (err) {
this.error.set(errorMessage(err));
} finally {
this.submitting.set(false);
}
}
}
/** Pulls a server-provided message out of the generated client's error
* shape, falling back to a generic one for network failures etc. */
function errorMessage(err: unknown): string {
if (err && typeof err === 'object' && 'error' in err) {
const body = (err as { error?: unknown }).error;
if (body && typeof body === 'object' && 'detail' in body && typeof (body as any).detail === 'string') {
return (body as any).detail;
}
}
return 'Could not log in. Check your connection and try again.';
}
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Completed</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined"
rel="stylesheet"
/>
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
+38
View File
@@ -0,0 +1,38 @@
// Include theming for Angular Material with `mat.theme()`.
// This Sass mixin will define CSS variables that are used for styling Angular Material
// components according to the Material 3 design spec.
// Learn more about theming and how to use it for your application's
// custom components at https://material.angular.dev/guide/theming
@use '@angular/material' as mat;
html {
height: 100%;
@include mat.theme(
(
color: (
primary: mat.$azure-palette,
tertiary: mat.$blue-palette,
),
typography: Roboto,
density: 0,
)
);
}
body {
// Default the application to a light color theme. This can be changed to
// `dark` to enable the dark color theme, or to `light dark` to defer to the
// user's system settings.
color-scheme: light;
// Set a default background, font and text colors for the application using
// Angular Material's system-level CSS variables. Learn more about these
// variables at https://material.angular.dev/guide/system-variables
background-color: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
font: var(--mat-sys-body-medium);
// Reset the user agent margin.
margin: 0;
height: 100%;
}
+1
View File
@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
+31
View File
@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}