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>
299 lines
6.7 KiB
Go
299 lines
6.7 KiB
Go
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()
|
|
}
|