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()
})
}()
}
+12 -135
View File
@@ -1,145 +1,22 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
"os"
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() {
rl.InitWindow(800, 600, "My Game Collection - Raylib")
defer rl.CloseWindow()
fyneApp := app.New()
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
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
window.ShowAndRun()
}
+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)
}
})
}
}