fix: bad ux

This commit is contained in:
juancwu 2026-01-26 21:23:33 +00:00
commit 5a425b8c60
15 changed files with 791 additions and 463 deletions

View file

@ -65,6 +65,67 @@ func (c *Client) RetrieveDNSRecords(domain string) (*DNSRecordsResponse, error)
return &resp, nil 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) { func (c *Client) Ping() (*PingResponse, error) {
reqBody := PingRequest{ reqBody := PingRequest{
BaseRequest: BaseRequest{ BaseRequest: BaseRequest{

View file

@ -1,5 +1,77 @@
package porkbun 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. // BaseRequest contains the authentication fields required for most Porkbun API calls.
type BaseRequest struct { type BaseRequest struct {
APIKey string `json:"apikey"` APIKey string `json:"apikey"`
@ -43,10 +115,10 @@ type Domain struct {
TLD string `json:"tld"` TLD string `json:"tld"`
CreateDate string `json:"createDate"` CreateDate string `json:"createDate"`
ExpireDate string `json:"expireDate"` ExpireDate string `json:"expireDate"`
SecurityLock string `json:"securityLock"` SecurityLock PorkbunBadEngineeringAPIResponseType `json:"securityLock"`
WhoIsPrivacy string `json:"whoisPrivacy"` WhoIsPrivacy PorkbunBadEngineeringAPIResponseType `json:"whoisPrivacy"`
AutoRenew any `json:"autoRenew"` AutoRenew PorkbunBadEngineeringAPIResponseType `json:"autoRenew"`
NotLocal any `json:"notLocal"` NotLocal PorkbunBadEngineeringAPIResponseType `json:"notLocal"`
Labels []DomainLabel `json:"labels,omitempty"` Labels []DomainLabel `json:"labels,omitempty"`
} }
@ -65,12 +137,12 @@ type DNSRecordsRequest struct {
// DNSRecord it is what it says it is. // DNSRecord it is what it says it is.
type DNSRecord struct { type DNSRecord struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Content string `json:"content"` Content string `json:"content"`
TTL any `json:"ttl"` Name string `json:"name,omitempty"`
Priority any `json:"prio"` TTL NullString `json:"ttl,omitempty"`
Notes string `json:"notes"` Priority NullString `json:"prio,omitempty"`
Notes NullString `json:"notes,omitempty"`
} }
// DNSRecordsResponse it is what it says it is. // DNSRecordsResponse it is what it says it is.

View file

@ -0,0 +1,3 @@
package defaults
const INPUT_WIDTH = 80

View file

@ -8,7 +8,9 @@ const (
PageLogin Page = iota PageLogin Page = iota
PageMenu PageMenu
PageListDomains PageListDomains
PageDNSRetrieve PageDomainDetails
PageDomainMenu
PageDNSList
) )
type SwitchPageMsg struct { type SwitchPageMsg struct {
@ -21,8 +23,14 @@ type SessionReadyMsg struct {
type ListDomainsMsg struct{} type ListDomainsMsg struct{}
type DomainSelectedMsg struct {
Domain *porkbun.Domain
}
type DNSRetrieveMsg struct { type DNSRetrieveMsg struct {
Domain string Domain string
} }
type RefreshMsg struct{}
type ErrorMsg error type ErrorMsg error

View file

@ -7,16 +7,16 @@ import (
"git.juancwu.dev/juancwu/porkbacon/internal/config" "git.juancwu.dev/juancwu/porkbacon/internal/config"
"git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" "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/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/listdomains"
"git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/login" "git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/login"
"git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/menu"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
type MainModel struct { type MainModel struct {
currentPage messages.Page currentPage messages.Page
pages map[messages.Page]tea.Model pages map[messages.Page]tea.Model
isMenuInit bool
width int width int
height int height int
} }
@ -53,27 +53,54 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
for k, p := range m.pages {
m.pages[k], _ = p.Update(msg)
}
case messages.SwitchPageMsg: case messages.SwitchPageMsg:
m.currentPage = msg.Page m.currentPage = msg.Page
return m, nil return m, nil
case messages.SessionReadyMsg: case messages.SessionReadyMsg:
m.pages[messages.PageMenu] = menu.New(msg.Client)
m.pages[messages.PageListDomains] = listdomains.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 { 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 // Initial flow: Go to ListDomains and trigger load
for _, page := range m.pages { m.currentPage = messages.PageListDomains
cmds = append(cmds, page.Init()) 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...) 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] page, ok := m.pages[m.currentPage]

View file

@ -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
}

View file

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

View file

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

View file

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

View file

@ -2,31 +2,38 @@ package listdomains
import ( import (
"fmt" "fmt"
"strings" "sort"
"git.juancwu.dev/juancwu/porkbacon/internal/porkbun" "git.juancwu.dev/juancwu/porkbacon/internal/porkbun"
"git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages"
"github.com/charmbracelet/bubbles/paginator" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "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 { type Model struct {
loading bool loading bool
client *porkbun.Client client *porkbun.Client
domains []string list list.Model
paginator paginator.Model
spinner spinner.Model spinner spinner.Model
stderr string stderr string
} }
func New(client *porkbun.Client) Model { func New(client *porkbun.Client) Model {
p := paginator.New() l := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
p.Type = paginator.Dots l.Title = "My Domains"
p.PerPage = 1 l.SetShowHelp(true)
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.New()
s.Spinner = spinner.Dot s.Spinner = spinner.Dot
@ -35,8 +42,7 @@ func New(client *porkbun.Client) Model {
return Model{ return Model{
loading: false, loading: false,
client: client, client: client,
domains: nil, list: l,
paginator: p,
spinner: s, spinner: s,
} }
} }
@ -47,46 +53,59 @@ func (m Model) Init() tea.Cmd {
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.WindowSizeMsg:
if !m.loading && msg.String() == "esc" { m.list.SetSize(msg.Width, msg.Height)
m.loading = false
m.domains = nil
return m, func() tea.Msg {
return messages.SwitchPageMsg{Page: messages.PageMenu}
}
}
if len(m.domains) > 0 { case tea.KeyMsg:
m.paginator, cmd = m.paginator.Update(msg) if m.loading {
return m, cmd 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: case messages.ListDomainsMsg:
m.loading = true m.loading = true
m.domains = nil cmds = append(cmds, listDomains(m.client), m.spinner.Tick)
return m, tea.Batch(listDomains(m.client), m.spinner.Tick) return m, tea.Batch(cmds...)
case *porkbun.DomainListAllResponse: case *porkbun.DomainListAllResponse:
m.loading = false m.loading = false
var items []list.Item
for _, domain := range msg.Domains { for _, domain := range msg.Domains {
m.domains = append(m.domains, renderDomain(&domain)) items = append(items, item{domain: domain})
} }
m.paginator.SetTotalPages(len(m.domains)) // Sort by domain name
m.paginator.Page = 0 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: case messages.ErrorMsg:
m.stderr = fmt.Sprintf("Error: %v", msg) m.stderr = fmt.Sprintf("Error: %v", msg)
m.loading = false
return m, nil return m, nil
} }
if m.loading { if m.loading {
m.spinner, cmd = m.spinner.Update(msg) 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 { 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()) return fmt.Sprintf("\n\n %s Loading... press ctl+c to quit\n\n", m.spinner.View())
} }
if len(m.domains) > 0 { return m.list.View()
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."
} }
func listDomains(client *porkbun.Client) tea.Cmd { func listDomains(client *porkbun.Client) tea.Cmd {
@ -115,25 +130,3 @@ func listDomains(client *porkbun.Client) tea.Cmd {
return resp 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()
}

View file

@ -6,6 +6,7 @@ import (
"git.juancwu.dev/juancwu/porkbacon/internal/config" "git.juancwu.dev/juancwu/porkbacon/internal/config"
"git.juancwu.dev/juancwu/porkbacon/internal/porkbun" "git.juancwu.dev/juancwu/porkbacon/internal/porkbun"
"git.juancwu.dev/juancwu/porkbacon/internal/ui/defaults"
"git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -37,7 +38,7 @@ func New(cfg *config.Config) Model {
m.inputs[0] = textinput.New() m.inputs[0] = textinput.New()
m.inputs[0].Placeholder = "Master Password" m.inputs[0].Placeholder = "Master Password"
m.inputs[0].EchoMode = textinput.EchoPassword m.inputs[0].EchoMode = textinput.EchoPassword
m.inputs[0].Width = 50 m.inputs[0].Width = defaults.INPUT_WIDTH
m.inputs[0].Focus() m.inputs[0].Focus()
} else { } else {
m.mode = ModeSetup m.mode = ModeSetup
@ -46,18 +47,18 @@ func New(cfg *config.Config) Model {
m.inputs[0] = textinput.New() m.inputs[0] = textinput.New()
m.inputs[0].Placeholder = "API Key" m.inputs[0].Placeholder = "API Key"
m.inputs[0].EchoMode = textinput.EchoPassword m.inputs[0].EchoMode = textinput.EchoPassword
m.inputs[0].Width = 50 m.inputs[0].Width = defaults.INPUT_WIDTH
m.inputs[0].Focus() m.inputs[0].Focus()
m.inputs[1] = textinput.New() m.inputs[1] = textinput.New()
m.inputs[1].Placeholder = "Secret API Key" m.inputs[1].Placeholder = "Secret API Key"
m.inputs[1].EchoMode = textinput.EchoPassword 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] = textinput.New()
m.inputs[2].Placeholder = "Master Password (to encrypt keys)" m.inputs[2].Placeholder = "Master Password (to encrypt keys)"
m.inputs[2].EchoMode = textinput.EchoPassword m.inputs[2].EchoMode = textinput.EchoPassword
m.inputs[2].Width = 50 m.inputs[2].Width = defaults.INPUT_WIDTH
} }
return m return m

View file

@ -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
}

View file

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