diff --git a/tui/auth.go b/tui/auth.go new file mode 100644 index 0000000..15eb534 --- /dev/null +++ b/tui/auth.go @@ -0,0 +1,8 @@ +package main + +import huh "github.com/charmbracelet/huh" + +type Auth struct { + form *huh.Form + jwt *string +} diff --git a/tui/entry.go b/tui/entry.go index 0f4f828..b5adbb6 100644 --- a/tui/entry.go +++ b/tui/entry.go @@ -1,18 +1,7 @@ package main import ( - "fmt" - "log" - "net/http" - "os" - "strings" "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - huh "github.com/charmbracelet/huh" ) type Feed struct { @@ -37,194 +26,3 @@ type Entry struct { isReadLater bool feed Feed } - -type statusMsg int - -type errMsg struct{ error } - -func (e errMsg) Error() string { return e.error.Error() } - -func (e Entry) FilterValue() string { - return e.title -} - -func (e Entry) Title() string { - return e.title -} - -func (e Entry) Description() string { - return fmt.Sprintf("%s", "my desc") // TODO: real description -} - -type Model struct { - list list.Model - textInput textinput.Model - err error -} - -func New() *Model { - ti := textinput.New() - ti.Placeholder = "Pikachu" - ti.Focus() - ti.CharLimit = 156 - ti.Width = 56 - return &Model{textInput: ti} -} - -func (m *Model) initList(width int, height int) { - m.list = list.New([]list.Item{}, list.NewDefaultDelegate(), width, height) - m.list.Title = "Posts" - m.list.SetFilteringEnabled(false) - var f = Feed{id: "1", tags: []string{"Devops", "Kubernetes"}, name: "zwindler", url: "zwindler.blog", faviconUrl: "zwindler.blog.favicon"} - m.list.SetItems([]list.Item{ - Entry{id: "1", title: "yay", content: "ouin ouin ouin", link: "awd", date: time.Now(), isRead: false, isIgnored: false, isReadLater: false, isBookmarked: false, feed: f}, - Entry{id: "2", title: "grrrrr", content: "ouin ouin ouin", link: "awd", date: time.Now(), isRead: false, isIgnored: false, isReadLater: false, isBookmarked: false, feed: f}, - Entry{id: "3", title: "my life is pain", content: "ouin ouin ouin", link: "awd", date: time.Now(), isRead: false, isIgnored: false, isReadLater: false, isBookmarked: false, feed: f}, - }) -} - -func (m Model) Init() tea.Cmd { - return checkServer -} - -func (m Model) handleSearchCompletion() (Model, tea.Cmd) { - var cmd tea.Cmd - queryWords := strings.Split(m.textInput.Value(), " ") - if len(queryWords) == 0 { - return m, cmd - } - lastWord := queryWords[len(queryWords)-1] - if lastWord == "tag:" { - var feed string - huh.NewSelect[string](). - Title("Pick a feed."). - Options( - huh.NewOption("United States", "US"), - huh.NewOption("Germany", "DE"), - huh.NewOption("Brazil", "BR"), - huh.NewOption("Canada", "CA"), - ). - Value(&feed).Run() - m.textInput.SetValue(m.textInput.Value() + feed) - m.textInput.CursorEnd() - } - - if lastWord == "feed:" { - var feed string - var feeds = []string{"Devops", "System", "Angular"} - tea.LogToFile("yay.log", "") - log.Print("input") - huh.NewSelect[string](). - Title("Pick a feed."). - Options( - huh.NewOptions(feeds...)..., - ). - Value(&feed).Run() - m.textInput.SetValue(m.textInput.Value() + feed) - m.textInput.CursorEnd() - } - return m, cmd -} - -func (m *Model) deleteWordBackward() { - if m.textInput.Position() == 0 || len(m.textInput.Value()) == 0 { - return - } - - // TODO: wtf are other echo modes, dont care - //if m.textInput.EchoMode != textinput.EchoNormal { - // m.deleteBeforeCursor() - // return - //} - - // Linter note: it's critical that we acquire the initial cursor position - // here prior to altering it via SetCursor() below. As such, moving this - // call into the corresponding if clause does not apply here. - oldPos := m.textInput.Position() //nolint:ifshort - - m.textInput.SetCursor(oldPos - 1) - // ECHO character? - for m.textInput.Value()[m.textInput.Position()] == ' ' { - if m.textInput.Position() <= 0 { - break - } - // ignore series of whitespace before cursor - m.textInput.SetCursor(m.textInput.Position() - 1) - } - - for m.textInput.Position() > 0 { - if m.textInput.Value()[m.textInput.Position()] != ' ' { - m.textInput.SetCursor(m.textInput.Position() - 1) - } else { - if m.textInput.Position() > 0 { - // keep the previous space - m.textInput.SetCursor(m.textInput.Position() + 1) - } - break - } - } - - if oldPos > len(m.textInput.Value()) { - m.textInput.SetValue(m.textInput.Value()[:m.textInput.Position()]) - } else { - m.textInput.SetValue(m.textInput.Value()[:m.textInput.Position()] + m.textInput.Value()[oldPos:]) - } -} - -const url = "localhost:3000" - -func checkServer() tea.Msg { - c := &http.Client{ - Timeout: 10 * time.Second, - } - res, err := c.Get(url) - if err != nil { - return errMsg{err} - } - defer res.Body.Close() // nolint:errcheck - - return statusMsg(res.StatusCode) - -} - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.initList(msg.Width, msg.Height) - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc: - return m, tea.Quit - } - switch { - case key.Matches(msg, m.textInput.KeyMap.DeleteCharacterBackward): - words := strings.Split(m.textInput.Value(), " ") - if len(words) > 0 && (strings.HasPrefix(words[len(words)-1], "tag:") || strings.HasPrefix(words[len(words)-1], "feed:")) { - m.deleteWordBackward() - } - } - // if writing - } - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - m.textInput, cmd = m.textInput.Update(msg) - switch msg := msg.(type) { - case tea.KeyMsg: - _ = msg - m, cmd = m.handleSearchCompletion() - } - - return m, cmd -} - -func (m Model) View() string { - return m.textInput.View() + m.list.View() -} - -func main() { - m := New() - p := tea.NewProgram(m) - if _, err := p.Run(); err != nil { - os.Exit(1) - } -} diff --git a/tui/http.go b/tui/http.go index e45d377..6e75000 100644 --- a/tui/http.go +++ b/tui/http.go @@ -9,38 +9,63 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +type statusMsg int + +type errMsg struct{ error } +type missingJwtMsg struct{} + +func (e errMsg) Error() string { return e.error.Error() } + type getEntriesSuccessMsg []Entry type getEntriesErrMsg error const serverUrl = "localhost:3000" -func getEntries() tea.Msg { - url := fmt.Sprintf("%s/entries", serverUrl) +type loginSuccessMsg struct{ string } - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return getEntriesErrMsg(err) +func login(username string, password string) tea.Cmd { + return func() tea.Msg { + _ = username + _ = password + return loginSuccessMsg{"dawdaw"} + } +} + +func getEntries(jwt *string) tea.Cmd { + return func() tea.Msg { + url := fmt.Sprintf("%s/entries", serverUrl) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if jwt == nil { + return missingJwtMsg{} + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", *jwt)) + + if err != nil { + return getEntriesErrMsg(err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return getEntriesErrMsg(err) + } + defer resp.Body.Close() // nolint: errcheck + + data, err := io.ReadAll(resp.Body) + if err != nil { + return getEntriesErrMsg(err) + } + + var entries []Entry + + err = json.Unmarshal(data, &entries) + if err != nil { + return getEntriesErrMsg(err) + } + + return getEntriesSuccessMsg(entries) } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return getEntriesErrMsg(err) - } - defer resp.Body.Close() // nolint: errcheck - - data, err := io.ReadAll(resp.Body) - if err != nil { - return getEntriesErrMsg(err) - } - - var entries []Entry - - err = json.Unmarshal(data, &entries) - if err != nil { - return getEntriesErrMsg(err) - } - - return getEntriesSuccessMsg(entries) } type getFeedsSuccessMsg []Feed diff --git a/tui/main.go b/tui/main.go new file mode 100644 index 0000000..b690ced --- /dev/null +++ b/tui/main.go @@ -0,0 +1,249 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + huh "github.com/charmbracelet/huh" +) + +func (e Entry) FilterValue() string { + return e.title +} + +func (e Entry) Title() string { + return e.title +} + +func (e Entry) Description() string { + return fmt.Sprintf("%s", "my desc") // TODO: real description +} + +type Model struct { + list list.Model + textInput textinput.Model + err error + auth Auth + page VexPage + query string + feeds []Feed + entries []Entry + tags []string +} + +func New() *Model { + loginForm := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Username"). + Key("username"), + // Validating fields is easy. The form will mark erroneous fields + // and display error messages accordingly. + // TODO: + // Validate(func(str string) error { + // if str == "octopus773" { + // return errors.New("Not you") + // } + // return nil + // }))) + huh.NewInput(). + Title("Password"). + Key("password"). + Password(true), + )) + ti := textinput.New() + ti.Placeholder = "Pikachu" + ti.Focus() + ti.CharLimit = 156 + ti.Width = 56 + return &Model{textInput: ti, auth: Auth{form: loginForm, jwt: new(string)}, page: "LOGIN"} +} + +func (m Model) getEverything() tea.Cmd { + return func() tea.Msg { + return tea.Batch(getEntries(m.auth.jwt)) // getTags, getFeeds) + } +} + +func (m *Model) initList(width int, height int) { + m.list = list.New([]list.Item{}, list.NewDefaultDelegate(), width, height) + m.list.Title = "Posts" + m.list.SetFilteringEnabled(false) + var f = Feed{id: "1", tags: []string{"Devops", "Kubernetes"}, name: "zwindler", url: "zwindler.blog", faviconUrl: "zwindler.blog.favicon"} + m.list.SetItems([]list.Item{ + Entry{id: "1", title: "yay", content: "ouin ouin ouin", link: "awd", date: time.Now(), isRead: false, isIgnored: false, isReadLater: false, isBookmarked: false, feed: f}, + Entry{id: "2", title: "grrrrr", content: "ouin ouin ouin", link: "awd", date: time.Now(), isRead: false, isIgnored: false, isReadLater: false, isBookmarked: false, feed: f}, + Entry{id: "3", title: "my life is pain", content: "ouin ouin ouin", link: "awd", date: time.Now(), isRead: false, isIgnored: false, isReadLater: false, isBookmarked: false, feed: f}, + }) +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(checkServer, m.auth.form.Init()) + +} + +func (m Model) handleSearchCompletion() (Model, tea.Cmd) { + var cmd tea.Cmd + queryWords := strings.Split(m.textInput.Value(), " ") + if len(queryWords) == 0 { + return m, cmd + } + lastWord := queryWords[len(queryWords)-1] + if lastWord == "tag:" { + var feed string + huh.NewSelect[string](). + Title("Pick a feed."). + Options( + huh.NewOption("United States", "US"), + huh.NewOption("Germany", "DE"), + huh.NewOption("Brazil", "BR"), + huh.NewOption("Canada", "CA"), + ). + Value(&feed).Run() + m.textInput.SetValue(m.textInput.Value() + feed) + m.textInput.CursorEnd() + } + + if lastWord == "feed:" { + var feed string + var feeds = []string{"Devops", "System", "Angular"} + huh.NewSelect[string](). + Title("Pick a feed."). + Options( + huh.NewOptions(feeds...)..., + ). + Value(&feed).Run() + m.textInput.SetValue(m.textInput.Value() + feed) + m.textInput.CursorEnd() + } + return m, cmd +} + +func (m *Model) deleteWordBackward() { + if m.textInput.Position() == 0 || len(m.textInput.Value()) == 0 { + return + } + + // TODO: wtf are other echo modes, dont care + //if m.textInput.EchoMode != textinput.EchoNormal { + // m.deleteBeforeCursor() + // return + //} + + // Linter note: it's critical that we acquire the initial cursor position + // here prior to altering it via SetCursor() below. As such, moving this + // call into the corresponding if clause does not apply here. + oldPos := m.textInput.Position() //nolint:ifshort + + m.textInput.SetCursor(oldPos - 1) + // ECHO character? + for m.textInput.Value()[m.textInput.Position()] == ' ' { + if m.textInput.Position() <= 0 { + break + } + // ignore series of whitespace before cursor + m.textInput.SetCursor(m.textInput.Position() - 1) + } + + for m.textInput.Position() > 0 { + if m.textInput.Value()[m.textInput.Position()] != ' ' { + m.textInput.SetCursor(m.textInput.Position() - 1) + } else { + if m.textInput.Position() > 0 { + // keep the previous space + m.textInput.SetCursor(m.textInput.Position() + 1) + } + break + } + } + + if oldPos > len(m.textInput.Value()) { + m.textInput.SetValue(m.textInput.Value()[:m.textInput.Position()]) + } else { + m.textInput.SetValue(m.textInput.Value()[:m.textInput.Position()] + m.textInput.Value()[oldPos:]) + } +} + +const url = "localhost:3000" + +func checkServer() tea.Msg { + c := &http.Client{ + Timeout: 10 * time.Second, + } + res, err := c.Get(url) + if err != nil { + return errMsg{err} + } + defer res.Body.Close() // nolint:errcheck + + return statusMsg(res.StatusCode) + +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.initList(msg.Width, msg.Height) + case loginSuccessMsg: + *m.auth.jwt = msg.string + m.page = FEEDS + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + } + switch { + case key.Matches(msg, m.textInput.KeyMap.DeleteCharacterBackward): + words := strings.Split(m.textInput.Value(), " ") + if len(words) > 0 && (strings.HasPrefix(words[len(words)-1], "tag:") || strings.HasPrefix(words[len(words)-1], "feed:")) { + m.deleteWordBackward() + } + } + // if writing + } + var cmds []tea.Cmd + + // Process the form + form, cmd := m.auth.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.auth.form = f + cmds = append(cmds, cmd) + } + + if m.auth.form.State == huh.StateCompleted { + // Quit when the form is done. + username := m.auth.form.GetString("username") + password := m.auth.form.GetString("password") + cmds = append(cmds, login(username, password)) + } + + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + m.textInput, cmd = m.textInput.Update(msg) + cmds = append(cmds, cmd) + switch msg := msg.(type) { + case tea.KeyMsg: + _ = msg + m, cmd = m.handleSearchCompletion() + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func main() { + tea.LogToFile("yay.log", "") + m := New() + p := tea.NewProgram(m) + if _, err := p.Run(); err != nil { + os.Exit(1) + } +} diff --git a/tui/pages.go b/tui/pages.go new file mode 100644 index 0000000..ee62991 --- /dev/null +++ b/tui/pages.go @@ -0,0 +1,36 @@ +package main + +const ( + LOGIN = "LOGIN" + ENTRIES = "ENTRIES" + FEEDS = "FEEDS" + TAGS = "TAGS" +) + +type VexPage string + +func (m Model) LoginView() string { + return m.auth.form.View() +} +func (m Model) EntriesView() string { + return "" +} +func (m Model) FeedsView() string { + return "feeds" +} +func (m Model) TagsView() string { + return "" +} +func (m Model) View() string { + switch m.page { + case LOGIN: + return m.LoginView() + case ENTRIES: + return m.EntriesView() + case FEEDS: + return m.FeedsView() + case TAGS: + return m.TagsView() + } + return m.textInput.View() + m.list.View() +}