phase 0.3
This commit is contained in:
commit
4a94bfc4e7
52 changed files with 11653 additions and 0 deletions
275
internal/adminui/airports.go
Normal file
275
internal/adminui/airports.go
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type airportLoadedMsg []db.AdminAirport
|
||||
type airportDoneMsg struct{ err error }
|
||||
|
||||
// ── Action mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
type airportAction int
|
||||
|
||||
const (
|
||||
airportActionNone airportAction = iota
|
||||
airportActionGiveCash
|
||||
airportActionGiveBuilding
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// field indices for airport edit form
|
||||
const (
|
||||
aeqCash = 0
|
||||
aeqLevel = 1
|
||||
aeqMaxFuel = 2
|
||||
aeqMaxPax = 3
|
||||
)
|
||||
|
||||
type airportsTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminAirport
|
||||
cursor int
|
||||
// simple single-input actions (give cash / give building)
|
||||
action airportAction
|
||||
input textinput.Model
|
||||
// full edit form
|
||||
inForm bool
|
||||
editID uint32
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newAirportsTab(client *db.AdminClient) *airportsTab {
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 20
|
||||
return &airportsTab{client: client, input: ti}
|
||||
}
|
||||
|
||||
func (t *airportsTab) InForm() bool { return t.action != airportActionNone || t.inForm }
|
||||
func (t *airportsTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *airportsTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListAirports()
|
||||
if err != nil {
|
||||
return airportDoneMsg{err}
|
||||
}
|
||||
return airportLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *airportsTab) openEditForm(a db.AdminAirport) {
|
||||
t.fields = []field{
|
||||
prefill("Cash (set)", strconv.FormatUint(a.Cash, 10)),
|
||||
prefill("Player Level", strconv.Itoa(int(a.Level))),
|
||||
prefill("Max Fuel Capacity", strconv.Itoa(int(a.MaxFuel))),
|
||||
prefill("Max Passenger Cap", strconv.Itoa(int(a.MaxPax))),
|
||||
}
|
||||
t.fields[aeqCash].input.Focus()
|
||||
t.focusIdx = aeqCash
|
||||
t.editID = a.ID
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *airportsTab) submitEdit() tea.Cmd {
|
||||
cash, e1 := strconv.ParseUint(strings.TrimSpace(t.fields[aeqCash].value()), 10, 64)
|
||||
level, e2 := strconv.ParseUint(strings.TrimSpace(t.fields[aeqLevel].value()), 10, 32)
|
||||
maxFuel, e3 := strconv.ParseUint(strings.TrimSpace(t.fields[aeqMaxFuel].value()), 10, 32)
|
||||
maxPax, e4 := strconv.ParseUint(strings.TrimSpace(t.fields[aeqMaxPax].value()), 10, 32)
|
||||
if e1 != nil || e2 != nil || e3 != nil || e4 != nil {
|
||||
t.status = "all fields must be valid numbers"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
id := t.editID
|
||||
return func() tea.Msg {
|
||||
return airportDoneMsg{t.client.UpdateAirport(id, cash, uint32(level), uint32(maxFuel), uint32(maxPax))}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *airportsTab) openAction(a airportAction, placeholder string) {
|
||||
t.action = a
|
||||
t.input.Reset()
|
||||
t.input.Placeholder = placeholder
|
||||
t.input.Focus()
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *airportsTab) submitAction() tea.Cmd {
|
||||
if len(t.items) == 0 {
|
||||
return nil
|
||||
}
|
||||
airport := t.items[t.cursor]
|
||||
raw := strings.TrimSpace(t.input.Value())
|
||||
|
||||
switch t.action {
|
||||
case airportActionGiveCash:
|
||||
amount, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || amount == 0 {
|
||||
t.status = "enter a positive cash amount"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
id := airport.ID
|
||||
return func() tea.Msg {
|
||||
return airportDoneMsg{t.client.GiveAirportCash(id, amount)}
|
||||
}
|
||||
|
||||
case airportActionGiveBuilding:
|
||||
bID, err := strconv.ParseUint(raw, 10, 32)
|
||||
if err != nil || bID == 0 {
|
||||
t.status = "enter a valid building ID"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
id := airport.ID
|
||||
return func() tea.Msg {
|
||||
return airportDoneMsg{t.client.GiveAirportBuilding(id, uint32(bID))}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *airportsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case airportLoadedMsg:
|
||||
t.items = []db.AdminAirport(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case airportDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.action = airportActionNone
|
||||
t.inForm = false
|
||||
t.status = "done"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s":
|
||||
return t, t.submitEdit()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
if t.action != airportActionNone {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.action = airportActionNone
|
||||
t.status = ""
|
||||
case "enter", "ctrl+s":
|
||||
return t, t.submitAction()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.input, cmd = t.input.Update(m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "g":
|
||||
t.openAction(airportActionGiveCash, "amount, e.g. 5000")
|
||||
case "b":
|
||||
t.openAction(airportActionGiveBuilding, "building ID, e.g. 3")
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *airportsTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Airports") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
sb.WriteString(titleSt.Render(fmt.Sprintf(" Edit Airport #%d", t.editID)) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
if t.action != airportActionNone {
|
||||
if len(t.items) > 0 {
|
||||
ap := t.items[t.cursor]
|
||||
sb.WriteString(fmt.Sprintf(" Selected: %s (owner: %s, cash: %d)\n\n",
|
||||
titleSt.Render(ap.Name), ap.Username, ap.Cash))
|
||||
}
|
||||
label := "Give Cash Amount:"
|
||||
if t.action == airportActionGiveBuilding {
|
||||
label = "Building ID to add:"
|
||||
}
|
||||
sb.WriteString(" " + labelSt.Render(label) + " " + t.input.View() + "\n")
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render("enter:confirm esc:cancel"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-5s %-20s %-16s %-12s %-8s %s",
|
||||
"ID", "Airport Name", "Owner", "Cash", "Fuel", "Lvl")
|
||||
sb.WriteString(dimSt.Render(header) + "\n")
|
||||
sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 70)) + "\n")
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no airports)") + "\n")
|
||||
}
|
||||
for i, a := range t.items {
|
||||
line := fmt.Sprintf(" %-5d %-20s %-16s %-12d %-8d %d",
|
||||
a.ID, a.Name, a.Username, a.Cash, a.Fuel, a.Level)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render("e:edit g:give cash b:give building j/k:nav r:reload q:quit"))
|
||||
return sb.String()
|
||||
}
|
||||
121
internal/adminui/app.go
Normal file
121
internal/adminui/app.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
var tabNames = []string{"Products", "Countries", "Buildings", "Planes", "Users", "Airports", "Factions"}
|
||||
|
||||
// App is the root admin model. It owns the tab bar and routes messages.
|
||||
type App struct {
|
||||
tabs []Tab
|
||||
activeTab int
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// NewApp constructs the admin app and wires up all tab sub-models.
|
||||
func NewApp(client *db.AdminClient) App {
|
||||
return App{
|
||||
tabs: []Tab{
|
||||
newProductsTab(client),
|
||||
newCountriesTab(client),
|
||||
newBuildingsTab(client),
|
||||
newPlanesTab(client),
|
||||
newUsersTab(client),
|
||||
newAirportsTab(client),
|
||||
newFactionsTab(client),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for _, t := range a.tabs {
|
||||
if cmd := t.Init(); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
a.width, a.height = m.Width, m.Height
|
||||
// Fan out window size to all tabs
|
||||
for i, t := range a.tabs {
|
||||
updated, _ := t.Update(msg)
|
||||
a.tabs[i] = updated.(Tab)
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
activeInForm := a.tabs[a.activeTab].InForm()
|
||||
|
||||
// Global quit always works
|
||||
if m.String() == "ctrl+c" {
|
||||
return a, tea.Quit
|
||||
}
|
||||
|
||||
// Tab switching only when not in a form
|
||||
if !activeInForm {
|
||||
switch m.String() {
|
||||
case "1", "2", "3", "4", "5", "6", "7":
|
||||
idx := int(m.String()[0] - '1')
|
||||
if idx < len(a.tabs) {
|
||||
a.activeTab = idx
|
||||
}
|
||||
return a, nil
|
||||
case "tab":
|
||||
a.activeTab = (a.activeTab + 1) % len(a.tabs)
|
||||
return a, nil
|
||||
case "shift+tab":
|
||||
a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs)
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fan out non-key messages to all tabs; key messages only go to active tab
|
||||
if _, isKey := msg.(tea.KeyMsg); !isKey {
|
||||
var cmds []tea.Cmd
|
||||
for i, t := range a.tabs {
|
||||
updated, cmd := t.Update(msg)
|
||||
a.tabs[i] = updated.(Tab)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
updated, cmd := a.tabs[a.activeTab].Update(msg)
|
||||
a.tabs[a.activeTab] = updated.(Tab)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a App) View() string {
|
||||
// Tab bar
|
||||
var tabs []string
|
||||
for i, name := range tabNames {
|
||||
key := dimSt.Render(string(rune('1'+i))) + " "
|
||||
if i == a.activeTab {
|
||||
tabs = append(tabs, lipgloss.NewStyle().Bold(true).
|
||||
Foreground(lipgloss.Color("#E94560")).
|
||||
Render("["+key+name+"]"))
|
||||
} else {
|
||||
tabs = append(tabs, dimSt.Render(" "+key+name+" "))
|
||||
}
|
||||
}
|
||||
bar := strings.Join(tabs, dimSt.Render("│"))
|
||||
header := titleSt.Render("✈ SKYRAMA ADMIN") + " " + bar
|
||||
|
||||
divider := dimSt.Render(strings.Repeat("─", a.width))
|
||||
|
||||
return header + "\n" + divider + "\n" + a.tabs[a.activeTab].View()
|
||||
}
|
||||
310
internal/adminui/buildings.go
Normal file
310
internal/adminui/buildings.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type bldgLoadedMsg []db.AdminBuilding
|
||||
type bldgDoneMsg struct{ err error }
|
||||
|
||||
// ── Field indices ─────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
bfName = 0
|
||||
bfMinLevel = 1
|
||||
bfCost = 2
|
||||
bfType = 3
|
||||
// storage
|
||||
bfCapacity = 4
|
||||
bfStorType = 5
|
||||
bfStorSubtype = 6
|
||||
// operation
|
||||
bfOpType = 7
|
||||
bfOpSize = 8
|
||||
// passive
|
||||
bfPassiveType = 9
|
||||
bfPassiveRate = 10
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type buildingsTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminBuilding
|
||||
cursor int
|
||||
inForm bool
|
||||
isEdit bool
|
||||
editID uint32
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newBuildingsTab(client *db.AdminClient) *buildingsTab {
|
||||
return &buildingsTab{client: client}
|
||||
}
|
||||
|
||||
func (t *buildingsTab) InForm() bool { return t.inForm }
|
||||
func (t *buildingsTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *buildingsTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListBuildings()
|
||||
if err != nil {
|
||||
return bldgDoneMsg{err}
|
||||
}
|
||||
return bldgLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *buildingsTab) initFields() {
|
||||
t.fields = []field{
|
||||
newTextField("Name", "e.g. Small Hangar"),
|
||||
newTextField("Min Level", "1"),
|
||||
newTextField("Cost (cash)", "100"),
|
||||
newEnumField("Type", []string{"none", "storage", "operation", "passive"}),
|
||||
// storage
|
||||
newTextField("Capacity", "10"),
|
||||
newEnumField("Storage Type", []string{"fuel", "plane", "product"}),
|
||||
newEnumField("Storage Subtype", []string{"none", "airplane", "helicopter", "seaplane", "spaceship"}),
|
||||
// operation
|
||||
newEnumField("Op Type", []string{"runway", "service", "production"}),
|
||||
newEnumField("Op Size", []string{"small", "medium", "large", "huge"}),
|
||||
// passive
|
||||
newEnumField("Passive Type", []string{"passengers", "fuel"}),
|
||||
newTextField("Passive Rate", "10"),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *buildingsTab) openForm() {
|
||||
t.initFields()
|
||||
t.fields[bfName].input.Focus()
|
||||
t.focusIdx = bfName
|
||||
t.isEdit = false
|
||||
t.updateHidden()
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *buildingsTab) openEditForm(b db.AdminBuilding) {
|
||||
t.initFields()
|
||||
t.fields[bfName].input.SetValue(b.Name)
|
||||
t.fields[bfMinLevel].input.SetValue(strconv.Itoa(int(b.MinLevel)))
|
||||
t.fields[bfCost].input.SetValue(strconv.Itoa(int(b.Cost)))
|
||||
setEnumSel(&t.fields[bfType], b.BuildingType)
|
||||
t.fields[bfCapacity].input.SetValue(strconv.Itoa(b.Capacity))
|
||||
setEnumSel(&t.fields[bfStorType], b.StorageType)
|
||||
setEnumSel(&t.fields[bfStorSubtype], b.StorageSubtype)
|
||||
setEnumSel(&t.fields[bfOpType], b.OperationType)
|
||||
setEnumSel(&t.fields[bfOpSize], b.OperationSize)
|
||||
setEnumSel(&t.fields[bfPassiveType], b.PassiveType)
|
||||
t.fields[bfPassiveRate].input.SetValue(strconv.Itoa(b.PassiveRate))
|
||||
t.fields[bfName].input.Focus()
|
||||
t.focusIdx = bfName
|
||||
t.isEdit = true
|
||||
t.editID = b.ID
|
||||
t.updateHidden()
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
// updateHidden shows/hides type-specific fields based on the type enum selection.
|
||||
func (t *buildingsTab) updateHidden() {
|
||||
btype := t.fields[bfType].options[t.fields[bfType].sel]
|
||||
for i := range t.fields {
|
||||
switch i {
|
||||
case bfCapacity, bfStorType, bfStorSubtype:
|
||||
t.fields[i].hidden = btype != "storage"
|
||||
case bfOpType, bfOpSize:
|
||||
t.fields[i].hidden = btype != "operation"
|
||||
case bfPassiveType, bfPassiveRate:
|
||||
t.fields[i].hidden = btype != "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *buildingsTab) submit() tea.Cmd {
|
||||
name := strings.TrimSpace(t.fields[bfName].value())
|
||||
if name == "" {
|
||||
t.status = "name is required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
lvl, err := strconv.ParseUint(strings.TrimSpace(t.fields[bfMinLevel].value()), 10, 32)
|
||||
if err != nil {
|
||||
t.status = "min level must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
cost, err := strconv.ParseUint(strings.TrimSpace(t.fields[bfCost].value()), 10, 32)
|
||||
if err != nil {
|
||||
t.status = "cost must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
btype := t.fields[bfType].value()
|
||||
|
||||
p := db.AddBuildingParams{
|
||||
Name: name,
|
||||
MinLevel: uint32(lvl),
|
||||
Cost: uint32(cost),
|
||||
BuildingType: btype,
|
||||
}
|
||||
|
||||
switch btype {
|
||||
case "storage":
|
||||
cap, err := strconv.Atoi(strings.TrimSpace(t.fields[bfCapacity].value()))
|
||||
if err != nil || cap <= 0 {
|
||||
t.status = "capacity must be a positive number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
p.Capacity = cap
|
||||
p.StorageType = t.fields[bfStorType].value()
|
||||
p.StorageSubtype = t.fields[bfStorSubtype].value()
|
||||
case "operation":
|
||||
p.OperationType = t.fields[bfOpType].value()
|
||||
p.OperationSize = t.fields[bfOpSize].value()
|
||||
case "passive":
|
||||
rate, err := strconv.Atoi(strings.TrimSpace(t.fields[bfPassiveRate].value()))
|
||||
if err != nil {
|
||||
t.status = "passive rate must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
p.PassiveType = t.fields[bfPassiveType].value()
|
||||
p.PassiveRate = rate
|
||||
}
|
||||
|
||||
if t.isEdit {
|
||||
id := t.editID
|
||||
return func() tea.Msg { return bldgDoneMsg{t.client.UpdateBuilding(id, p)} }
|
||||
}
|
||||
return func() tea.Msg { return bldgDoneMsg{t.client.AddBuilding(p)} }
|
||||
}
|
||||
|
||||
func (t *buildingsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case bldgLoadedMsg:
|
||||
t.items = []db.AdminBuilding(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case bldgDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.inForm = false
|
||||
t.status = "saved"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s":
|
||||
return t, t.submit()
|
||||
default:
|
||||
prevType := t.fields[bfType].sel
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
// If type enum changed, update field visibility
|
||||
if t.fields[bfType].sel != prevType {
|
||||
t.updateHidden()
|
||||
// If current focus is now hidden, jump to next visible
|
||||
if t.focusIdx < len(t.fields) && t.fields[t.focusIdx].hidden {
|
||||
t.focusIdx = nextVisible(t.fields, t.focusIdx)
|
||||
applyFocus(t.fields, t.focusIdx)
|
||||
}
|
||||
}
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "a":
|
||||
t.openForm()
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "d":
|
||||
if len(t.items) > 0 {
|
||||
id := t.items[t.cursor].ID
|
||||
return t, func() tea.Msg {
|
||||
return bldgDoneMsg{t.client.DeleteBuilding(id)}
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *buildingsTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Buildings") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
formTitle := "Add Building"
|
||||
if t.isEdit {
|
||||
formTitle = fmt.Sprintf("Edit Building #%d", t.editID)
|
||||
}
|
||||
sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-5s %-24s %-12s %-8s %s", "ID", "Name", "Type", "Cost", "Details")
|
||||
sb.WriteString(dimSt.Render(header) + "\n")
|
||||
sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 65)) + "\n")
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no buildings — press a to add)") + "\n")
|
||||
}
|
||||
for i, b := range t.items {
|
||||
line := fmt.Sprintf(" %-5d %-24s %-12s %-8d %s",
|
||||
b.ID, b.Name, b.BuildingType, b.Cost, b.Details)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpList))
|
||||
return sb.String()
|
||||
}
|
||||
226
internal/adminui/countries.go
Normal file
226
internal/adminui/countries.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type cntryLoadedMsg []db.AdminCountry
|
||||
type cntryDoneMsg struct{ err error }
|
||||
|
||||
// ── Field indices ─────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
cfName = 0
|
||||
cfContinent = 1
|
||||
cfP1 = 2
|
||||
cfP2 = 3
|
||||
cfP3 = 4
|
||||
)
|
||||
|
||||
var continentOptions = []string{
|
||||
"void", "Africa", "Antarctica", "Asia",
|
||||
"Europe", "North America", "Oceania", "South America",
|
||||
}
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type countriesTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminCountry
|
||||
cursor int
|
||||
inForm bool
|
||||
isEdit bool
|
||||
editID uint16
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newCountriesTab(client *db.AdminClient) *countriesTab {
|
||||
return &countriesTab{client: client}
|
||||
}
|
||||
|
||||
func (t *countriesTab) InForm() bool { return t.inForm }
|
||||
func (t *countriesTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *countriesTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListCountries()
|
||||
if err != nil {
|
||||
return cntryDoneMsg{err}
|
||||
}
|
||||
return cntryLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *countriesTab) openForm() {
|
||||
t.fields = []field{
|
||||
newTextField("Name", "e.g. Turkey"),
|
||||
newEnumField("Continent", continentOptions),
|
||||
newTextField("Product 1 ID", "1"),
|
||||
newTextField("Product 2 ID", "2"),
|
||||
newTextField("Product 3 ID", "3"),
|
||||
}
|
||||
t.fields[cfName].input.Focus()
|
||||
t.focusIdx = cfName
|
||||
t.isEdit = false
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *countriesTab) openEditForm(c db.AdminCountry) {
|
||||
t.fields = []field{
|
||||
prefill("Name", c.Name),
|
||||
newEnumField("Continent", continentOptions),
|
||||
prefill("Product 1 ID", strconv.Itoa(int(c.P1))),
|
||||
prefill("Product 2 ID", strconv.Itoa(int(c.P2))),
|
||||
prefill("Product 3 ID", strconv.Itoa(int(c.P3))),
|
||||
}
|
||||
setEnumSel(&t.fields[cfContinent], c.Continent)
|
||||
t.fields[cfName].input.Focus()
|
||||
t.focusIdx = cfName
|
||||
t.isEdit = true
|
||||
t.editID = c.ID
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *countriesTab) submit() tea.Cmd {
|
||||
name := strings.TrimSpace(t.fields[cfName].value())
|
||||
continent := t.fields[cfContinent].value()
|
||||
p1s := strings.TrimSpace(t.fields[cfP1].value())
|
||||
p2s := strings.TrimSpace(t.fields[cfP2].value())
|
||||
p3s := strings.TrimSpace(t.fields[cfP3].value())
|
||||
|
||||
if name == "" {
|
||||
t.status = "name is required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
p1, e1 := strconv.ParseUint(p1s, 10, 16)
|
||||
p2, e2 := strconv.ParseUint(p2s, 10, 16)
|
||||
p3, e3 := strconv.ParseUint(p3s, 10, 16)
|
||||
if e1 != nil || e2 != nil || e3 != nil {
|
||||
t.status = "product IDs must be valid numbers"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
if t.isEdit {
|
||||
id := t.editID
|
||||
return func() tea.Msg {
|
||||
return cntryDoneMsg{t.client.UpdateCountry(id, name, continent, uint16(p1), uint16(p2), uint16(p3))}
|
||||
}
|
||||
}
|
||||
return func() tea.Msg {
|
||||
return cntryDoneMsg{t.client.AddCountry(name, continent, uint16(p1), uint16(p2), uint16(p3))}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *countriesTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case cntryLoadedMsg:
|
||||
t.items = []db.AdminCountry(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case cntryDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.inForm = false
|
||||
t.status = "saved"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s":
|
||||
return t, t.submit()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "a":
|
||||
t.openForm()
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *countriesTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Countries") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
formTitle := "Add Country"
|
||||
if t.isEdit {
|
||||
formTitle = fmt.Sprintf("Edit Country #%d", t.editID)
|
||||
}
|
||||
sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-5s %-20s %-14s %s", "ID", "Name", "Continent", "Products (P1/P2/P3)")
|
||||
sb.WriteString(dimSt.Render(header) + "\n")
|
||||
sb.WriteString(dimSt.Render(" " + strings.Repeat("─", 60)) + "\n")
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no countries)") + "\n")
|
||||
}
|
||||
for i, c := range t.items {
|
||||
line := fmt.Sprintf(" %-5d %-20s %-14s %d / %d / %d",
|
||||
c.ID, c.Name, c.Continent, c.P1, c.P2, c.P3)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render("a:add e:edit j/k:nav r:reload q:quit"))
|
||||
return sb.String()
|
||||
}
|
||||
232
internal/adminui/factions.go
Normal file
232
internal/adminui/factions.go
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type factionLoadedMsg []db.AdminFaction
|
||||
type factionDoneMsg struct{ err error }
|
||||
|
||||
// ── Field indices ─────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
ffName = 0
|
||||
ffContinent = 1
|
||||
ffLeader = 2
|
||||
ffBackground = 3
|
||||
ffUser = 4
|
||||
ffAirport = 5
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type factionsTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminFaction
|
||||
cursor int
|
||||
inForm bool
|
||||
isEdit bool
|
||||
editID uint32
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newFactionsTab(client *db.AdminClient) *factionsTab {
|
||||
return &factionsTab{client: client}
|
||||
}
|
||||
|
||||
func (t *factionsTab) InForm() bool { return t.inForm }
|
||||
func (t *factionsTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *factionsTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListFactions()
|
||||
if err != nil {
|
||||
return factionDoneMsg{err}
|
||||
}
|
||||
return factionLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *factionsTab) openForm() {
|
||||
t.fields = []field{
|
||||
newTextField("Name", "e.g. Monsoon Alliance"),
|
||||
newEnumField("Continent", continentOptions),
|
||||
newTextField("Leader", "e.g. Mei-Lin Zhao"),
|
||||
newTextField("Background", "short bio"),
|
||||
newTextField("Owner User ID", "NPC user id"),
|
||||
newTextField("Central Airport ID", "central airport id"),
|
||||
}
|
||||
t.fields[ffName].input.Focus()
|
||||
t.focusIdx = ffName
|
||||
t.isEdit = false
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *factionsTab) openEditForm(f db.AdminFaction) {
|
||||
t.fields = []field{
|
||||
prefill("Name", f.Name),
|
||||
newEnumField("Continent", continentOptions),
|
||||
prefill("Leader", f.Leader),
|
||||
prefill("Background", f.Background),
|
||||
prefill("Owner User ID", strconv.Itoa(int(f.UserID))),
|
||||
prefill("Central Airport ID", strconv.Itoa(int(f.CentralAirportID))),
|
||||
}
|
||||
setEnumSel(&t.fields[ffContinent], f.Continent)
|
||||
t.fields[ffName].input.Focus()
|
||||
t.focusIdx = ffName
|
||||
t.isEdit = true
|
||||
t.editID = f.ID
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *factionsTab) submit() tea.Cmd {
|
||||
name := strings.TrimSpace(t.fields[ffName].value())
|
||||
continent := t.fields[ffContinent].value()
|
||||
leader := strings.TrimSpace(t.fields[ffLeader].value())
|
||||
background := strings.TrimSpace(t.fields[ffBackground].value())
|
||||
userStr := strings.TrimSpace(t.fields[ffUser].value())
|
||||
airportStr := strings.TrimSpace(t.fields[ffAirport].value())
|
||||
|
||||
if name == "" || leader == "" {
|
||||
t.status = "name and leader are required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
userID, e1 := strconv.ParseInt(userStr, 10, 32)
|
||||
airportID, e2 := strconv.ParseUint(airportStr, 10, 32)
|
||||
if e1 != nil || e2 != nil {
|
||||
t.status = "owner user ID and central airport ID must be valid numbers"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
if t.isEdit {
|
||||
id := t.editID
|
||||
return func() tea.Msg {
|
||||
return factionDoneMsg{t.client.UpdateFaction(id, name, continent, leader, background, int32(userID), uint32(airportID))}
|
||||
}
|
||||
}
|
||||
return func() tea.Msg {
|
||||
return factionDoneMsg{t.client.AddFaction(name, continent, leader, background, int32(userID), uint32(airportID))}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *factionsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case factionLoadedMsg:
|
||||
t.items = []db.AdminFaction(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case factionDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.inForm = false
|
||||
t.status = "saved"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s":
|
||||
return t, t.submit()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "a":
|
||||
t.openForm()
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "d":
|
||||
if len(t.items) > 0 {
|
||||
id := t.items[t.cursor].ID
|
||||
return t, func() tea.Msg {
|
||||
return factionDoneMsg{t.client.DeleteFaction(id)}
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *factionsTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Factions") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
formTitle := "Add Faction"
|
||||
if t.isEdit {
|
||||
formTitle = fmt.Sprintf("Edit Faction #%d", t.editID)
|
||||
}
|
||||
sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-4s %-14s %-22s %-18s %-7s %s",
|
||||
"ID", "Continent", "Faction", "Leader", "UserID", "AirportID")
|
||||
sb.WriteString(dimSt.Render(header) + "\n")
|
||||
sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 78)) + "\n")
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no factions — run sql/seed_factions.sql)") + "\n")
|
||||
}
|
||||
for i, f := range t.items {
|
||||
line := fmt.Sprintf(" %-4d %-14s %-22s %-18s %-7d %d",
|
||||
f.ID, f.Continent, f.Name, f.Leader, f.UserID, f.CentralAirportID)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render("a:add e:edit d:delete j/k:nav r:reload q:quit"))
|
||||
return sb.String()
|
||||
}
|
||||
334
internal/adminui/planes.go
Normal file
334
internal/adminui/planes.go
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type planeLoadedMsg []db.AdminPlane
|
||||
type planeDoneMsg struct{ err error }
|
||||
|
||||
// ── Field indices ─────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
pfName = 0
|
||||
pfSize = 1
|
||||
pfType = 2
|
||||
pfOpType = 3
|
||||
pfMinLevel = 4
|
||||
pfTravelSec = 5
|
||||
pfFuelCost = 6
|
||||
pfCashCost = 7
|
||||
pfMaxPax = 8
|
||||
pfBuyCost = 9
|
||||
pfXP = 10
|
||||
pfCashRew = 11
|
||||
pfCargoRew = 12
|
||||
pfBoardingTime = 13
|
||||
pfFuelingTime = 14
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type planesTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminPlane
|
||||
cursor int
|
||||
inForm bool
|
||||
isEdit bool
|
||||
editID uint32
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newPlanesTab(client *db.AdminClient) *planesTab {
|
||||
return &planesTab{client: client}
|
||||
}
|
||||
|
||||
func (t *planesTab) InForm() bool { return t.inForm }
|
||||
func (t *planesTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *planesTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListPlanes()
|
||||
if err != nil {
|
||||
return planeDoneMsg{err}
|
||||
}
|
||||
return planeLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *planesTab) initFields() []field {
|
||||
return []field{
|
||||
newTextField("Name", "e.g. Cessna 172"),
|
||||
newEnumField("Aircraft Size", []string{"small", "medium", "large", "huge"}),
|
||||
newEnumField("Aircraft Type", []string{"plane", "helicopter", "seaplane", "spaceship"}),
|
||||
newEnumField("Operation Type", []string{"passenger", "cargo"}),
|
||||
newTextField("Min Level", "1"),
|
||||
newTextField("Travel Time (sec)", "300"),
|
||||
newTextField("Fuel Cost", "50"),
|
||||
newTextField("Cash Cost", "10"),
|
||||
newTextField("Max Passengers (0=null)", "0"),
|
||||
newTextField("Buy Cost (cash)", "1000"),
|
||||
newTextField("XP Reward", "20"),
|
||||
newTextField("Cash Reward (0=null)", "50"),
|
||||
newTextField("Cargo Reward (0=null)", "0"),
|
||||
newTextField("Boarding Time (sec, 0=skip)", "0"),
|
||||
newTextField("Fueling Time (sec, 0=skip)", "0"),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *planesTab) openForm() {
|
||||
t.fields = t.initFields()
|
||||
t.fields[pfName].input.Focus()
|
||||
t.focusIdx = pfName
|
||||
t.isEdit = false
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *planesTab) openEditForm(p db.AdminPlane) {
|
||||
t.fields = t.initFields()
|
||||
t.fields[pfName].input.SetValue(p.Name)
|
||||
setEnumSel(&t.fields[pfSize], p.Size)
|
||||
setEnumSel(&t.fields[pfType], p.AircraftType)
|
||||
setEnumSel(&t.fields[pfOpType], p.OperationType)
|
||||
t.fields[pfMinLevel].input.SetValue(strconv.Itoa(int(p.MinLevel)))
|
||||
t.fields[pfTravelSec].input.SetValue(strconv.Itoa(int(p.TravelSec)))
|
||||
t.fields[pfFuelCost].input.SetValue(strconv.Itoa(int(p.FuelCost)))
|
||||
t.fields[pfCashCost].input.SetValue(strconv.Itoa(int(p.CashCost)))
|
||||
t.fields[pfMaxPax].input.SetValue(strconv.Itoa(p.MaxPassengers))
|
||||
t.fields[pfBuyCost].input.SetValue(strconv.Itoa(int(p.BuyCost)))
|
||||
t.fields[pfXP].input.SetValue(strconv.Itoa(int(p.XP)))
|
||||
t.fields[pfCashRew].input.SetValue(strconv.Itoa(p.CashReward))
|
||||
t.fields[pfCargoRew].input.SetValue(strconv.Itoa(p.CargoReward))
|
||||
t.fields[pfBoardingTime].input.SetValue(strconv.Itoa(int(p.BoardingTimeSec)))
|
||||
t.fields[pfFuelingTime].input.SetValue(strconv.Itoa(int(p.FuelingTimeSec)))
|
||||
t.fields[pfName].input.Focus()
|
||||
t.focusIdx = pfName
|
||||
t.isEdit = true
|
||||
t.editID = p.ID
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func mustInt(s string) (int, error) {
|
||||
return strconv.Atoi(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func (t *planesTab) submit() tea.Cmd {
|
||||
name := strings.TrimSpace(t.fields[pfName].value())
|
||||
if name == "" {
|
||||
t.status = "name is required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
|
||||
lvl, err := mustInt(t.fields[pfMinLevel].value())
|
||||
if err != nil {
|
||||
t.status = "min level must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
travelSec, err := mustInt(t.fields[pfTravelSec].value())
|
||||
if err != nil || travelSec <= 0 {
|
||||
t.status = "travel time must be a positive number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
fuelCost, err := mustInt(t.fields[pfFuelCost].value())
|
||||
if err != nil {
|
||||
t.status = "fuel cost must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
cashCost, err := mustInt(t.fields[pfCashCost].value())
|
||||
if err != nil {
|
||||
t.status = "cash cost must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
maxPax, err := mustInt(t.fields[pfMaxPax].value())
|
||||
if err != nil {
|
||||
t.status = "max passengers must be a number (0 for none)"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
buyCost, err := mustInt(t.fields[pfBuyCost].value())
|
||||
if err != nil {
|
||||
t.status = "buy cost must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
xp, err := mustInt(t.fields[pfXP].value())
|
||||
if err != nil {
|
||||
t.status = "XP reward must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
cashRew, err := mustInt(t.fields[pfCashRew].value())
|
||||
if err != nil {
|
||||
t.status = "cash reward must be a number (0 for none)"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
cargoRew, err := mustInt(t.fields[pfCargoRew].value())
|
||||
if err != nil {
|
||||
t.status = "cargo reward must be a number (0 for none)"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
boardingTime, err := mustInt(t.fields[pfBoardingTime].value())
|
||||
if err != nil {
|
||||
t.status = "boarding time must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
fuelingTime, err := mustInt(t.fields[pfFuelingTime].value())
|
||||
if err != nil {
|
||||
t.status = "fueling time must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
|
||||
p := db.AddPlaneParams{
|
||||
Name: name,
|
||||
Size: t.fields[pfSize].value(),
|
||||
AircraftType: t.fields[pfType].value(),
|
||||
OperationType: t.fields[pfOpType].value(),
|
||||
MinLevel: uint32(lvl),
|
||||
FuelCost: uint32(fuelCost),
|
||||
CashCost: uint32(cashCost),
|
||||
MaxPassengers: maxPax,
|
||||
TravelSec: uint32(travelSec),
|
||||
BuyCost: uint32(buyCost),
|
||||
XP: uint32(xp),
|
||||
CashReward: cashRew,
|
||||
CargoReward: cargoRew,
|
||||
BoardingTimeSec: boardingTime,
|
||||
FuelingTimeSec: fuelingTime,
|
||||
}
|
||||
if t.isEdit {
|
||||
id := t.editID
|
||||
return func() tea.Msg { return planeDoneMsg{t.client.UpdatePlane(id, p)} }
|
||||
}
|
||||
return func() tea.Msg { return planeDoneMsg{t.client.AddPlane(p)} }
|
||||
}
|
||||
|
||||
func (t *planesTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case planeLoadedMsg:
|
||||
t.items = []db.AdminPlane(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case planeDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.inForm = false
|
||||
t.status = "saved"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s":
|
||||
return t, t.submit()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "a":
|
||||
t.openForm()
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "d":
|
||||
if len(t.items) > 0 {
|
||||
id := t.items[t.cursor].ID
|
||||
return t, func() tea.Msg {
|
||||
return planeDoneMsg{t.client.DeletePlane(id)}
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *planesTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Planes") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
formTitle := "Add Plane"
|
||||
if t.isEdit {
|
||||
formTitle = fmt.Sprintf("Edit Plane #%d", t.editID)
|
||||
}
|
||||
sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-5s %-20s %-8s %-12s %-10s %-5s %-8s %s",
|
||||
"ID", "Name", "Size", "Type", "Op", "Lvl", "Travel", "Buy$")
|
||||
sb.WriteString(dimSt.Render(header) + "\n")
|
||||
sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 80)) + "\n")
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no planes — press a to add)") + "\n")
|
||||
}
|
||||
for i, p := range t.items {
|
||||
travelFmt := fmt.Sprintf("%ds", p.TravelSec)
|
||||
line := fmt.Sprintf(" %-5d %-20s %-8s %-12s %-10s %-5d %-8s %d",
|
||||
p.ID, p.Name, p.Size, p.AircraftType, p.OperationType, p.MinLevel, travelFmt, p.BuyCost)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpList))
|
||||
return sb.String()
|
||||
}
|
||||
187
internal/adminui/products.go
Normal file
187
internal/adminui/products.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type prodLoadedMsg []db.AdminProduct
|
||||
type prodDoneMsg struct{ err error }
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type productsTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminProduct
|
||||
cursor int
|
||||
inForm bool
|
||||
isEdit bool
|
||||
editID uint16
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newProductsTab(client *db.AdminClient) *productsTab {
|
||||
return &productsTab{client: client}
|
||||
}
|
||||
|
||||
func (t *productsTab) InForm() bool { return t.inForm }
|
||||
|
||||
func (t *productsTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *productsTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListProducts()
|
||||
if err != nil {
|
||||
return prodDoneMsg{err}
|
||||
}
|
||||
return prodLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *productsTab) openForm() {
|
||||
f := newTextField("Name", "e.g. Milk")
|
||||
f.input.Focus()
|
||||
t.fields = []field{f}
|
||||
t.focusIdx = 0
|
||||
t.isEdit = false
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *productsTab) openEditForm(p db.AdminProduct) {
|
||||
f := prefill("Name", p.Name)
|
||||
f.input.Focus()
|
||||
t.fields = []field{f}
|
||||
t.focusIdx = 0
|
||||
t.isEdit = true
|
||||
t.editID = p.ID
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *productsTab) submit() tea.Cmd {
|
||||
name := strings.TrimSpace(t.fields[0].value())
|
||||
if name == "" {
|
||||
t.status = "name is required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
if t.isEdit {
|
||||
id := t.editID
|
||||
return func() tea.Msg { return prodDoneMsg{t.client.UpdateProduct(id, name)} }
|
||||
}
|
||||
return func() tea.Msg { return prodDoneMsg{t.client.AddProduct(name)} }
|
||||
}
|
||||
|
||||
func (t *productsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case prodLoadedMsg:
|
||||
t.items = []db.AdminProduct(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case prodDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.inForm = false
|
||||
t.status = "saved"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s", "enter":
|
||||
return t, t.submit()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// List mode
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "a":
|
||||
t.openForm()
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "d":
|
||||
if len(t.items) > 0 {
|
||||
id := t.items[t.cursor].ID
|
||||
return t, func() tea.Msg {
|
||||
return prodDoneMsg{t.client.DeleteProduct(id)}
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *productsTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Products") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
formTitle := "Add Product"
|
||||
if t.isEdit {
|
||||
formTitle = fmt.Sprintf("Edit Product #%d", t.editID)
|
||||
}
|
||||
sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no products — press a to add)") + "\n")
|
||||
}
|
||||
for i, p := range t.items {
|
||||
line := fmt.Sprintf(" #%-4d %s", p.ID, p.Name)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpList))
|
||||
return sb.String()
|
||||
}
|
||||
203
internal/adminui/shared.go
Normal file
203
internal/adminui/shared.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Tab extends tea.Model with an InForm indicator used by the root app to
|
||||
// suppress tab-switch shortcuts while a form is open.
|
||||
type Tab interface {
|
||||
tea.Model
|
||||
InForm() bool
|
||||
}
|
||||
|
||||
var (
|
||||
titleSt = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00BFFF"))
|
||||
activeTS = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#39D353"))
|
||||
dimSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262"))
|
||||
errSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4444"))
|
||||
okSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#39D353"))
|
||||
cursorSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700"))
|
||||
labelSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#7EC8E3"))
|
||||
hdrSt = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#E94560"))
|
||||
)
|
||||
|
||||
const (
|
||||
helpList = "a:add e:edit d:del j/k:nav r:reload q:quit"
|
||||
helpForm = "tab/shift+tab:nav ←/→:cycle ctrl+s:save esc:cancel"
|
||||
)
|
||||
|
||||
// prefill creates a pre-filled text field (value shown, not just placeholder).
|
||||
func prefill(label, value string) field {
|
||||
f := newTextField(label, "")
|
||||
f.input.SetValue(value)
|
||||
return f
|
||||
}
|
||||
|
||||
// setEnumSel sets the sel index of an enum field to match value.
|
||||
func setEnumSel(f *field, value string) {
|
||||
for i, opt := range f.options {
|
||||
if opt == value {
|
||||
f.sel = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// field is a form entry: either a text input or an enum cycle selector.
|
||||
type field struct {
|
||||
label string
|
||||
isEnum bool
|
||||
input textinput.Model
|
||||
options []string
|
||||
sel int
|
||||
hidden bool
|
||||
}
|
||||
|
||||
func newTextField(label, placeholder string) field {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = placeholder
|
||||
ti.CharLimit = 256
|
||||
return field{label: label, input: ti}
|
||||
}
|
||||
|
||||
func newPasswordField(label string) field {
|
||||
ti := textinput.New()
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
ti.Placeholder = "password"
|
||||
ti.CharLimit = 128
|
||||
return field{label: label, input: ti}
|
||||
}
|
||||
|
||||
func newEnumField(label string, options []string) field {
|
||||
return field{label: label, isEnum: true, options: options}
|
||||
}
|
||||
|
||||
func (f *field) value() string {
|
||||
if f.isEnum {
|
||||
return f.options[f.sel]
|
||||
}
|
||||
return f.input.Value()
|
||||
}
|
||||
|
||||
func (f *field) cycleNext() {
|
||||
if f.isEnum {
|
||||
f.sel = (f.sel + 1) % len(f.options)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *field) cyclePrev() {
|
||||
if f.isEnum {
|
||||
f.sel = (f.sel - 1 + len(f.options)) % len(f.options)
|
||||
}
|
||||
}
|
||||
|
||||
// applyFocus focuses the field at idx and blurs all others.
|
||||
func applyFocus(fields []field, idx int) {
|
||||
for i := range fields {
|
||||
if !fields[i].isEnum {
|
||||
fields[i].input.Blur()
|
||||
}
|
||||
}
|
||||
if idx >= 0 && idx < len(fields) && !fields[idx].isEnum {
|
||||
fields[idx].input.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
// nextVisible returns the index of the next non-hidden field after from.
|
||||
func nextVisible(fields []field, from int) int {
|
||||
n := len(fields)
|
||||
for i := 1; i < n; i++ {
|
||||
idx := (from + i) % n
|
||||
if !fields[idx].hidden {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return from
|
||||
}
|
||||
|
||||
// prevVisible returns the index of the previous non-hidden field before from.
|
||||
func prevVisible(fields []field, from int) int {
|
||||
n := len(fields)
|
||||
for i := 1; i < n; i++ {
|
||||
idx := (from - i + n) % n
|
||||
if !fields[idx].hidden {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return from
|
||||
}
|
||||
|
||||
// handleFormKey routes a key message to the form: navigates fields or updates
|
||||
// the focused one. Returns (newFocusIdx, cmd).
|
||||
func handleFormKey(fields []field, focusIdx int, msg tea.KeyMsg) (int, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "tab", "down":
|
||||
next := nextVisible(fields, focusIdx)
|
||||
applyFocus(fields, next)
|
||||
return next, nil
|
||||
case "shift+tab", "up":
|
||||
prev := prevVisible(fields, focusIdx)
|
||||
applyFocus(fields, prev)
|
||||
return prev, nil
|
||||
}
|
||||
// Route to focused field
|
||||
f := &fields[focusIdx]
|
||||
if f.isEnum {
|
||||
switch msg.String() {
|
||||
case "left", "h":
|
||||
f.cyclePrev()
|
||||
case "right", "l", " ":
|
||||
f.cycleNext()
|
||||
}
|
||||
return focusIdx, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
f.input, cmd = f.input.Update(msg)
|
||||
return focusIdx, cmd
|
||||
}
|
||||
|
||||
// renderForm renders all non-hidden form fields.
|
||||
func renderForm(fields []field, focusIdx int) string {
|
||||
var sb strings.Builder
|
||||
for i := range fields {
|
||||
f := &fields[i]
|
||||
if f.hidden {
|
||||
continue
|
||||
}
|
||||
prefix := " "
|
||||
if i == focusIdx {
|
||||
prefix = cursorSt.Render("▸ ")
|
||||
}
|
||||
lbl := labelSt.Render(f.label + ":")
|
||||
if f.isEnum {
|
||||
var parts []string
|
||||
for j, opt := range f.options {
|
||||
if j == f.sel {
|
||||
parts = append(parts, activeTS.Render("["+opt+"]"))
|
||||
} else {
|
||||
parts = append(parts, dimSt.Render(" "+opt+" "))
|
||||
}
|
||||
}
|
||||
sb.WriteString(prefix + lbl + " " + strings.Join(parts, "") + "\n")
|
||||
} else {
|
||||
sb.WriteString(prefix + lbl + " " + f.input.View() + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// renderStatus renders the status line (ok or error style).
|
||||
func renderStatus(msg string, isErr bool) string {
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
if isErr {
|
||||
return errSt.Render("✗ " + msg)
|
||||
}
|
||||
return okSt.Render("✓ " + msg)
|
||||
}
|
||||
238
internal/adminui/users.go
Normal file
238
internal/adminui/users.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type userLoadedMsg []db.AdminUser
|
||||
type userDoneMsg struct{ err error }
|
||||
|
||||
// ── Field indices ─────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
ufUsername = 0
|
||||
ufPassword = 1
|
||||
ufAirportName = 2
|
||||
ufCountryID = 3
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type usersTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminUser
|
||||
cursor int
|
||||
inForm bool
|
||||
isEdit bool
|
||||
editUsername string
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newUsersTab(client *db.AdminClient) *usersTab {
|
||||
return &usersTab{client: client}
|
||||
}
|
||||
|
||||
func (t *usersTab) InForm() bool { return t.inForm }
|
||||
func (t *usersTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *usersTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListUsers()
|
||||
if err != nil {
|
||||
return userDoneMsg{err}
|
||||
}
|
||||
return userLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *usersTab) openForm() {
|
||||
t.fields = []field{
|
||||
newTextField("Username", "e.g. alice"),
|
||||
newPasswordField("Password"),
|
||||
newTextField("Airport Name", "e.g. Alice's Airport"),
|
||||
newTextField("Country ID", "1"),
|
||||
}
|
||||
t.fields[ufUsername].input.Focus()
|
||||
t.focusIdx = ufUsername
|
||||
t.isEdit = false
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *usersTab) openEditForm(u db.AdminUser) {
|
||||
f := newPasswordField("New Password")
|
||||
f.input.Focus()
|
||||
t.fields = []field{f}
|
||||
t.focusIdx = 0
|
||||
t.isEdit = true
|
||||
t.editUsername = u.Username
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *usersTab) submit() tea.Cmd {
|
||||
if t.isEdit {
|
||||
password := t.fields[0].value()
|
||||
if len(password) < 4 {
|
||||
t.status = "password must be at least 4 characters"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
username := t.editUsername
|
||||
return func() tea.Msg {
|
||||
return userDoneMsg{t.client.UpdateUserPassword(username, password)}
|
||||
}
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(t.fields[ufUsername].value())
|
||||
password := t.fields[ufPassword].value()
|
||||
airportName := strings.TrimSpace(t.fields[ufAirportName].value())
|
||||
countryIDStr := strings.TrimSpace(t.fields[ufCountryID].value())
|
||||
|
||||
if username == "" {
|
||||
t.status = "username is required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
if len(password) < 4 {
|
||||
t.status = "password must be at least 4 characters"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
if airportName == "" {
|
||||
t.status = "airport name is required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
countryID, err := strconv.Atoi(countryIDStr)
|
||||
if err != nil || countryID <= 0 {
|
||||
t.status = "country ID must be a positive number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
return func() tea.Msg {
|
||||
return userDoneMsg{t.client.CreateUserWithAirport(username, password, airportName, countryID)}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *usersTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case userLoadedMsg:
|
||||
t.items = []db.AdminUser(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case userDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.inForm = false
|
||||
t.status = "saved"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s":
|
||||
return t, t.submit()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "a":
|
||||
t.openForm()
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "d":
|
||||
if len(t.items) > 0 {
|
||||
username := t.items[t.cursor].Username
|
||||
return t, func() tea.Msg {
|
||||
return userDoneMsg{t.client.DeleteUser(username)}
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *usersTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Users") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
formTitle := "Add User + Airport"
|
||||
if t.isEdit {
|
||||
formTitle = fmt.Sprintf("Change Password — %s", t.editUsername)
|
||||
}
|
||||
sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-5s %-20s %-20s %s", "ID", "Username", "Created", "Status")
|
||||
sb.WriteString(dimSt.Render(header) + "\n")
|
||||
sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 60)) + "\n")
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no users — press a to add)") + "\n")
|
||||
}
|
||||
for i, u := range t.items {
|
||||
status := okSt.Render("active")
|
||||
if u.Deleted {
|
||||
status = errSt.Render("deleted")
|
||||
}
|
||||
created := u.CreatedAt.Format("2006-01-02")
|
||||
line := fmt.Sprintf(" %-5d %-20s %-20s ", u.ID, u.Username, created)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + status + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + status + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render("a:add e:edit(pw) d:soft-delete j/k:nav r:reload q:quit"))
|
||||
return sb.String()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue