phase 0.3

This commit is contained in:
portakal 2026-06-04 20:51:20 +03:00
commit 4a94bfc4e7
52 changed files with 11653 additions and 0 deletions

View 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
View 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()
}

View 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()
}

View 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()
}

View 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
View 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()
}

View 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
View 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
View 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()
}