fix: bad ux
This commit is contained in:
parent
a70ed5a372
commit
8e531f8e3c
15 changed files with 796 additions and 463 deletions
|
|
@ -16,4 +16,4 @@ func main() {
|
||||||
fmt.Println("Error:", err)
|
fmt.Println("Error:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ func Load() (*Config, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := filepath.Join(configDir, "config.json")
|
filename := filepath.Join(configDir, "config.json")
|
||||||
|
|
||||||
// If file doesn't exist, return empty config with filename set
|
// If file doesn't exist, return empty config with filename set
|
||||||
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
||||||
return &Config{filename: filename}, nil
|
return &Config{filename: filename}, nil
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
@ -38,16 +110,16 @@ type DomainListAllResponse struct {
|
||||||
|
|
||||||
// Domain represents a single domain returned by domain endpoints.
|
// Domain represents a single domain returned by domain endpoints.
|
||||||
type Domain struct {
|
type Domain struct {
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DomainLabel represents a label associated with a domain.
|
// DomainLabel represents a label associated with a domain.
|
||||||
|
|
@ -64,13 +136,13 @@ 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"`
|
Name string `json:"name,omitempty"`
|
||||||
TTL any `json:"ttl"`
|
TTL NullString `json:"ttl,omitempty"`
|
||||||
Priority any `json:"prio"`
|
Priority NullString `json:"prio,omitempty"`
|
||||||
Notes string `json:"notes"`
|
Notes NullString `json:"notes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNSRecordsResponse it is what it says it is.
|
// DNSRecordsResponse it is what it says it is.
|
||||||
|
|
|
||||||
3
internal/ui/defaults/input.go
Normal file
3
internal/ui/defaults/input.go
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
package defaults
|
||||||
|
|
||||||
|
const INPUT_WIDTH = 80
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Initial flow: Go to ListDomains and trigger load
|
||||||
var cmds []tea.Cmd
|
m.currentPage = messages.PageListDomains
|
||||||
for _, page := range m.pages {
|
return m, func() tea.Msg { return messages.ListDomainsMsg{} }
|
||||||
cmds = append(cmds, page.Init())
|
|
||||||
|
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]
|
||||||
|
|
|
||||||
370
internal/ui/pages/dns/list.go
Normal file
370
internal/ui/pages/dns/list.go
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
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)
|
||||||
|
// Don't auto-fetch if we want to wait for user to enter page,
|
||||||
|
// but typically we want data ready.
|
||||||
|
// However, user enters 'Domain Menu' first.
|
||||||
|
// So we can wait until SwitchPageMsg or explicit Refresh.
|
||||||
|
// Let's clear previous data
|
||||||
|
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.inputs[0].Focus()
|
||||||
|
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.inputs[0].Focus()
|
||||||
|
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" && m.focusIndex == len(m.inputs) { // Submit button logic if I had one, but let's use Enter on last field or just Enter?
|
||||||
|
// Let's iterate inputs, if all filled? No, optional fields.
|
||||||
|
// Let's say Enter on last field triggers submit.
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Update inputs
|
||||||
|
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("")
|
||||||
|
}
|
||||||
|
m.focusIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
m.focusIndex = 0
|
||||||
|
}
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
67
internal/ui/pages/domaindetails/model.go
Normal file
67
internal/ui/pages/domaindetails/model.go
Normal 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()
|
||||||
|
}
|
||||||
93
internal/ui/pages/domainmenu/model.go
Normal file
93
internal/ui/pages/domainmenu/model.go
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -2,42 +2,48 @@ 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
|
||||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue