diff --git a/internal/porkbun/client.go b/internal/porkbun/client.go index 6091e90..e421414 100644 --- a/internal/porkbun/client.go +++ b/internal/porkbun/client.go @@ -48,6 +48,23 @@ func (c *Client) DomainListAll(start int, includeLabels bool) (*DomainListAllRes return &resp, nil } +func (c *Client) RetrieveDNSRecords(domain string) (*DNSRecordsResponse, error) { + reqBody := DNSRecordsRequest{ + BaseRequest: BaseRequest{ + APIKey: c.APIKey, + SecretAPIKey: c.SecretAPIKey, + }, + } + + var resp DNSRecordsResponse + err := c.post("/dns/retrieve/"+domain, reqBody, &resp) + if err != nil { + return nil, err + } + + return &resp, nil +} + func (c *Client) Ping() (*PingResponse, error) { reqBody := PingRequest{ BaseRequest: BaseRequest{ diff --git a/internal/porkbun/models.go b/internal/porkbun/models.go index 8e8082c..cc6d36d 100644 --- a/internal/porkbun/models.go +++ b/internal/porkbun/models.go @@ -56,3 +56,25 @@ type DomainLabel struct { Title string `json:"title"` Color string `json:"color"` } + +// DNSRecordsRequest is used to get a list of DNS records +type DNSRecordsRequest struct { + BaseRequest +} + +// 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"` +} + +// DNSRecordsResponse it is what it says it is. +type DNSRecordsResponse struct { + BaseResponse + Records []DNSRecord `json:"records"` +} diff --git a/internal/ui/pages/menu/model.go b/internal/ui/pages/menu/model.go index 3801f60..89272a1 100644 --- a/internal/ui/pages/menu/model.go +++ b/internal/ui/pages/menu/model.go @@ -7,6 +7,8 @@ import ( "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" ) @@ -20,6 +22,11 @@ type domainListMsg struct { Domains []string } +type dnsRecordListMsg struct { + Status string + Records []string +} + type menuItem struct { id uint8 title, desc string @@ -39,10 +46,20 @@ const ( 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 domains []string + records []string client *porkbun.Client err error output string @@ -67,15 +84,27 @@ func New(client *porkbun.Client) *Model { 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 nil + return tea.Sequence(m.spinner.Tick) } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -86,6 +115,42 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + if m.loading && msg.String() == "ctrl+c" { + return m, tea.Quit + } + + if m.state == stateDNSRetrieveGetDomain { + switch msg.String() { + case "enter": + domain := m.textInput.Value() + m.textInput.SetValue("") + m.state = stateNoOp + m.loading = true + cmd := func() tea.Msg { + resp, err := m.client.RetrieveDNSRecords(domain) + if err != nil { + return messages.ErrorMsg(err) + } + var records []string + for _, record := range resp.Records { + records = append(records, renderRecordItem(&record)) + } + return dnsRecordListMsg{ + Status: resp.Status, + Records: records, + } + } + return m, tea.Batch(cmd, m.spinner.Tick) + case "esc": + m.state = stateNoOp + m.textInput.SetValue("") + return m, nil + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd + } + if m.output != "" { if msg.String() == "esc" { m.output = "" @@ -104,6 +169,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + if len(m.records) > 0 { + var cmd tea.Cmd + m.paginator, cmd = m.paginator.Update(msg) + if msg.String() == "esc" { + m.records = nil + return m, nil + } + return m, cmd + } + if msg.String() == "enter" { i, ok := m.list.SelectedItem().(menuItem) if ok { @@ -111,13 +186,24 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + case dnsRecordListMsg: + m.loading = false + m.paginator.PerPage = 1 + m.paginator.SetTotalPages(len(msg.Records)) + m.paginator.Page = 0 + m.domains = msg.Records + return m, nil + case domainListMsg: + m.loading = false m.paginator.PerPage = 1 m.paginator.SetTotalPages(len(msg.Domains)) m.paginator.Page = 0 m.domains = msg.Domains + return m, nil case pingResultMsg: + m.loading = false m.output = fmt.Sprintf("Ping successful!\nYour IP: %s", msg.IP) return m, nil @@ -127,26 +213,51 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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(), + ) + } + 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()) } + 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 m.list.View() } func (m *Model) handleSelection(i menuItem) (tea.Model, tea.Cmd) { switch i.id { + case dnsRetrieveRecords: + m.state = stateDNSRetrieveGetDomain + m.textInput.Focus() + return m, textinput.Blink case domainListAll: - return m, func() tea.Msg { + m.loading = true + cmd := func() tea.Msg { resp, err := m.client.DomainListAll(0, true) if err != nil { return messages.ErrorMsg(err) @@ -161,14 +272,17 @@ func (m *Model) handleSelection(i menuItem) (tea.Model, tea.Cmd) { Domains: domains, } } + return m, tea.Batch(cmd, m.spinner.Tick) case utilPing: - return m, func() tea.Msg { + 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 new file mode 100644 index 0000000..f5754a8 --- /dev/null +++ b/internal/ui/pages/menu/recorditem.go @@ -0,0 +1,23 @@ +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() +} diff --git a/internal/ui/utils/text.go b/internal/ui/utils/text.go new file mode 100644 index 0000000..bc08395 --- /dev/null +++ b/internal/ui/utils/text.go @@ -0,0 +1,65 @@ +package utils + +import ( + "strings" +) + +// WrapText wraps text to a limit, forcing mid-word breaks if a word is too long. +// It uses runes to correctly handle UTF-8 multi-byte characters. +func WrapText(text string, limit int) string { + if limit <= 0 { + return text + } + + var result strings.Builder + paragraphs := strings.Split(text, "\n") + + for i, paragraph := range paragraphs { + words := strings.Fields(paragraph) + currentLineLen := 0 + + for _, word := range words { + // Convert to runes to handle UTF-8 characters correctly + wordRunes := []rune(word) + wordLen := len(wordRunes) + + // Determine if we need a space before the word + if currentLineLen > 0 { + // If adding the word + space exceeds the limit, push to next line + if currentLineLen+1+wordLen <= limit { + result.WriteString(" ") + currentLineLen++ + } else { + result.WriteString("\n") + currentLineLen = 0 + } + } + + // Write the word, chunking it if it exceeds the remaining line limit + for len(wordRunes) > 0 { + spaceLeft := limit - currentLineLen + + // If the line is full, wrap to the next line + if spaceLeft <= 0 { + result.WriteString("\n") + currentLineLen = 0 + spaceLeft = limit + } + + // Take as much of the word as fits on the current line + take := min(len(wordRunes), spaceLeft) + + result.WriteString(string(wordRunes[:take])) + currentLineLen += take + wordRunes = wordRunes[take:] // Advance the rune slice + } + } + + // Preserve original paragraph breaks + if i < len(paragraphs)-1 { + result.WriteString("\n") + } + } + + return result.String() +}