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
+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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user