summaryrefslogtreecommitdiff
path: root/pkg
diff options
context:
space:
mode:
authorSanto Cariotti <santo@dcariotti.me>2025-04-08 14:37:33 +0200
committerSanto Cariotti <santo@dcariotti.me>2025-04-08 14:39:13 +0200
commit1f0d9ec8452f15c27cd33c4e3874454c35993743 (patch)
treec453a31ae5eb823aaf48868eea9fc4daf65f108b /pkg
parentc5b10e28b358308d8349b940af09f64368172f2e (diff)
Use internal/pkg structure
Diffstat (limited to 'pkg')
-rw-r--r--pkg/ui/views/auth.go590
-rw-r--r--pkg/ui/views/play.go427
-rw-r--r--pkg/ui/views/tabs.go32
-rw-r--r--pkg/ui/views/views.go144
-rw-r--r--pkg/utils.go35
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)
- }
-}