From 5a425b8c6083989aa66fc8b92a19c64dce944ab1 Mon Sep 17 00:00:00 2001 From: juancwu Date: Mon, 26 Jan 2026 21:23:33 +0000 Subject: [PATCH] fix: bad ux --- cmd/tui/main.go | 2 +- internal/config/config.go | 2 +- internal/porkbun/client.go | 61 ++++ internal/porkbun/models.go | 106 +++++-- internal/ui/defaults/input.go | 3 + internal/ui/messages/messages.go | 10 +- internal/ui/model.go | 55 +++- internal/ui/pages/dns/list.go | 365 +++++++++++++++++++++++ internal/ui/pages/dns/retrieve.go | 157 ---------- internal/ui/pages/domaindetails/model.go | 67 +++++ internal/ui/pages/domainmenu/model.go | 93 ++++++ internal/ui/pages/listdomains/model.go | 119 ++++---- internal/ui/pages/login/model.go | 9 +- internal/ui/pages/menu/model.go | 182 ----------- internal/ui/pages/menu/recorditem.go | 23 -- 15 files changed, 791 insertions(+), 463 deletions(-) create mode 100644 internal/ui/defaults/input.go create mode 100644 internal/ui/pages/dns/list.go delete mode 100644 internal/ui/pages/dns/retrieve.go create mode 100644 internal/ui/pages/domaindetails/model.go create mode 100644 internal/ui/pages/domainmenu/model.go delete mode 100644 internal/ui/pages/menu/model.go delete mode 100644 internal/ui/pages/menu/recorditem.go diff --git a/cmd/tui/main.go b/cmd/tui/main.go index 6460c96..01d5899 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -16,4 +16,4 @@ func main() { fmt.Println("Error:", err) os.Exit(1) } -} \ No newline at end of file +} diff --git a/internal/config/config.go b/internal/config/config.go index dcdd3bf..6b16ae6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,7 +28,7 @@ func Load() (*Config, error) { } filename := filepath.Join(configDir, "config.json") - + // If file doesn't exist, return empty config with filename set if _, err := os.Stat(filename); os.IsNotExist(err) { return &Config{filename: filename}, nil diff --git a/internal/porkbun/client.go b/internal/porkbun/client.go index e421414..6461b52 100644 --- a/internal/porkbun/client.go +++ b/internal/porkbun/client.go @@ -65,6 +65,67 @@ func (c *Client) RetrieveDNSRecords(domain string) (*DNSRecordsResponse, error) return &resp, nil } +func (c *Client) CreateDNSRecord(domain string, record DNSRecord) error { + reqBody := struct { + BaseRequest + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL string `json:"ttl,omitempty"` + Priority string `json:"prio,omitempty"` + }{ + BaseRequest: BaseRequest{ + APIKey: c.APIKey, + SecretAPIKey: c.SecretAPIKey, + }, + Name: record.Name, + Type: record.Type, + Content: record.Content, + TTL: fmt.Sprintf("%v", record.TTL), + Priority: fmt.Sprintf("%v", record.Priority), + } + + var resp BaseResponse + return c.post("/dns/create/"+domain, reqBody, &resp) +} + +func (c *Client) EditDNSRecord(domain string, id string, record DNSRecord) error { + reqBody := struct { + BaseRequest + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL string `json:"ttl,omitempty"` + Priority string `json:"prio,omitempty"` + }{ + BaseRequest: BaseRequest{ + APIKey: c.APIKey, + SecretAPIKey: c.SecretAPIKey, + }, + Name: record.Name, + Type: record.Type, + Content: record.Content, + TTL: fmt.Sprintf("%v", record.TTL), + Priority: fmt.Sprintf("%v", record.Priority), + } + + var resp BaseResponse + return c.post("/dns/edit/"+domain+"/"+id, reqBody, &resp) +} + +func (c *Client) DeleteDNSRecord(domain, id string) error { + reqBody := struct { + BaseRequest + }{ + BaseRequest: BaseRequest{ + APIKey: c.APIKey, + SecretAPIKey: c.SecretAPIKey, + }, + } + var resp BaseResponse + return c.post("/dns/delete/"+domain+"/"+id, reqBody, &resp) +} + func (c *Client) Ping() (*PingResponse, error) { reqBody := PingRequest{ BaseRequest: BaseRequest{ diff --git a/internal/porkbun/models.go b/internal/porkbun/models.go index cc6d36d..d16c6d8 100644 --- a/internal/porkbun/models.go +++ b/internal/porkbun/models.go @@ -1,5 +1,77 @@ package porkbun +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// PorkbunBadEngineeringAPIResponseType represents the great undecisiveness +// from the api endpoint in choosing one consistent data type for certain fields. +// Ranging from null, "1" and 0. Why not just use -1, 1, and 0!? or even just null, 1 and 0... +type PorkbunBadEngineeringAPIResponseType int + +func (t *PorkbunBadEngineeringAPIResponseType) UnmarshalJSON(b []byte) error { + if bytes.Equal(b, []byte("null")) || len(b) == 0 { + *t = 0 + return nil + } + + s := string(b) + s = strings.Trim(s, "\"") + if s == "" { + *t = 0 + return nil + } + + i, err := strconv.ParseInt(s, 10, 32) + if err != nil { + return fmt.Errorf("cannot parse porkbun bad engeineering api response type: %w", err) + } + + *t = PorkbunBadEngineeringAPIResponseType(i) + + return nil +} + +type NullString struct { + Valid bool + Value string +} + +func (ns *NullString) UnmarshalJSON(b []byte) error { + if bytes.Equal(b, []byte("null")) || len(b) == 0 { + ns.Valid = false + ns.Value = "" + return nil + } + + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + + ns.Valid = true + ns.Value = s + return nil +} + +func (ns NullString) MarshalJSON() ([]byte, error) { + if !ns.Valid { + return []byte("null"), nil + } + return json.Marshal(ns.Value) +} + +func (ns NullString) String() string { + if ns.Valid { + return "" + } + return ns.Value +} + // BaseRequest contains the authentication fields required for most Porkbun API calls. type BaseRequest struct { APIKey string `json:"apikey"` @@ -38,16 +110,16 @@ type DomainListAllResponse struct { // Domain represents a single domain returned by domain endpoints. type Domain struct { - Domain string `json:"domain"` - Status string `json:"status"` - TLD string `json:"tld"` - CreateDate string `json:"createDate"` - ExpireDate string `json:"expireDate"` - SecurityLock string `json:"securityLock"` - WhoIsPrivacy string `json:"whoisPrivacy"` - AutoRenew any `json:"autoRenew"` - NotLocal any `json:"notLocal"` - Labels []DomainLabel `json:"labels,omitempty"` + Domain string `json:"domain"` + Status string `json:"status"` + TLD string `json:"tld"` + CreateDate string `json:"createDate"` + ExpireDate string `json:"expireDate"` + SecurityLock PorkbunBadEngineeringAPIResponseType `json:"securityLock"` + WhoIsPrivacy PorkbunBadEngineeringAPIResponseType `json:"whoisPrivacy"` + AutoRenew PorkbunBadEngineeringAPIResponseType `json:"autoRenew"` + NotLocal PorkbunBadEngineeringAPIResponseType `json:"notLocal"` + Labels []DomainLabel `json:"labels,omitempty"` } // DomainLabel represents a label associated with a domain. @@ -64,13 +136,13 @@ type DNSRecordsRequest struct { // DNSRecord it is what it says it is. type DNSRecord struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Content string `json:"content"` - TTL any `json:"ttl"` - Priority any `json:"prio"` - Notes string `json:"notes"` + ID string `json:"id"` + Type string `json:"type"` + Content string `json:"content"` + Name string `json:"name,omitempty"` + TTL NullString `json:"ttl,omitempty"` + Priority NullString `json:"prio,omitempty"` + Notes NullString `json:"notes,omitempty"` } // DNSRecordsResponse it is what it says it is. diff --git a/internal/ui/defaults/input.go b/internal/ui/defaults/input.go new file mode 100644 index 0000000..6425dce --- /dev/null +++ b/internal/ui/defaults/input.go @@ -0,0 +1,3 @@ +package defaults + +const INPUT_WIDTH = 80 diff --git a/internal/ui/messages/messages.go b/internal/ui/messages/messages.go index 2e93168..f0d6327 100644 --- a/internal/ui/messages/messages.go +++ b/internal/ui/messages/messages.go @@ -8,7 +8,9 @@ const ( PageLogin Page = iota PageMenu PageListDomains - PageDNSRetrieve + PageDomainDetails + PageDomainMenu + PageDNSList ) type SwitchPageMsg struct { @@ -21,8 +23,14 @@ type SessionReadyMsg struct { type ListDomainsMsg struct{} +type DomainSelectedMsg struct { + Domain *porkbun.Domain +} + type DNSRetrieveMsg struct { Domain string } +type RefreshMsg struct{} + type ErrorMsg error diff --git a/internal/ui/model.go b/internal/ui/model.go index 8032aa8..791453e 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -7,16 +7,16 @@ import ( "git.juancwu.dev/juancwu/porkbacon/internal/config" "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" "git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/dns" + "git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/domaindetails" + "git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/domainmenu" "git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/listdomains" "git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/login" - "git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/menu" tea "github.com/charmbracelet/bubbletea" ) type MainModel struct { currentPage messages.Page pages map[messages.Page]tea.Model - isMenuInit bool width int height int } @@ -53,27 +53,54 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + for k, p := range m.pages { + m.pages[k], _ = p.Update(msg) + } + case messages.SwitchPageMsg: m.currentPage = msg.Page return m, nil + case messages.SessionReadyMsg: - m.pages[messages.PageMenu] = menu.New(msg.Client) m.pages[messages.PageListDomains] = listdomains.New(msg.Client) - m.pages[messages.PageDNSRetrieve] = dns.NewRetrieveModel(msg.Client) + m.pages[messages.PageDomainMenu] = domainmenu.New() + m.pages[messages.PageDomainDetails] = domaindetails.New() + m.pages[messages.PageDNSList] = dns.NewListModel(msg.Client) + if m.width > 0 && m.height > 0 { - m.pages[messages.PageMenu], _ = m.pages[messages.PageMenu].Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + for k, p := range m.pages { + m.pages[k], _ = p.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + } } - m.currentPage = messages.PageMenu - m.isMenuInit = true - var cmds []tea.Cmd - for _, page := range m.pages { - cmds = append(cmds, page.Init()) + + // Initial flow: Go to ListDomains and trigger load + m.currentPage = messages.PageListDomains + return m, func() tea.Msg { return messages.ListDomainsMsg{} } + + case messages.DomainSelectedMsg: + // Broadcast to DomainMenu and DomainDetails and DNSList + // Then switch to DomainMenu + // We need to update those models with the message + for _, pageKey := range []messages.Page{messages.PageDomainMenu, messages.PageDomainDetails, messages.PageDNSList} { + if p, ok := m.pages[pageKey]; ok { + var c tea.Cmd + m.pages[pageKey], c = p.Update(msg) + if c != nil { + cmds = append(cmds, c) + } + } + } + m.currentPage = messages.PageDomainMenu + return m, tea.Batch(cmds...) + + case messages.RefreshMsg: + page, ok := m.pages[m.currentPage] + if ok { + var c tea.Cmd + m.pages[m.currentPage], c = page.Update(msg) + cmds = append(cmds, c) } return m, tea.Batch(cmds...) - case messages.ListDomainsMsg: - m.currentPage = messages.PageListDomains - case messages.DNSRetrieveMsg: - m.currentPage = messages.PageDNSRetrieve } page, ok := m.pages[m.currentPage] diff --git a/internal/ui/pages/dns/list.go b/internal/ui/pages/dns/list.go new file mode 100644 index 0000000..5535652 --- /dev/null +++ b/internal/ui/pages/dns/list.go @@ -0,0 +1,365 @@ +package dns + +import ( + "fmt" + "strings" + + "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" + "git.juancwu.dev/juancwu/porkbacon/internal/ui/defaults" + "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type State int + +const ( + StateList State = iota + StateForm + StateConfirmDelete +) + +type Mode int + +const ( + ModeCreate Mode = iota + ModeEdit +) + +type item struct { + record porkbun.DNSRecord +} + +func (i item) Title() string { + return fmt.Sprintf("%s %s", i.record.Type, i.record.Name) +} + +func (i item) Description() string { + return i.record.Content +} + +func (i item) FilterValue() string { + return i.record.Name + " " + i.record.Type + " " + i.record.Content +} + +type Model struct { + state State + mode Mode + list list.Model + spinner spinner.Model + inputs []textinput.Model + focusIndex int + client *porkbun.Client + domain string + selectedRecord *porkbun.DNSRecord + loading bool + err error + statusMsg string +} + +func NewListModel(client *porkbun.Client) Model { + l := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + l.Title = "DNS Records" + l.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add record")), + key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit record")), + key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete record")), + key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + } + } + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), + key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), + key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "del")), + } + } + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + inputs := make([]textinput.Model, 5) + for i := range inputs { + inputs[i] = textinput.New() + inputs[i].Width = defaults.INPUT_WIDTH + } + inputs[0].Placeholder = "Type (required)" + inputs[1].Placeholder = "Name (leave empty for root)" + inputs[2].Placeholder = "Content (required)" + inputs[3].Placeholder = "TTL (optional)" + inputs[4].Placeholder = "Priority (optional)" + + return Model{ + state: StateList, + list: l, + spinner: s, + inputs: inputs, + client: client, + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case messages.DomainSelectedMsg: + m.domain = msg.Domain.Domain + m.list.Title = fmt.Sprintf("DNS Records for %s", m.domain) + m.list.SetItems(nil) + m.state = StateList + + case messages.SwitchPageMsg: + if msg.Page == messages.PageDNSList { + if len(m.list.Items()) == 0 { + return m.refresh() + } + } + + case messages.RefreshMsg: + if m.domain != "" { + return m.refresh() + } + + case *porkbun.DNSRecordsResponse: + m.loading = false + items := []list.Item{} + for _, r := range msg.Records { + items = append(items, item{record: r}) + } + m.list.SetItems(items) + m.statusMsg = "Records loaded." + + case messages.ErrorMsg: + m.loading = false + m.err = msg + m.statusMsg = "Error occurred." + + case tea.KeyMsg: + // Global keys for this model + if m.state == StateList && !m.loading { + switch msg.String() { + case "esc": + return m, func() tea.Msg { + return messages.SwitchPageMsg{Page: messages.PageDomainMenu} + } + case "r": + return m.refresh() + case "a": + m.mode = ModeCreate + m.state = StateForm + m.resetInputs() + m.FocusAt(0) + return m, nil + case "e": + if i, ok := m.list.SelectedItem().(item); ok { + m.mode = ModeEdit + m.selectedRecord = &i.record + m.state = StateForm + m.populateInputs(i.record) + m.FocusAt(0) + return m, nil + } + case "d": + if i, ok := m.list.SelectedItem().(item); ok { + m.selectedRecord = &i.record + m.state = StateConfirmDelete + return m, nil + } + } + } else if m.state == StateConfirmDelete { + switch msg.String() { + case "y", "Y", "enter": + return m.deleteRecord() + case "n", "N", "esc": + m.state = StateList + m.statusMsg = "Deletion cancelled." + return m, nil + } + } else if m.state == StateForm { + switch msg.String() { + case "esc": + m.state = StateList + m.statusMsg = "Cancelled." + return m, nil + case "tab", "shift+tab", "enter", "up", "down": + s := msg.String() + if s == "enter" { + if m.focusIndex == len(m.inputs)-1 { + return m.submitForm() + } + m.focusIndex++ + } else if s == "up" || s == "shift+tab" { + m.focusIndex-- + } else if s == "down" || s == "tab" { + m.focusIndex++ + } + + if m.focusIndex > len(m.inputs)-1 { + m.focusIndex = 0 + } else if m.focusIndex < 0 { + m.focusIndex = len(m.inputs) - 1 + } + + cmds := make([]tea.Cmd, len(m.inputs)) + for i := 0; i <= len(m.inputs)-1; i++ { + if i == m.focusIndex { + cmds[i] = m.inputs[i].Focus() + continue + } + m.inputs[i].Blur() + } + return m, tea.Batch(cmds...) + } + } + + case tea.WindowSizeMsg: + m.list.SetWidth(msg.Width) + m.list.SetHeight(msg.Height) + } + + if m.loading { + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + } + + if m.state == StateList { + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + } else if m.state == StateForm { + for i := range m.inputs { + m.inputs[i], cmd = m.inputs[i].Update(msg) + cmds = append(cmds, cmd) + } + } + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + if m.loading { + return fmt.Sprintf("\n\n %s Processing...", m.spinner.View()) + } + + if m.err != nil { + return fmt.Sprintf("Error: %v\n\n(Press r to refresh, esc to go back)", m.err) + } + + if m.state == StateConfirmDelete { + return fmt.Sprintf( + "Are you sure you want to delete this record?\n\n%s %s\n\n(y/n)", + m.selectedRecord.Type, m.selectedRecord.Name, + ) + } + + if m.state == StateForm { + var b strings.Builder + title := "Create Record" + if m.mode == ModeEdit { + title = "Edit Record" + } + b.WriteString(title + "\n\n") + + labels := []string{"Type", "Name", "Content", "TTL", "Priority"} + + for i := range m.inputs { + b.WriteString(fmt.Sprintf("% -10s %s\n", labels[i], m.inputs[i].View())) + } + + b.WriteString("\n(Press Enter to next/submit, Esc to cancel)\n") + return b.String() + } + + return m.list.View() +} + +func (m *Model) refresh() (tea.Model, tea.Cmd) { + m.loading = true + m.err = nil + return m, tea.Batch( + func() tea.Msg { + resp, err := m.client.RetrieveDNSRecords(m.domain) + if err != nil { + return messages.ErrorMsg(err) + } + return resp + }, + m.spinner.Tick, + ) +} + +func (m *Model) deleteRecord() (tea.Model, tea.Cmd) { + m.loading = true + id := m.selectedRecord.ID + return m, tea.Batch( + func() tea.Msg { + err := m.client.DeleteDNSRecord(m.domain, id) + if err != nil { + return messages.ErrorMsg(err) + } + return messages.RefreshMsg{} + }, + m.spinner.Tick, + ) +} + +func (m *Model) submitForm() (tea.Model, tea.Cmd) { + m.loading = true + + record := porkbun.DNSRecord{ + Type: m.inputs[0].Value(), + Name: m.inputs[1].Value(), + Content: m.inputs[2].Value(), + TTL: porkbun.NullString{Value: m.inputs[3].Value(), Valid: true}, + Priority: porkbun.NullString{Value: m.inputs[4].Value(), Valid: true}, + } + + return m, tea.Batch( + func() tea.Msg { + var err error + if m.mode == ModeCreate { + err = m.client.CreateDNSRecord(m.domain, record) + } else { + err = m.client.EditDNSRecord(m.domain, m.selectedRecord.ID, record) + } + + if err != nil { + return messages.ErrorMsg(err) + } + return messages.RefreshMsg{} + }, + m.spinner.Tick, + ) +} + +func (m *Model) resetInputs() { + for i := range m.inputs { + m.inputs[i].SetValue("") + } +} + +func (m *Model) populateInputs(r porkbun.DNSRecord) { + m.inputs[0].SetValue(r.Type) + m.inputs[1].SetValue(r.Name) + m.inputs[2].SetValue(r.Content) + m.inputs[3].SetValue(r.TTL.String()) + m.inputs[4].SetValue(r.Priority.String()) +} + +func (m *Model) FocusAt(index int) { + for i := range m.inputs { + m.inputs[i].Blur() + } + m.inputs[index].Focus() + m.focusIndex = index +} diff --git a/internal/ui/pages/dns/retrieve.go b/internal/ui/pages/dns/retrieve.go deleted file mode 100644 index 7f61d01..0000000 --- a/internal/ui/pages/dns/retrieve.go +++ /dev/null @@ -1,157 +0,0 @@ -package dns - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/paginator" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" - "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" - "git.juancwu.dev/juancwu/porkbacon/internal/ui/utils" -) - -type RetrieveModel struct { - client *porkbun.Client - loading bool - records []string - - spinner spinner.Model - paginator paginator.Model - textinput textinput.Model - - stderr string -} - -func NewRetrieveModel(client *porkbun.Client) RetrieveModel { - p := paginator.New() - p.Type = paginator.Dots - p.PerPage = 1 - p.ActiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}).Render("•") - p.InactiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "238"}).Render("•") - - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - - ti := textinput.New() - ti.Placeholder = "Enter domain" - ti.Width = 80 - - return RetrieveModel{ - client: client, - spinner: s, - paginator: p, - textinput: ti, - } -} - -func (m RetrieveModel) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, textinput.Blink) -} - -func (m RetrieveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - if m.loading { - return m, nil - } - - if msg.String() == "esc" { - hadRecords := len(m.records) > 0 - m.loading = false - m.records = nil - return m, func() tea.Msg { - if hadRecords { - return messages.DNSRetrieveMsg{} - } - - return messages.SwitchPageMsg{Page: messages.PageMenu} - } - } - - if msg.String() == "enter" { - m.loading = true - m.records = nil - return m, tea.Batch(retrieveRecords(m.client, m.textinput.Value()), m.spinner.Tick) - } - - if len(m.records) > 0 { - m.paginator, cmd = m.paginator.Update(msg) - return m, cmd - } - - m.textinput, cmd = m.textinput.Update(msg) - return m, cmd - - case messages.DNSRetrieveMsg: - m.textinput.Reset() - m.textinput.Focus() - - case *porkbun.DNSRecordsResponse: - m.loading = false - for _, record := range msg.Records { - m.records = append(m.records, renderRecord(&record)) - } - m.paginator.SetTotalPages(len(m.records)) - m.paginator.Page = 0 - case messages.ErrorMsg: - m.stderr = fmt.Sprintf("Error: %v", msg) - } - - if m.loading { - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - - return m, textinput.Blink -} - -func (m RetrieveModel) View() string { - if m.stderr != "" { - return fmt.Sprintf("%s\n\n(Press ctrl+c to quit)", m.stderr) - } - - if m.loading { - return fmt.Sprintf("\n\n %s Loading... press ctl+c to quit\n\n", m.spinner.View()) - } - - if len(m.records) > 0 { - return fmt.Sprintf("%s\n\n%s\n\n(Press Esc to go back, arrows to navigate)", m.records[m.paginator.Page], m.paginator.View()) - } - - return fmt.Sprintf( - "Enter domain to retrieve records for:\n\n%s\n\n(esc to quit)", - m.textinput.View(), - ) -} - -func retrieveRecords(client *porkbun.Client, domain string) tea.Cmd { - return func() tea.Msg { - resp, err := client.RetrieveDNSRecords(domain) - if err != nil { - return messages.ErrorMsg(err) - } - return resp - } -} - -func renderRecord(item *porkbun.DNSRecord) string { - var b strings.Builder - b.WriteString("ID: " + item.ID + "\n") - b.WriteString("Name: " + item.Name + "\n") - b.WriteString("Type: " + item.Type + "\n") - b.WriteString(fmt.Sprintln("TTL:", item.TTL)) - b.WriteString(fmt.Sprintln("Priority:", item.Priority)) - b.WriteString("Content: ") - b.WriteString(utils.WrapText(item.Content, 80)) - b.WriteString("\n") - b.WriteString("Notes: " + item.Notes + "\n") - return b.String() -} diff --git a/internal/ui/pages/domaindetails/model.go b/internal/ui/pages/domaindetails/model.go new file mode 100644 index 0000000..7dcfa29 --- /dev/null +++ b/internal/ui/pages/domaindetails/model.go @@ -0,0 +1,67 @@ +package domaindetails + +import ( + "fmt" + "strings" + + "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" + "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" + tea "github.com/charmbracelet/bubbletea" +) + +type Model struct { + domain *porkbun.Domain +} + +func New() Model { + return Model{} +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case messages.DomainSelectedMsg: + m.domain = msg.Domain + case tea.KeyMsg: + if msg.String() == "esc" { + return m, func() tea.Msg { + return messages.SwitchPageMsg{Page: messages.PageDomainMenu} + } + } + } + return m, nil +} + +func (m Model) View() string { + if m.domain == nil { + return "No domain selected" + } + return renderDomain(m.domain) +} + +func renderDomain(item *porkbun.Domain) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("Domain Details: %s\n\n", item.Domain)) + b.WriteString("Status: " + item.Status + "\n") + b.WriteString("Create Date: " + item.CreateDate + "\n") + b.WriteString("Expire Date: " + item.ExpireDate + "\n") + b.WriteString(fmt.Sprintln("Security Lock:", item.SecurityLock)) + b.WriteString(fmt.Sprintln("Whois Privacy:", item.WhoIsPrivacy)) + b.WriteString(fmt.Sprintln("Auto Renew:", item.AutoRenew)) + b.WriteString(fmt.Sprintln("Not Local:", item.NotLocal)) + if len(item.Labels) > 0 { + b.WriteString("Labels:\n") + } + for i, label := range item.Labels { + b.WriteString("=> " + label.Title) + if i < len(item.Labels)-1 { + b.WriteString("\n") + } + } + + b.WriteString("\n\n(Press Esc to go back)") + return b.String() +} diff --git a/internal/ui/pages/domainmenu/model.go b/internal/ui/pages/domainmenu/model.go new file mode 100644 index 0000000..0fd8429 --- /dev/null +++ b/internal/ui/pages/domainmenu/model.go @@ -0,0 +1,93 @@ +package domainmenu + +import ( + "fmt" + + "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" + "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +type menuItem struct { + title, desc string + id string +} + +func (i menuItem) Title() string { return i.title } +func (i menuItem) Description() string { return i.desc } +func (i menuItem) FilterValue() string { return i.title } + +const ( + actionDetails = "details" + actionDNS = "dns" +) + +type Model struct { + list list.Model + selectedDomain *porkbun.Domain +} + +func New() Model { + items := []list.Item{ + menuItem{title: "View Details", desc: "View domain registration details", id: actionDetails}, + menuItem{title: "View DNS Records", desc: "Manage DNS records", id: actionDNS}, + } + + l := list.New(items, list.NewDefaultDelegate(), 0, 0) + l.SetShowHelp(false) + l.Title = "Domain Actions" + + return Model{ + list: l, + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.list.SetWidth(msg.Width) + m.list.SetHeight(msg.Height) + + case messages.DomainSelectedMsg: + m.selectedDomain = msg.Domain + m.list.Title = fmt.Sprintf("Actions for %s", m.selectedDomain.Domain) + + case tea.KeyMsg: + switch msg.String() { + case "esc": + return m, func() tea.Msg { + return messages.SwitchPageMsg{Page: messages.PageListDomains} + } + case "enter": + i, ok := m.list.SelectedItem().(menuItem) + if ok { + switch i.id { + case actionDetails: + return m, func() tea.Msg { + return messages.SwitchPageMsg{Page: messages.PageDomainDetails} + } + case actionDNS: + return m, func() tea.Msg { + return messages.SwitchPageMsg{Page: messages.PageDNSList} + } + } + } + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m Model) View() string { + if m.selectedDomain == nil { + return "No domain selected" + } + return m.list.View() +} diff --git a/internal/ui/pages/listdomains/model.go b/internal/ui/pages/listdomains/model.go index cdb47e9..3efa508 100644 --- a/internal/ui/pages/listdomains/model.go +++ b/internal/ui/pages/listdomains/model.go @@ -2,42 +2,48 @@ package listdomains import ( "fmt" - "strings" + "sort" "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" - "github.com/charmbracelet/bubbles/paginator" + "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) +type item struct { + domain porkbun.Domain +} + +func (i item) Title() string { return i.domain.Domain } +func (i item) Description() string { + return fmt.Sprintf("Status: %s | Expires: %s", i.domain.Status, i.domain.ExpireDate) +} +func (i item) FilterValue() string { return i.domain.Domain } + type Model struct { - loading bool - client *porkbun.Client - domains []string - paginator paginator.Model - spinner spinner.Model - stderr string + loading bool + client *porkbun.Client + list list.Model + spinner spinner.Model + stderr string } func New(client *porkbun.Client) Model { - p := paginator.New() - p.Type = paginator.Dots - p.PerPage = 1 - p.ActiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}).Render("•") - p.InactiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "238"}).Render("•") + l := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + l.Title = "My Domains" + l.SetShowHelp(true) s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) return Model{ - loading: false, - client: client, - domains: nil, - paginator: p, - spinner: s, + loading: false, + client: client, + list: l, + spinner: s, } } @@ -47,46 +53,59 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + var cmds []tea.Cmd switch msg := msg.(type) { - case tea.KeyMsg: - if !m.loading && msg.String() == "esc" { - m.loading = false - m.domains = nil - return m, func() tea.Msg { - return messages.SwitchPageMsg{Page: messages.PageMenu} - } - } + case tea.WindowSizeMsg: + m.list.SetSize(msg.Width, msg.Height) - if len(m.domains) > 0 { - m.paginator, cmd = m.paginator.Update(msg) - return m, cmd + case tea.KeyMsg: + if m.loading { + break + } + if msg.String() == "enter" { + if i, ok := m.list.SelectedItem().(item); ok { + return m, func() tea.Msg { + return messages.DomainSelectedMsg{Domain: &i.domain} + } + } } case messages.ListDomainsMsg: m.loading = true - m.domains = nil - return m, tea.Batch(listDomains(m.client), m.spinner.Tick) + cmds = append(cmds, listDomains(m.client), m.spinner.Tick) + return m, tea.Batch(cmds...) case *porkbun.DomainListAllResponse: m.loading = false + var items []list.Item for _, domain := range msg.Domains { - m.domains = append(m.domains, renderDomain(&domain)) + items = append(items, item{domain: domain}) } - m.paginator.SetTotalPages(len(m.domains)) - m.paginator.Page = 0 + // Sort by domain name + sort.Slice(items, func(i, j int) bool { + return items[i].(item).domain.Domain < items[j].(item).domain.Domain + }) + + cmd = m.list.SetItems(items) + cmds = append(cmds, cmd) case messages.ErrorMsg: m.stderr = fmt.Sprintf("Error: %v", msg) + m.loading = false return m, nil } if m.loading { m.spinner, cmd = m.spinner.Update(msg) - return m, cmd + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) } - return m, nil + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) } func (m Model) View() string { @@ -98,11 +117,7 @@ func (m Model) View() string { return fmt.Sprintf("\n\n %s Loading... press ctl+c to quit\n\n", m.spinner.View()) } - if len(m.domains) > 0 { - return fmt.Sprintf("%s\n\n%s\n\n(Press Esc to go back, arrows to navigate)", m.domains[m.paginator.Page], m.paginator.View()) - } - - return "Uhh.. This is awkward... Press Esc to go back." + return m.list.View() } func listDomains(client *porkbun.Client) tea.Cmd { @@ -115,25 +130,3 @@ func listDomains(client *porkbun.Client) tea.Cmd { return resp } } - -func renderDomain(item *porkbun.Domain) string { - var b strings.Builder - b.WriteString("Domain: " + item.Domain + "\n") - b.WriteString("Status: " + item.Status + "\n") - b.WriteString("Create Date: " + item.CreateDate + "\n") - b.WriteString("Expire Date: " + item.ExpireDate + "\n") - b.WriteString("Security Lock: " + item.SecurityLock + "\n") - b.WriteString("Whois Privacy: " + item.WhoIsPrivacy + "\n") - b.WriteString(fmt.Sprintln("Auto Renew:", item.AutoRenew)) - b.WriteString(fmt.Sprintln("Not Local:", item.NotLocal)) - if len(item.Labels) > 0 { - b.WriteString("Labels:\n") - } - for i, label := range item.Labels { - b.WriteString("=> " + label.Title) - if i < len(item.Labels)-1 { - b.WriteString("\n") - } - } - return b.String() -} diff --git a/internal/ui/pages/login/model.go b/internal/ui/pages/login/model.go index 581eb4b..d6768ef 100644 --- a/internal/ui/pages/login/model.go +++ b/internal/ui/pages/login/model.go @@ -6,6 +6,7 @@ import ( "git.juancwu.dev/juancwu/porkbacon/internal/config" "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" + "git.juancwu.dev/juancwu/porkbacon/internal/ui/defaults" "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -37,7 +38,7 @@ func New(cfg *config.Config) Model { m.inputs[0] = textinput.New() m.inputs[0].Placeholder = "Master Password" m.inputs[0].EchoMode = textinput.EchoPassword - m.inputs[0].Width = 50 + m.inputs[0].Width = defaults.INPUT_WIDTH m.inputs[0].Focus() } else { m.mode = ModeSetup @@ -46,18 +47,18 @@ func New(cfg *config.Config) Model { m.inputs[0] = textinput.New() m.inputs[0].Placeholder = "API Key" m.inputs[0].EchoMode = textinput.EchoPassword - m.inputs[0].Width = 50 + m.inputs[0].Width = defaults.INPUT_WIDTH m.inputs[0].Focus() m.inputs[1] = textinput.New() m.inputs[1].Placeholder = "Secret API Key" m.inputs[1].EchoMode = textinput.EchoPassword - m.inputs[1].Width = 50 + m.inputs[1].Width = defaults.INPUT_WIDTH m.inputs[2] = textinput.New() m.inputs[2].Placeholder = "Master Password (to encrypt keys)" m.inputs[2].EchoMode = textinput.EchoPassword - m.inputs[2].Width = 50 + m.inputs[2].Width = defaults.INPUT_WIDTH } return m diff --git a/internal/ui/pages/menu/model.go b/internal/ui/pages/menu/model.go deleted file mode 100644 index 5c3feef..0000000 --- a/internal/ui/pages/menu/model.go +++ /dev/null @@ -1,182 +0,0 @@ -package menu - -import ( - "fmt" - - "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" - "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/paginator" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type pingResultMsg struct { - IP string -} - -type menuItem struct { - id uint8 - title, desc string -} - -func (i menuItem) ID() uint8 { return i.id } -func (i menuItem) Title() string { return i.title } -func (i menuItem) Description() string { return i.desc } -func (i menuItem) FilterValue() string { return i.title } - -const ( - domainListAll uint8 = iota - dnsRetrieveRecords - dnsCreateRecord - dnsEditRecord - dnsDeleteRecord - utilPing -) - -const ( - stateNoOp uint8 = iota - stateDNSRetrieveGetDomain -) - -type Model struct { - list list.Model - paginator paginator.Model - spinner spinner.Model - textInput textinput.Model - loading bool - state uint8 - client *porkbun.Client - err error - output string -} - -func New(client *porkbun.Client) Model { - items := []list.Item{ - menuItem{id: domainListAll, title: "Domain: List All", desc: "List all domains in your account"}, - menuItem{id: dnsRetrieveRecords, title: "DNS: Retrieve Records", desc: "Retrieve DNS records for a domain"}, - menuItem{id: dnsCreateRecord, title: "DNS: Create Record", desc: "Create a new DNS record"}, - menuItem{id: dnsEditRecord, title: "DNS: Edit Record", desc: "Edit an existing DNS record"}, - menuItem{id: dnsDeleteRecord, title: "DNS: Delete Record", desc: "Delete a DNS record"}, - menuItem{id: utilPing, title: "Util: Ping", desc: "Ping Porkbun"}, - } - - l := list.New(items, list.NewDefaultDelegate(), 0, 0) - l.Title = "Porkbun Actions" - - p := paginator.New() - p.Type = paginator.Dots - p.PerPage = 1 - p.ActiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}).Render("•") - p.InactiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "238"}).Render("•") - - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - - ti := textinput.New() - ti.Placeholder = "example.com" - ti.CharLimit = 156 - ti.Width = 20 - - return Model{ - list: l, - paginator: p, - spinner: s, - textInput: ti, - client: client, - state: 0, - } -} - -func (m Model) Init() tea.Cmd { - return tea.Sequence(m.spinner.Tick) -} - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.list.SetWidth(msg.Width) - m.list.SetHeight(msg.Height) - return m, nil - - case tea.KeyMsg: - if m.output != "" { - if msg.String() == "esc" { - m.output = "" - return m, nil - } - return m, nil - } - - if msg.String() == "enter" { - i, ok := m.list.SelectedItem().(menuItem) - if ok { - return m.handleSelection(i) - } - } - - case pingResultMsg: - m.loading = false - m.output = fmt.Sprintf("Ping successful!\nYour IP: %s", msg.IP) - return m, nil - - case messages.ErrorMsg: - m.output = fmt.Sprintf("Error: %v", msg) - return m, nil - } - - var cmd tea.Cmd - if m.loading { - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - - m.list, cmd = m.list.Update(msg) - return m, cmd -} - -func (m Model) View() string { - if m.loading { - return fmt.Sprintf("\n\n %s Loading... press ctl+c to quit\n\n", m.spinner.View()) - } - - if m.output != "" { - return fmt.Sprintf("%s\n\n(Press Esc to go back)", m.output) - } - - if m.state == stateDNSRetrieveGetDomain { - return fmt.Sprintf( - "Enter domain to retrieve records for:\n\n%s\n\n(esc to quit)", - m.textInput.View(), - ) - } - - return m.list.View() -} - -func (m *Model) handleSelection(i menuItem) (tea.Model, tea.Cmd) { - switch i.id { - case dnsRetrieveRecords: - return m, func() tea.Msg { - return messages.DNSRetrieveMsg{} - } - case domainListAll: - return m, func() tea.Msg { - return messages.ListDomainsMsg{} - } - case utilPing: - m.loading = true - cmd := func() tea.Msg { - resp, err := m.client.Ping() - if err != nil { - return messages.ErrorMsg(err) - } - return pingResultMsg{IP: resp.YourIP} - } - return m, tea.Batch(cmd, m.spinner.Tick) - } - return m, nil -} diff --git a/internal/ui/pages/menu/recorditem.go b/internal/ui/pages/menu/recorditem.go deleted file mode 100644 index f5754a8..0000000 --- a/internal/ui/pages/menu/recorditem.go +++ /dev/null @@ -1,23 +0,0 @@ -package menu - -import ( - "fmt" - "strings" - - "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" - "git.juancwu.dev/juancwu/porkbacon/internal/ui/utils" -) - -func renderRecordItem(item *porkbun.DNSRecord) string { - var b strings.Builder - b.WriteString("ID: " + item.ID + "\n") - b.WriteString("Name: " + item.Name + "\n") - b.WriteString("Type: " + item.Type + "\n") - b.WriteString(fmt.Sprintln("TTL:", item.TTL)) - b.WriteString(fmt.Sprintln("Priority:", item.Priority)) - b.WriteString("Content: ") - b.WriteString(utils.WrapText(item.Content, 80)) - b.WriteString("\n") - b.WriteString("Notes: " + item.Notes + "\n") - return b.String() -}