diff options
Diffstat (limited to 'ui')
-rw-r--r-- | ui/go.mod | 32 | ||||
-rw-r--r-- | ui/go.sum | 51 | ||||
-rw-r--r-- | ui/main.go | 17 | ||||
-rw-r--r-- | ui/views/login.go | 278 | ||||
-rw-r--r-- | ui/views/views.go | 45 |
5 files changed, 423 insertions, 0 deletions
diff --git a/ui/go.mod b/ui/go.mod new file mode 100644 index 0000000..396aeee --- /dev/null +++ b/ui/go.mod @@ -0,0 +1,32 @@ +module github.com/boozec/rahanna/ui + +go 1.24.0 + +require ( + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/bubbletea v1.3.4 + github.com/charmbracelet/lipgloss v1.1.0 + golang.org/x/term v0.30.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/ui/go.sum b/ui/go.sum new file mode 100644 index 0000000..bef9076 --- /dev/null +++ b/ui/go.sum @@ -0,0 +1,51 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= +github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/ui/main.go b/ui/main.go new file mode 100644 index 0000000..d24624a --- /dev/null +++ b/ui/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "log" + + "github.com/boozec/rahanna/ui/views" + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + views.ClearScreen() + p := tea.NewProgram(views.LoginModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + log.Fatal(err) + } + views.ClearScreen() +} diff --git a/ui/views/login.go b/ui/views/login.go new file mode 100644 index 0000000..e42d37f --- /dev/null +++ b/ui/views/login.go @@ -0,0 +1,278 @@ +package views + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Model holds the state for login page +type loginModel struct { + username textinput.Model + password textinput.Model + focus int + err error + isLoading bool + token string + width int + height int +} + +// Response from API +type loginResponse struct { + Token string `json:"token"` + Error string `json:"error"` +} + +// Initialize loginModel +func LoginModel() loginModel { + inputStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7EE2A8")) + + username := textinput.New() + username.Prompt = " " + username.TextStyle = inputStyle + username.Placeholder = "mario.rossi" + username.Focus() + username.CharLimit = 156 + username.Width = 30 + + password := textinput.New() + password.Prompt = " " + password.TextStyle = inputStyle + password.Placeholder = "*****" + password.EchoMode = textinput.EchoPassword + password.CharLimit = 156 + password.Width = 30 + + width, height := GetTerminalSize() + + return loginModel{ + username: username, + password: password, + focus: 0, + err: nil, + isLoading: false, + token: "", + width: width, + height: height, + } +} + +// Init function +func (m loginModel) Init() tea.Cmd { + ClearScreen() + return textinput.Blink +} + +// Update function +func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case tea.KeyMsg: + switch msg.Type { + case tea.KeyUp: + m.focus = 0 + m.username.Focus() + m.password.Blur() + case tea.KeyDown: + m.focus = 1 + m.username.Blur() + m.password.Focus() + case tea.KeyEnter: + if !m.isLoading { + m.isLoading = true + return m, m.loginCallback() + } + case tea.KeyTab: + m.focus = (m.focus + 1) % 2 + if m.focus == 0 { + m.username.Focus() + m.password.Blur() + } else { + m.username.Blur() + m.password.Focus() + } + case tea.KeyCtrlC: + return m, tea.Quit + } + case loginResponse: + m.isLoading = false + if msg.Error != "" { + m.err = fmt.Errorf(msg.Error) + m.focus = 0 + m.username.Focus() + m.password.Blur() + } else { + m.token = msg.Token + ClearScreen() + f, err := os.OpenFile(".rahannarc", os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + m.err = err + break + } + defer f.Close() + + f.Write([]byte(m.token)) + + return m, tea.Quit + } + case error: + m.isLoading = false + m.err = msg + m.focus = 0 + m.username.Focus() + m.password.Blur() + } + + var cmd tea.Cmd + m.username, cmd = m.username.Update(msg) + cmdPassword := tea.Batch(cmd) + m.password, cmd = m.password.Update(msg) + return m, tea.Batch(cmd, cmdPassword) +} + +// Login API callback +func (m loginModel) loginCallback() tea.Cmd { + return func() tea.Msg { + url := os.Getenv("API_BASE") + "/auth/login" + + payload, err := json.Marshal(map[string]string{ + "username": m.username.Value(), + "password": m.password.Value(), + }) + + if err != nil { + return loginResponse{Error: err.Error()} + } + + resp, err := http.Post(url, "application/json", bytes.NewReader(payload)) + if err != nil { + return loginResponse{Error: err.Error()} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var response loginResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return loginResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return loginResponse{Error: response.Error} + } + + var response loginResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return loginResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +// View function (UI rendering) +func (m loginModel) View() string { + width, height := m.width, m.height + formWidth := getFormWidth(width) + + // Styles + logoStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7ee2a8")). + Bold(true). + Align(lipgloss.Center). + Width(width) + + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00ffcc")). + Padding(1, 2). + Align(lipgloss.Center). + Width(formWidth) + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7ee2a8")). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + labelStyle := lipgloss.NewStyle(). + Width(10). + Align(lipgloss.Right) + + inputWrapStyle := lipgloss.NewStyle(). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + errorStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ff0000")). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + statusStyle := lipgloss.NewStyle(). + Align(lipgloss.Center). + Bold(true). + Width(formWidth - 4) // Account for padding + + // Error message + formError := "" + if m.err != nil { + formError = fmt.Sprintf("Error: %v", m.err.Error()) + } + + // Status message + statusMsg := fmt.Sprintf("Press %s to login", lipgloss.NewStyle().Italic(true).Render("Enter")) + if m.isLoading { + statusMsg = "Logging in..." + } + + form := lipgloss.JoinVertical(lipgloss.Center, + titleStyle.Render("Sign in to your account"), + "\n", + errorStyle.Render(formError), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Username:"), + m.username.View(), + ), + ), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Password:"), + m.password.View(), + ), + ), + "\n", + statusStyle.Render(statusMsg), + ) + + // Wrap content inside a border + borderedForm := borderStyle.Render(form) + + // Center logo and form in available space + contentHeight := lipgloss.Height(logo) + lipgloss.Height(borderedForm) + 2 + paddingTop := (height - contentHeight) / 2 + if paddingTop < 0 { + paddingTop = 0 + } + + // Combine logo and form with vertical centering + output := lipgloss.NewStyle(). + MarginTop(paddingTop). + Render( + lipgloss.JoinVertical(lipgloss.Center, + logoStyle.Render(logo), + lipgloss.PlaceHorizontal(width, lipgloss.Center, borderedForm), + ), + ) + + return output +} diff --git a/ui/views/views.go b/ui/views/views.go new file mode 100644 index 0000000..18c1124 --- /dev/null +++ b/ui/views/views.go @@ -0,0 +1,45 @@ +package views + +import ( + "os" + "os/exec" + + "golang.org/x/term" +) + +var logo = ` +▗▄▄▖ ▗▄▖ ▗▖ ▗▖ ▗▄▖ ▗▖ ▗▖▗▖ ▗▖ ▗▄▖ +▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▛▚▖▐▌▐▛▚▖▐▌▐▌ ▐▌ +▐▛▀▚▖▐▛▀▜▌▐▛▀▜▌▐▛▀▜▌▐▌ ▝▜▌▐▌ ▝▜▌▐▛▀▜▌ +▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌ +` + +// Get terminal size dynamically +func GetTerminalSize() (width, height int) { + fd := int(os.Stdin.Fd()) + if w, h, err := term.GetSize(fd); err == nil { + return w, h + } + return 80, 24 // Default size if detection fails +} + +// Clear terminal screen +func ClearScreen() { + cmd := exec.Command("clear") // Unix (Linux/macOS) + if os.Getenv("OS") == "Windows_NT" { + cmd = exec.Command("cmd", "/c", "cls") // Windows + } + cmd.Stdout = os.Stdout + cmd.Run() +} + +func getFormWidth(width int) int { + formWidth := width * 2 / 3 + if formWidth > 80 { + formWidth = 80 // Cap at 80 chars for readability + } else if formWidth < 40 { + formWidth = width - 4 // For small terminals + } + + return formWidth +} |