178 lines
4.7 KiB
Go
178 lines
4.7 KiB
Go
|
|
package ui
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/charmbracelet/bubbles/textinput"
|
||
|
|
tea "github.com/charmbracelet/bubbletea"
|
||
|
|
"github.com/charmbracelet/lipgloss"
|
||
|
|
|
||
|
|
"skyrama-tui/internal/service"
|
||
|
|
"skyrama-tui/internal/style"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ─── Messages ─────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
type LoginSuccessMsg struct {
|
||
|
|
UserID int32
|
||
|
|
Username string
|
||
|
|
}
|
||
|
|
type LoginErrMsg struct{ Err error }
|
||
|
|
|
||
|
|
// ─── Model ────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
type LoginModel struct {
|
||
|
|
client *service.GameService
|
||
|
|
inputs []textinput.Model // 0=username, 1=password
|
||
|
|
focus int
|
||
|
|
mode string // "login" | "register"
|
||
|
|
err string
|
||
|
|
width int
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewLoginModel(client *service.GameService) LoginModel {
|
||
|
|
user := textinput.New()
|
||
|
|
user.Placeholder = "username"
|
||
|
|
user.Focus()
|
||
|
|
user.CharLimit = 64
|
||
|
|
user.Width = 30
|
||
|
|
|
||
|
|
pass := textinput.New()
|
||
|
|
pass.Placeholder = "password"
|
||
|
|
pass.EchoMode = textinput.EchoPassword
|
||
|
|
pass.EchoCharacter = '•'
|
||
|
|
pass.CharLimit = 128
|
||
|
|
pass.Width = 30
|
||
|
|
|
||
|
|
return LoginModel{
|
||
|
|
client: client,
|
||
|
|
inputs: []textinput.Model{user, pass},
|
||
|
|
mode: "login",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m LoginModel) Init() tea.Cmd {
|
||
|
|
return textinput.Blink
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) {
|
||
|
|
switch msg := msg.(type) {
|
||
|
|
case tea.WindowSizeMsg:
|
||
|
|
m.width = msg.Width
|
||
|
|
|
||
|
|
case tea.KeyMsg:
|
||
|
|
switch msg.String() {
|
||
|
|
case "tab", "down":
|
||
|
|
m.focus = (m.focus + 1) % len(m.inputs)
|
||
|
|
for i := range m.inputs {
|
||
|
|
if i == m.focus {
|
||
|
|
m.inputs[i].Focus()
|
||
|
|
} else {
|
||
|
|
m.inputs[i].Blur()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
case "shift+tab", "up":
|
||
|
|
m.focus = (m.focus - 1 + len(m.inputs)) % len(m.inputs)
|
||
|
|
for i := range m.inputs {
|
||
|
|
if i == m.focus {
|
||
|
|
m.inputs[i].Focus()
|
||
|
|
} else {
|
||
|
|
m.inputs[i].Blur()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
case "ctrl+r":
|
||
|
|
m.mode = "register"
|
||
|
|
m.err = ""
|
||
|
|
case "ctrl+l":
|
||
|
|
m.mode = "login"
|
||
|
|
m.err = ""
|
||
|
|
case "enter":
|
||
|
|
username := strings.TrimSpace(m.inputs[0].Value())
|
||
|
|
password := m.inputs[1].Value()
|
||
|
|
if username == "" || password == "" {
|
||
|
|
m.err = "username and password required"
|
||
|
|
return m, nil
|
||
|
|
}
|
||
|
|
if m.mode == "login" {
|
||
|
|
return m, m.doLogin(username, password)
|
||
|
|
}
|
||
|
|
return m, m.doRegister(username, password)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Forward input events to focused field
|
||
|
|
var cmds []tea.Cmd
|
||
|
|
for i := range m.inputs {
|
||
|
|
var cmd tea.Cmd
|
||
|
|
m.inputs[i], cmd = m.inputs[i].Update(msg)
|
||
|
|
cmds = append(cmds, cmd)
|
||
|
|
}
|
||
|
|
return m, tea.Batch(cmds...)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m LoginModel) View() string {
|
||
|
|
banner := style.Title.Render("✈ SKYRAMA ✈")
|
||
|
|
|
||
|
|
modeLabel := style.PanelHeader.Render("Login")
|
||
|
|
if m.mode == "register" {
|
||
|
|
modeLabel = style.PanelHeader.Render("Register")
|
||
|
|
}
|
||
|
|
|
||
|
|
form := lipgloss.JoinVertical(lipgloss.Left,
|
||
|
|
style.Muted.Render("Username"),
|
||
|
|
m.inputs[0].View(),
|
||
|
|
"",
|
||
|
|
style.Muted.Render("Password"),
|
||
|
|
m.inputs[1].View(),
|
||
|
|
)
|
||
|
|
|
||
|
|
errLine := ""
|
||
|
|
if m.err != "" {
|
||
|
|
errLine = "\n" + style.Error.String() + style.Muted.Render(m.err)
|
||
|
|
}
|
||
|
|
|
||
|
|
hints := "\n" +
|
||
|
|
style.HelpKey.Render("enter") + style.HelpDesc.Render(" confirm ") +
|
||
|
|
style.HelpKey.Render("ctrl+r") + style.HelpDesc.Render(" register ") +
|
||
|
|
style.HelpKey.Render("ctrl+l") + style.HelpDesc.Render(" login ") +
|
||
|
|
style.HelpKey.Render("tab") + style.HelpDesc.Render(" next field")
|
||
|
|
|
||
|
|
box := style.Panel.Padding(1, 3).Render(
|
||
|
|
lipgloss.JoinVertical(lipgloss.Left, modeLabel, "", form, errLine, hints),
|
||
|
|
)
|
||
|
|
|
||
|
|
content := lipgloss.JoinVertical(lipgloss.Center, banner, "", box)
|
||
|
|
|
||
|
|
return lipgloss.Place(m.width, 24, lipgloss.Center, lipgloss.Center, content)
|
||
|
|
}
|
||
|
|
|
||
|
|
// SetErr injects an error string from outside (e.g. from main after a LoginErrMsg).
|
||
|
|
func (m *LoginModel) SetErr(e string) { m.err = e }
|
||
|
|
|
||
|
|
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func (m LoginModel) doLogin(username, password string) tea.Cmd {
|
||
|
|
return func() tea.Msg {
|
||
|
|
id, err := m.client.Login(username, password)
|
||
|
|
if err != nil {
|
||
|
|
return LoginErrMsg{Err: err}
|
||
|
|
}
|
||
|
|
return LoginSuccessMsg{UserID: id, Username: username}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m LoginModel) doRegister(username, password string) tea.Cmd {
|
||
|
|
return func() tea.Msg {
|
||
|
|
_, err := m.client.Register(username, password)
|
||
|
|
if err != nil {
|
||
|
|
return LoginErrMsg{Err: fmt.Errorf("register: %w", err)}
|
||
|
|
}
|
||
|
|
// Auto-login after register
|
||
|
|
id, err := m.client.Login(username, password)
|
||
|
|
if err != nil {
|
||
|
|
return LoginErrMsg{Err: err}
|
||
|
|
}
|
||
|
|
return LoginSuccessMsg{UserID: id, Username: username}
|
||
|
|
}
|
||
|
|
}
|