summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/go.mod32
-rw-r--r--ui/go.sum51
-rw-r--r--ui/main.go17
-rw-r--r--ui/views/login.go278
-rw-r--r--ui/views/views.go45
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
+}