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

+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()
})
}()
}