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:
1 parent
384c4a7217
commit
9972537b63
97 files changed
+11810
-336
No files matched your search
@@ -131,6 +131,15 @@ func main() {
|
||||
e := echo.New()
|
||||
e.Use(middleware.Logger())
|
||||
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"))
|
||||
|
||||
|
||||
+117
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user