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

+6 -136
View File
@@ -1,150 +1,20 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/spinner"
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() {
p := tea.NewProgram(InitialModel())
c := client.New(os.Getenv("COMPLETED_API_URL"))
p := tea.NewProgram(NewModel(c))
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)
}
}
+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)
}
}
}