diff options
author | Santo Cariotti <santo@dcariotti.me> | 2025-04-08 14:37:33 +0200 |
---|---|---|
committer | Santo Cariotti <santo@dcariotti.me> | 2025-04-08 14:39:13 +0200 |
commit | 1f0d9ec8452f15c27cd33c4e3874454c35993743 (patch) | |
tree | c453a31ae5eb823aaf48868eea9fc4daf65f108b /pkg | |
parent | c5b10e28b358308d8349b940af09f64368172f2e (diff) |
Use internal/pkg structure
Diffstat (limited to 'pkg')
-rw-r--r-- | pkg/ui/views/auth.go | 590 | ||||
-rw-r--r-- | pkg/ui/views/play.go | 427 | ||||
-rw-r--r-- | pkg/ui/views/tabs.go | 32 | ||||
-rw-r--r-- | pkg/ui/views/views.go | 144 | ||||
-rw-r--r-- | pkg/utils.go | 35 |
5 files changed, 1193 insertions, 35 deletions
diff --git a/pkg/ui/views/auth.go b/pkg/ui/views/auth.go new file mode 100644 index 0000000..a695466 --- /dev/null +++ b/pkg/ui/views/auth.go @@ -0,0 +1,590 @@ +package views + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + SignInTab TabType = iota + SignUpTab +) + +// AuthModel is the main container model for both login and signup tabsuth +type AuthModel struct { + loginModel loginModel + signupModel signupModel + activeTab TabType + width int + height int +} + +// 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 +} + +// Model holds the state for signup page +type signupModel struct { + loginModel + confirmPassword textinput.Model +} + +// Response from API +type authResponse struct { + Token string `json:"token"` + Error string `json:"error"` +} + +// Initialize AuthModel which contains both tabs +func NewAuthModel(width, height int) AuthModel { + return AuthModel{ + loginModel: initLoginModel(width, height), + signupModel: initSignupModel(width, height), + activeTab: SignInTab, + width: width, + height: height, + } +} + +// Initialize loginModel +func initLoginModel(width, height int) loginModel { + 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 + + return loginModel{ + username: username, + password: password, + focus: 0, + err: nil, + isLoading: false, + token: "", + width: width, + height: height, + } +} + +// Initialize signupModel +func initSignupModel(width, height int) signupModel { + 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 + + confirmPassword := textinput.New() + confirmPassword.Prompt = " " + confirmPassword.TextStyle = inputStyle + confirmPassword.Placeholder = "*****" + confirmPassword.EchoMode = textinput.EchoPassword + confirmPassword.CharLimit = 156 + confirmPassword.Width = 30 + + return signupModel{ + loginModel: loginModel{ + username: username, + password: password, + focus: 0, + err: nil, + isLoading: false, + token: "", + width: width, + height: height, + }, + confirmPassword: confirmPassword, + } +} + +// Init function for AuthModel +func (m AuthModel) Init() tea.Cmd { + ClearScreen() + return textinput.Blink +} + +func (m AuthModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + if exit := handleExit(msg); exit != nil { + return m, exit + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "alt+1": + // Switch to sign-in tab + if m.activeTab != SignInTab { + m.activeTab = SignInTab + m.loginModel.focus = 0 + m.loginModel.username.Focus() + m.loginModel.password.Blur() + m.signupModel.username.Blur() + m.signupModel.password.Blur() + m.signupModel.confirmPassword.Blur() + } + return m, nil + + case "alt+2": + // Switch to sign-up tab + if m.activeTab != SignUpTab { + m.activeTab = SignUpTab + m.signupModel.focus = 0 + m.signupModel.username.Focus() + m.signupModel.password.Blur() + m.signupModel.confirmPassword.Blur() + m.loginModel.username.Blur() + m.loginModel.password.Blur() + } + return m, nil + + } + } + + if m.activeTab == SignInTab { + var cmd tea.Cmd + m.loginModel, cmd = m.loginModel.Update(msg) + cmds = append(cmds, cmd) + } else { + var cmd tea.Cmd + m.signupModel, cmd = m.signupModel.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +// View function for AuthModel +func (m AuthModel) View() string { + width, height := m.width, m.height + + // Get the content of the active tab + var tabContent string + if m.activeTab == SignInTab { + tabContent = m.loginModel.renderContent() + } else { + tabContent = m.signupModel.renderContent() + } + + // Create the window with tab content + ui := lipgloss.JoinVertical(lipgloss.Center, + getTabsRow([]string{"Sign In", "Sign Up"}, m.activeTab), + windowStyle.Width(getFormWidth(width)).Render(tabContent), + ) + + // Center logo and form in available space + contentHeight := lipgloss.Height(logo) + lipgloss.Height(ui) + 2 + paddingTop := (height - contentHeight) / 2 + if paddingTop < 0 { + paddingTop = 0 + } + + // Combine logo and tabs with vertical centering + output := lipgloss.NewStyle(). + MarginTop(paddingTop). + Render( + lipgloss.JoinVertical(lipgloss.Center, + getLogo(m.width), + lipgloss.PlaceHorizontal(width, lipgloss.Center, ui), + ), + ) + + return output +} + +// Update function for loginModel +func (m loginModel) Update(msg tea.Msg) (loginModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyUp: + m.focus = (m.focus - 1) % 2 + if m.focus < 0 { + m.focus = 1 + } + m.updateFocus() + case tea.KeyDown: + m.focus = (m.focus + 1) % 2 + m.updateFocus() + case tea.KeyEnter: + if !m.isLoading { + m.isLoading = true + return m, m.loginCallback() + } + case tea.KeyTab: + m.focus = (m.focus + 1) % 2 + m.updateFocus() + } + case authResponse: + m.isLoading = false + if msg.Error != "" { + m.err = fmt.Errorf(msg.Error) + m.focus = 0 + m.updateFocus() + } 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, SwitchModelCmd(NewPlayModel(m.width, m.height)) + } + case error: + m.isLoading = false + m.err = msg + m.focus = 0 + m.updateFocus() + } + + 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) +} + +// Update function for signupModel +func (m signupModel) Update(msg tea.Msg) (signupModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyUp: + m.focus = (m.focus - 1) % 3 + if m.focus < 0 { + m.focus = 2 + } + m.updateFocus() + case tea.KeyDown: + m.focus = (m.focus + 1) % 3 + m.updateFocus() + case tea.KeyEnter: + if !m.isLoading { + m.isLoading = true + return m, m.signupCallback() + } + case tea.KeyTab: + m.focus = (m.focus + 1) % 3 + m.updateFocus() + } + case authResponse: + m.isLoading = false + if msg.Error != "" { + m.err = fmt.Errorf(msg.Error) + m.focus = 0 + m.updateFocus() + } 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, SwitchModelCmd(NewPlayModel(m.width, m.height)) + } + case error: + m.isLoading = false + m.err = msg + m.focus = 0 + m.updateFocus() + } + + var cmds []tea.Cmd + var cmd tea.Cmd + + m.username, cmd = m.username.Update(msg) + cmds = append(cmds, cmd) + + m.password, cmd = m.password.Update(msg) + cmds = append(cmds, cmd) + + m.confirmPassword, cmd = m.confirmPassword.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +// Helper function to update input focus for signup +func (m *signupModel) updateFocus() { + m.username.Blur() + m.password.Blur() + m.confirmPassword.Blur() + + switch m.focus { + case 0: + m.username.Focus() + case 1: + m.password.Focus() + case 2: + m.confirmPassword.Focus() + } +} + +// Helper function to update input focus for signin +func (m *loginModel) updateFocus() { + m.username.Blur() + m.password.Blur() + + switch m.focus { + case 0: + m.username.Focus() + case 1: + m.password.Focus() + } +} + +// 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 authResponse{Error: err.Error()} + } + + resp, err := http.Post(url, "application/json", bytes.NewReader(payload)) + if err != nil { + return authResponse{Error: err.Error()} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var response authResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return authResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return authResponse{Error: response.Error} + } + + var response authResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return authResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +// Signup API callback +func (m signupModel) signupCallback() tea.Cmd { + return func() tea.Msg { + // Validate that passwords match + if m.password.Value() != m.confirmPassword.Value() { + return authResponse{Error: "Passwords do not match"} + } + + url := os.Getenv("API_BASE") + "/auth/register" + + payload, err := json.Marshal(map[string]string{ + "username": m.username.Value(), + "password": m.password.Value(), + }) + + if err != nil { + return authResponse{Error: err.Error()} + } + + resp, err := http.Post(url, "application/json", bytes.NewReader(payload)) + if err != nil { + return authResponse{Error: err.Error()} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + var response authResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return authResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return authResponse{Error: response.Error} + } + + var response authResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return authResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +// Render content of the login tab +func (m loginModel) renderContent() string { + formWidth := getFormWidth(m.width) + + // Styles + 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 + + 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.Align(lipgloss.Center).Width(formWidth-4).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), + ) + + return form +} + +// Render content of the signup tab +func (m signupModel) renderContent() string { + formWidth := getFormWidth(m.width) + + // Styles + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7ee2a8")). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + labelStyle := lipgloss.NewStyle(). + Width(16). + Align(lipgloss.Right) + + inputWrapStyle := lipgloss.NewStyle(). + 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 register", lipgloss.NewStyle().Italic(true).Render("Enter")) + if m.isLoading { + statusMsg = "Creating account..." + } + + form := lipgloss.JoinVertical(lipgloss.Center, + titleStyle.Render("Create a new account"), + "\n", + errorStyle.Align(lipgloss.Center).Width(formWidth-4).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(), + ), + ), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Confirm:"), + m.confirmPassword.View(), + ), + ), + "\n", + statusStyle.Render(statusMsg), + ) + + return form +} diff --git a/pkg/ui/views/play.go b/pkg/ui/views/play.go new file mode 100644 index 0000000..991849e --- /dev/null +++ b/pkg/ui/views/play.go @@ -0,0 +1,427 @@ +package views + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/boozec/rahanna/internal/api/database" + "github.com/boozec/rahanna/internal/network" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var chess string = ` + A B C D E F G H ++---------------+ +8 |♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜| 8 +7 |♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟| 7 +6 |. . . . . . . .| 6 +5 |. . . . . . . .| 5 +4 |. . . . . . . .| 4 +3 |. . . . . . . .| 3 +2 |♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙| 2 +1 |♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖| 1 ++---------------+ + A B C D E F G H +` + +type playKeyMap struct { + EnterNewGame key.Binding + StartNewGame key.Binding + GoLogout key.Binding + Quit key.Binding +} + +type playResponse struct { + Name string `json:"name"` + Error string `json:"error"` +} + +var defaultGameKeyMap = playKeyMap{ + EnterNewGame: key.NewBinding( + key.WithKeys("alt+E", "alt+e"), + key.WithHelp("Alt+E", "Enter a play using code"), + ), + StartNewGame: key.NewBinding( + key.WithKeys("alt+s", "alt+s"), + key.WithHelp("Alt+S", "Start a new play"), + ), + GoLogout: key.NewBinding( + key.WithKeys("alt+Q", "alt+q"), + key.WithHelp("Alt+Q", "Logout"), + ), + Quit: key.NewBinding( + key.WithKeys("Q", "q"), + key.WithHelp(" Q", "Quit"), + ), +} + +type PlayModelPage int + +const ( + LandingPage PlayModelPage = iota + InsertCodePage + StartGamePage +) + +type PlayModel struct { + width int + height int + err error + keys playKeyMap + namePrompt textinput.Model + page PlayModelPage + isLoading bool + playName string + play *database.Game +} + +func NewPlayModel(width, height int) PlayModel { + namePrompt := textinput.New() + namePrompt.Prompt = " " + namePrompt.TextStyle = inputStyle + namePrompt.Placeholder = "rectangular-lake" + namePrompt.Focus() + namePrompt.CharLimit = 23 + namePrompt.Width = 23 + + return PlayModel{ + width: width, + height: height, + err: nil, + keys: defaultGameKeyMap, + namePrompt: namePrompt, + page: LandingPage, + isLoading: false, + playName: "", + play: nil, + } +} + +func (m PlayModel) Init() tea.Cmd { + ClearScreen() + return textinput.Blink +} + +func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if exit := handleExit(msg); exit != nil { + return m, exit + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.EnterNewGame): + m.page = InsertCodePage + return m, nil + case key.Matches(msg, m.keys.StartNewGame): + m.page = StartGamePage + if !m.isLoading { + m.isLoading = true + return m, m.newGameCallback() + } + case key.Matches(msg, m.keys.GoLogout): + return m, m.logout() + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case msg.Type == tea.KeyEnter: + if m.page == InsertCodePage { + if !m.isLoading { + m.isLoading = true + return m, m.enterGame() + } + } + } + case playResponse: + m.isLoading = false + m.err = nil + if msg.Error != "" { + m.err = fmt.Errorf(msg.Error) + if msg.Error == "unauthorized" { + return m, m.logout() + } + } else { + m.playName = msg.Name + } + return m, nil + case database.Game: + m.isLoading = false + m.play = &msg + m.err = nil + return m, nil + case error: + m.isLoading = false + m.err = msg + } + + var cmd tea.Cmd = nil + + if m.page == InsertCodePage { + m.namePrompt, cmd = m.namePrompt.Update(msg) + } + + return m, tea.Batch(cmd) +} + +func (m PlayModel) View() string { + formWidth := getFormWidth(m.width) + + var content string + base := lipgloss.NewStyle().Align(lipgloss.Center).Width(m.width) + + switch m.page { + case LandingPage: + content = chess + m.namePrompt.Blur() + case InsertCodePage: + m.namePrompt.Focus() + var statusMsg string + if m.isLoading { + statusMsg = "Loading..." + content = base. + Render( + lipgloss.NewStyle(). + Align(lipgloss.Center). + Bold(true). + Render(statusMsg), + ) + } else if m.play != nil { + statusMsg = fmt.Sprintf("You are playing versus %s", lipgloss.NewStyle().Foreground(lipgloss.Color("#e67e22")).Render(m.play.Player1.Username)) + content = base. + Render( + lipgloss.NewStyle(). + Align(lipgloss.Center). + Width(m.width). + Bold(true). + Render(statusMsg), + ) + } else { + statusMsg = fmt.Sprintf("Press %s to join", lipgloss.NewStyle().Italic(true).Render("Enter")) + content = base. + Render( + lipgloss.JoinVertical(lipgloss.Left, + lipgloss.NewStyle().Width(23).Render("Insert play code:"), + m.namePrompt.View(), + lipgloss.NewStyle(). + Align(lipgloss.Center). + PaddingTop(2). + Width(23). + Bold(true). + Render(statusMsg), + ), + ) + } + + case StartGamePage: + var statusMsg string + if m.isLoading { + statusMsg = "Loading..." + } else if m.playName != "" { + statusMsg = fmt.Sprintf("Share `%s` to your friend", lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("#F39C12")).Render(m.playName)) + } + + content = base. + Render(statusMsg) + } + + var windowContent string + if m.err != nil { + formError := fmt.Sprintf("Error: %v", m.err.Error()) + windowContent = lipgloss.JoinVertical( + lipgloss.Center, + windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( + lipgloss.Center, + errorStyle.Align(lipgloss.Center).Width(formWidth-4).Render(formError), + content, + )), + ) + } else { + windowContent = lipgloss.JoinVertical( + lipgloss.Center, + windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( + lipgloss.Center, + content, + )), + ) + } + + enterKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.EnterNewGame.Help().Key), m.keys.EnterNewGame.Help().Desc) + startKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.StartNewGame.Help().Key), m.keys.StartNewGame.Help().Desc) + logoutKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.GoLogout.Help().Key), m.keys.GoLogout.Help().Desc) + quitKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.Quit.Help().Key), m.keys.Quit.Help().Desc) + + // Vertically align the buttons + buttons := lipgloss.JoinVertical( + lipgloss.Left, + enterKey, + startKey, + logoutKey, + quitKey, + ) + + centeredContent := lipgloss.JoinVertical( + lipgloss.Center, + getLogo(m.width), + windowContent, + lipgloss.NewStyle().MarginTop(2).Render(buttons), + ) + + return lipgloss.Place( + m.width, + m.height, + lipgloss.Center, + lipgloss.Center, + centeredContent, + ) +} + +func (m PlayModel) newGameCallback() tea.Cmd { + return func() tea.Msg { + f, err := os.Open(".rahannarc") + if err != nil { + return playResponse{Error: err.Error()} + } + defer f.Close() + + scanner := bufio.NewScanner(f) + var authorization string + for scanner.Scan() { + authorization = scanner.Text() + } + + if err := scanner.Err(); err != nil { + fmt.Println("Error during scanning:", err) + } + + url := os.Getenv("API_BASE") + "/play" + + port, err := network.GetRandomAvailablePort() + if err != nil { + return playResponse{Error: err.Error()} + } + + payload, err := json.Marshal(map[string]string{ + "ip": fmt.Sprintf("%s:%d", network.GetOutboundIP().String(), port), + }) + + if err != nil { + return playResponse{Error: err.Error()} + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) + if err != nil { + return playResponse{Error: err.Error()} + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", authorization)) + + client := &http.Client{} + + resp, err := client.Do(req) + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var response playResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return playResponse{Error: response.Error} + } + + var response playResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +func (m PlayModel) enterGame() tea.Cmd { + return func() tea.Msg { + f, err := os.Open(".rahannarc") + if err != nil { + return playResponse{Error: err.Error()} + } + defer f.Close() + + scanner := bufio.NewScanner(f) + var authorization string + for scanner.Scan() { + authorization = scanner.Text() + } + + if err := scanner.Err(); err != nil { + fmt.Println("Error during scanning:", err) + } + + url := os.Getenv("API_BASE") + "/enter-game" + + port, err := network.GetRandomAvailablePort() + if err != nil { + return playResponse{Error: err.Error()} + } + + payload, err := json.Marshal(map[string]string{ + "ip": fmt.Sprintf("%s:%d", network.GetOutboundIP().String(), port), + "name": m.namePrompt.Value(), + }) + + if err != nil { + return playResponse{Error: err.Error()} + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) + if err != nil { + return playResponse{Error: err.Error()} + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", authorization)) + + client := &http.Client{} + + resp, err := client.Do(req) + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var response playResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return playResponse{Error: response.Error} + } + + var response database.Game + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +func (m PlayModel) logout() tea.Cmd { + if err := os.Remove(".rahannarc"); err != nil { + return nil + } + return SwitchModelCmd(NewAuthModel(m.width, m.height+1)) +} diff --git a/pkg/ui/views/tabs.go b/pkg/ui/views/tabs.go new file mode 100644 index 0000000..13e3672 --- /dev/null +++ b/pkg/ui/views/tabs.go @@ -0,0 +1,32 @@ +package views + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +type TabType int + +var ( + tabStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(highlightColor).Padding(0, 2) + inactiveTabStyle = tabStyle + activeTabStyle = tabStyle +) + +func getTabsRow(tabsText []string, activeTab TabType) string { + tabs := make([]string, len(tabsText)) + + for i, tab := range tabsText { + if TabType(i) == activeTab { + tabs[i] = fmt.Sprintf("%s %s", altCodeStyle.Render(fmt.Sprintf("Alt+%d", i+1)), lipgloss.NewStyle().Bold(true).Foreground(highlightColor).Render(tab)) + tabs[i] = activeTabStyle.Foreground(highlightColor).Render(tabs[i]) + } else { + tabs[i] = fmt.Sprintf("%s %s", altCodeStyle.Render(fmt.Sprintf("Alt+%d", i+1)), lipgloss.NewStyle().Render(tab)) + tabs[i] = inactiveTabStyle.Foreground(highlightColor).Render(tabs[i]) + } + } + + return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + +} diff --git a/pkg/ui/views/views.go b/pkg/ui/views/views.go new file mode 100644 index 0000000..fa70035 --- /dev/null +++ b/pkg/ui/views/views.go @@ -0,0 +1,144 @@ +package views + +import ( + "errors" + "os" + + "os/exec" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "golang.org/x/term" +) + +var logo = ` +▗▄▄▖ ▗▄▖ ▗▖ ▗▖ ▗▄▖ ▗▖ ▗▖▗▖ ▗▖ ▗▄▖ +▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▛▚▖▐▌▐▛▚▖▐▌▐▌ ▐▌ +▐▛▀▚▖▐▛▀▜▌▐▛▀▜▌▐▛▀▜▌▐▌ ▝▜▌▐▌ ▝▜▌▐▛▀▜▌ +▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌ +` + +var ( + highlightColor = lipgloss.Color("#7ee2a8") + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) + altCodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Bold(true) + windowStyle = lipgloss.NewStyle().BorderForeground(highlightColor).Padding(2, 0).Align(lipgloss.Center).Border(lipgloss.RoundedBorder()) + inputStyle = lipgloss.NewStyle().Foreground(highlightColor) +) + +// 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() { + if len(os.Getenv("DEBUG")) == 0 { + cmd := exec.Command("clear") + if os.Getenv("OS") == "Windows_NT" { + cmd = exec.Command("cmd", "/c", "cls") + } + 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 +} + +type RahannaModel struct { + width int + height int + currentModel tea.Model + auth AuthModel + play PlayModel +} + +func NewRahannaModel() RahannaModel { + width, height := GetTerminalSize() + + auth := NewAuthModel(width, height) + play := NewPlayModel(width, height) + + var currentModel tea.Model = auth + + if _, err := os.Stat(".rahannarc"); !errors.Is(err, os.ErrNotExist) { + currentModel = play + } + + return RahannaModel{ + width: width, + height: height, + currentModel: currentModel, + auth: auth, + play: play, + } +} + +func (m RahannaModel) Init() tea.Cmd { + return m.currentModel.Init() +} + +type switchModel struct { + model tea.Model +} + +func SwitchModelCmd(model tea.Model) tea.Cmd { + s := switchModel{ + model: model, + } + + return func() tea.Msg { + return s + } +} + +func (m RahannaModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case switchModel: + m.currentModel = msg.model + return m, nil + } + var cmd tea.Cmd + m.currentModel, cmd = m.currentModel.Update(msg) + return m, cmd +} + +func (m RahannaModel) View() string { + return m.currentModel.View() +} + +func handleExit(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + return tea.Quit + } + } + + return nil +} + +func getLogo(width int) string { + logoStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7ee2a8")). + Bold(true). + Align(lipgloss.Center). + Width(width) + + return logoStyle.Render(logo) + +} diff --git a/pkg/utils.go b/pkg/utils.go deleted file mode 100644 index 9246854..0000000 --- a/pkg/utils.go +++ /dev/null @@ -1,35 +0,0 @@ -package utils - -import ( - "encoding/json" - "net/http" - - "golang.org/x/crypto/bcrypt" -) - -func HashPassword(password string) (string, error) { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) - return string(bytes), err -} - -func CheckPasswordHash(password, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil -} - -// Set a JSON response with status code 400 -func JsonError(w *http.ResponseWriter, error string) { - payloadMap := map[string]string{"error": error} - - (*w).Header().Set("Content-Type", "application/json") - (*w).WriteHeader(http.StatusBadRequest) - - payload, err := json.Marshal(payloadMap) - - if err != nil { - (*w).WriteHeader(http.StatusBadGateway) - (*w).Write([]byte(err.Error())) - } else { - (*w).Write(payload) - } -} |