diff --git a/tui/.gitignore b/tui/.gitignore new file mode 100644 index 0000000..9239013 --- /dev/null +++ b/tui/.gitignore @@ -0,0 +1 @@ +vex.log diff --git a/tui/http.go b/tui/cmd/http.go similarity index 64% rename from tui/http.go rename to tui/cmd/http.go index 1dd4404..26f24c6 100644 --- a/tui/http.go +++ b/tui/cmd/http.go @@ -9,6 +9,8 @@ import ( "net/http" tea "github.com/charmbracelet/bubbletea" + "github.com/google/uuid" + "github.com/zoryia/vex/tui/models" ) type statusMsg int @@ -133,12 +135,16 @@ func register(username string, password string, email string) tea.Cmd { } } -type getEntriesSuccessMsg []Entry +type getEntriesSuccessMsg []models.Entry func getEntries(jwt *string) tea.Cmd { return func() tea.Msg { url := fmt.Sprintf("%s/entries", serverUrl) - req, _ := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + log.Print(err) + return http.ErrMissingBoundary + } if jwt == nil { return missingJwtMsg{} } @@ -146,18 +152,20 @@ func getEntries(jwt *string) tea.Cmd { req.Header.Add("Content-type", "application/json") data, err := getData(req) if err != nil { + log.Print(err) return httpErrorMsg(err) } - var entries []Entry + var entries []models.Entry err = json.Unmarshal(data, &entries) if err != nil { + log.Print(err) return httpErrorMsg(err) } return getEntriesSuccessMsg(entries) } } -type getFeedsSuccessMsg []Feed +type getFeedsSuccessMsg []models.Feed func getFeeds(jwt *string) tea.Cmd { return func() tea.Msg { @@ -171,7 +179,7 @@ func getFeeds(jwt *string) tea.Cmd { if err != nil { return httpErrorMsg(err) } - var feeds []Feed + var feeds []models.Feed err = json.Unmarshal(data, &feeds) if err != nil { return httpErrorMsg(err) @@ -179,3 +187,82 @@ func getFeeds(jwt *string) tea.Cmd { return getFeedsSuccessMsg(feeds) } } + +type addFeedSuccessMsg struct{} + +func addFeed(jwt *string, link string, tags []string) tea.Cmd { + return func() tea.Msg { + + url := fmt.Sprintf("%s/feeds", serverUrl) + body := struct { + Link string `json:"link"` + Tags []string `json:"tags"` + }{ + Link: link, Tags: tags, + } + out, err := json.Marshal(body) + if err != nil { + log.Fatal(err) + } + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(out)) + req.Header.Add("Content-type", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", *jwt)) + data, err := getData(req) + if err != nil { + return err + } + var addFeedResp AuthRes + err = json.Unmarshal(data, &addFeedResp) + if err != nil { + return httpErrorMsg(err) + } + return addFeedSuccessMsg{} + } +} + +type ignorePostSuccessMsg uuid.UUID + +func ignorePost(jwt *string, e models.Entry) tea.Cmd { + return func() tea.Msg { + url := fmt.Sprintf("%s/ignore/%s", serverUrl, e.Id.String()) + req, _ := http.NewRequest(http.MethodPut, url, nil) + if jwt == nil { + return missingJwtMsg{} + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", *jwt)) + data, err := getData(req) + if err != nil { + return httpErrorMsg(err) + } + var feeds []models.Feed + err = json.Unmarshal(data, &feeds) + if err != nil { + return httpErrorMsg(err) + } + return getFeedsSuccessMsg(feeds) + } +} + +type toggleReadSuccessMsg uuid.UUID + +func toggleRead(jwt *string, e models.Entry) tea.Cmd { + return func() tea.Msg { + return nil + } +} + +type toggleReadLaterSuccessMsg uuid.UUID + +func toggleReadLater(jwt *string, e models.Entry) tea.Cmd { + return func() tea.Msg { + return nil + } +} + +type toggleBookmarkSuccessMsg uuid.UUID + +func toggleBookmark(jwt *string, e models.Entry) tea.Cmd { + return func() tea.Msg { + return nil + } +} diff --git a/tui/cmd/keymaps.go b/tui/cmd/keymaps.go new file mode 100644 index 0000000..0f9f410 --- /dev/null +++ b/tui/cmd/keymaps.go @@ -0,0 +1,52 @@ +package main + +import "github.com/charmbracelet/bubbles/key" + +type ListKeyMap struct { + Query key.Binding + BookmarkToggle key.Binding + ReadToggle key.Binding + ReadLaterToggle key.Binding + IgnoreToggle key.Binding + PreviewPost key.Binding + GoToFeeds key.Binding + GoToPosts key.Binding +} + +func NewListKeyMap() *ListKeyMap { + return &ListKeyMap{ + GoToFeeds: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "go to feeds"), + ), + GoToPosts: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "go to posts"), + ), + PreviewPost: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "preview post"), + ), + Query: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "query posts"), + ), + BookmarkToggle: key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "toggle bookmarked"), + ), + ReadToggle: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "toggle mark as read"), + ), + + IgnoreToggle: key.NewBinding( + key.WithKeys("x", "d"), + key.WithHelp("d/x", "ignore post"), + ), + ReadLaterToggle: key.NewBinding( + key.WithKeys("m"), + key.WithHelp("m", "add to read later"), + ), + } +} diff --git a/tui/cmd/main.go b/tui/cmd/main.go new file mode 100644 index 0000000..8e2368a --- /dev/null +++ b/tui/cmd/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "os" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/google/uuid" + . "github.com/zoryia/vex/tui/models" + . "github.com/zoryia/vex/tui/pages" + "github.com/zoryia/vex/tui/pages/auth" + "github.com/zoryia/vex/tui/pages/feeds" + "github.com/zoryia/vex/tui/pages/preview" +) + +type Model struct { + // entries + list list.Model + queryInput textinput.Model + + err error + page VexPage + tags []string + keys *ListKeyMap + + Preview preview.Model + Feeds feeds.Model + Auth auth.Model +} + +func queryInput() textinput.Model { + ti := textinput.New() + ti.Placeholder = "Search Query" + ti.CharLimit = 156 + ti.Width = 56 + return ti + +} + +func New() *Model { + return &Model{ + queryInput: queryInput(), + page: LOGIN, + keys: NewListKeyMap(), + + Preview: preview.Model{Viewport: viewport.New(0, 0)}, + Feeds: feeds.New(), + Auth: auth.New(), + } +} + +func (m Model) getEverything() tea.Cmd { + 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) + m.list.DisableQuitKeybindings() + m.list.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + m.keys.Query, + m.keys.PreviewPost, + } + } + m.list.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + m.keys.GoToFeeds, + m.keys.ReadToggle, + m.keys.ReadLaterToggle, + m.keys.BookmarkToggle, + m.keys.IgnoreToggle, + m.keys.Query, + m.keys.PreviewPost, + } + } + var f = Feed{Id: uuid.UUID{}, Tags: []string{"Devops", "Kubernetes"}, Name: "zwindler", Url: "zwindler.blog", FaviconUrl: "zwindler.blog.favicon"} + m.list.SetItems([]list.Item{ + Entry{Id: uuid.UUID{}, ArticleTitle: "yay", Content: "ouin ouin ouinouin ouin ouinouin ouin ouinouin ouin ouinouin ouin ouinouin ouin ouinouin ouin ouinouin ouin ouinouin ouin ouinouin ouin ouin", Link: "awd", Date: time.Now(), IsRead: false, IsIgnored: false, IsReadLater: false, IsBookmarked: false, Feed: f}, + Entry{Id: uuid.UUID{}, ArticleTitle: "grrrrr", Content: "ouin ouin ouin", Link: "awd", Date: time.Now(), IsRead: false, IsIgnored: false, IsReadLater: false, IsBookmarked: false, Feed: f}, + Entry{Id: uuid.UUID{}, ArticleTitle: "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( + m.Auth.LoginForm.Init(), + m.Auth.RegisterForm.Init(), + checkJwt(m.Auth.Jwt), + ) +} + +func main() { + tea.LogToFile("vex.log", "") + m := New() + p := tea.NewProgram(m, + tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer" + tea.WithMouseCellMotion(), + ) + if _, err := p.Run(); err != nil { + os.Exit(1) + } +} diff --git a/tui/cmd/search.go b/tui/cmd/search.go new file mode 100644 index 0000000..c7aa446 --- /dev/null +++ b/tui/cmd/search.go @@ -0,0 +1,90 @@ +package main + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + huh "github.com/charmbracelet/huh" +) + +func (m Model) handleSearchCompletion() (Model, tea.Cmd) { + var cmd tea.Cmd + queryWords := strings.Split(m.queryInput.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.queryInput.SetValue(m.queryInput.Value() + feed) + m.queryInput.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.queryInput.SetValue(m.queryInput.Value() + feed) + m.queryInput.CursorEnd() + } + return m, cmd +} + +func (m *Model) deleteWordBackward() { + if m.queryInput.Position() == 0 || len(m.queryInput.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.queryInput.Position() //nolint:ifshort + + m.queryInput.SetCursor(oldPos - 1) + // ECHO character? + for m.queryInput.Value()[m.queryInput.Position()] == ' ' { + if m.queryInput.Position() <= 0 { + break + } + // ignore series of whitespace before cursor + m.queryInput.SetCursor(m.queryInput.Position() - 1) + } + + for m.queryInput.Position() > 0 { + if m.queryInput.Value()[m.queryInput.Position()] != ' ' { + m.queryInput.SetCursor(m.queryInput.Position() - 1) + } else { + if m.queryInput.Position() > 0 { + // keep the previous space + m.queryInput.SetCursor(m.queryInput.Position() + 1) + } + break + } + } + + if oldPos > len(m.queryInput.Value()) { + m.queryInput.SetValue(m.queryInput.Value()[:m.queryInput.Position()]) + } else { + m.queryInput.SetValue(m.queryInput.Value()[:m.queryInput.Position()] + m.queryInput.Value()[oldPos:]) + } +} diff --git a/tui/cmd/update.go b/tui/cmd/update.go new file mode 100644 index 0000000..228b465 --- /dev/null +++ b/tui/cmd/update.go @@ -0,0 +1,247 @@ +package main + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + huh "github.com/charmbracelet/huh" + . "github.com/zoryia/vex/tui/models" + . "github.com/zoryia/vex/tui/pages" + "github.com/zoryia/vex/tui/pages/feeds" +) + +func (m Model) LoginUpdate(msg tea.Msg) (tea.Model, []tea.Cmd) { + var cmds []tea.Cmd + if m.page != LOGIN { + return m, nil + } + return m, cmds +} + +func (m Model) GlobalUpdate(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.initList(msg.Width, msg.Height-2) + m.queryInput.Width = msg.Width - 5 + m.Preview.Viewport.Width = msg.Width + m.Preview.Viewport.Height = msg.Height - m.Preview.VerticalMarginHeight() + m.Feeds.List.SetWidth(msg.Width) + m.Feeds.List.SetHeight(msg.Height) + + case getEntriesSuccessMsg: + var entries []list.Item + for _, e := range msg { + entries = append(entries, e) + } + m.list.SetItems(entries) + + case invalidJwtMsg: + m.Auth.Jwt = new(string) + m.page = LOGIN + return m, nil + + case loginSuccessMsg: + *m.Auth.Jwt = msg.string + m.page = ENTRIES + return m, m.getEverything() + + case registerSuccessMsg: + *m.Auth.Jwt = msg.string + m.page = ENTRIES + return m, m.getEverything() + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + } + } + return m, nil +} + +func loginUpdate(m Model, msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlT: + m.page = REGISTER + } + } + form, cmd := m.Auth.LoginForm.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.Auth.LoginForm = f + cmds = append(cmds, cmd) + } + + if m.Auth.LoginForm.State == huh.StateCompleted { + username := m.Auth.LoginForm.GetString("email") + password := m.Auth.LoginForm.GetString("password") + cmds = append(cmds, login(username, password)) + } + + return m, tea.Batch(cmds...) +} +func registerUpdate(m Model, msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlT: + m.page = LOGIN + } + } + + // Process the form + // LOGIN + registerForm, cmd := m.Auth.RegisterForm.Update(msg) + if f, ok := registerForm.(*huh.Form); ok { + m.Auth.RegisterForm = f + cmds = append(cmds, cmd) + } + + if m.Auth.RegisterForm.State == huh.StateCompleted { + username := m.Auth.RegisterForm.GetString("username") + password := m.Auth.RegisterForm.GetString("password") + email := m.Auth.RegisterForm.GetString("email") + cmds = append(cmds, register(username, password, email)) + } + + return m, tea.Batch(cmds...) +} +func entriesUpdate(m Model, msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + var blurredNow = false + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + if m.queryInput.Focused() { + // Get entries with query + m.queryInput.Blur() + blurredNow = true + } + } + switch { + case key.Matches(msg, m.queryInput.KeyMap.DeleteCharacterBackward) && m.queryInput.Focused(): + words := strings.Split(m.queryInput.Value(), " ") + if len(words) > 0 && (strings.HasPrefix(words[len(words)-1], "tag:") || strings.HasPrefix(words[len(words)-1], "feed:")) { + m.deleteWordBackward() + } + } + if m.queryInput.Focused() == false { + switch { + case key.Matches(msg, m.keys.GoToFeeds): + m.page = FEEDS + + case key.Matches(msg, m.keys.IgnoreToggle): + var e = m.list.SelectedItem().(Entry) + cmds = append(cmds, ignorePost(m.Auth.Jwt, e)) + + case key.Matches(msg, m.keys.ReadToggle): + var e = m.list.SelectedItem().(Entry) + cmds = append(cmds, toggleRead(m.Auth.Jwt, e)) + + case key.Matches(msg, m.keys.ReadLaterToggle): + var e = m.list.SelectedItem().(Entry) + cmds = append(cmds, toggleReadLater(m.Auth.Jwt, e)) + + case key.Matches(msg, m.keys.BookmarkToggle): + var e = m.list.SelectedItem().(Entry) + cmds = append(cmds, toggleBookmark(m.Auth.Jwt, e)) + + case key.Matches(msg, m.keys.Query): + m.queryInput.Focus() + m.queryInput.SetValue("") + + case key.Matches(msg, m.keys.PreviewPost) && m.queryInput.Focused() == false && blurredNow == false: + var e = m.list.SelectedItem().(Entry) + m.Preview.Entry = e + m.Preview.Viewport.SetContent(e.Content) + m.page = PREVIEW + + } + } + m, cmd = m.handleSearchCompletion() + cmds = append(cmds, cmd) + } + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + m.queryInput, cmd = m.queryInput.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} +func feedsUpdate(m Model, msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, feeds.FeedsKeyMaps().AddFeed): + m.Feeds.AddFeed.Run() + } + } + registerForm, cmd := m.Auth.RegisterForm.Update(msg) + if f, ok := registerForm.(*huh.Form); ok { + m.Auth.RegisterForm = f + cmds = append(cmds, cmd) + } + + if m.Auth.RegisterForm.State == huh.StateCompleted { + url := m.Auth.RegisterForm.GetString("url") + tags := m.Auth.RegisterForm.Get("tags").([]string) + cmds = append(cmds, addFeed(m.Auth.Jwt, url, tags)) + } + m.Feeds.List, cmd = m.Feeds.List.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} +func tagsUpdate(m Model, msg tea.Msg) (Model, tea.Cmd) { + return m, nil +} +func previewUpdate(m Model, msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + m.Preview.Viewport, cmd = m.Preview.Viewport.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} +func ignoredUpdate(m Model, msg tea.Msg) (Model, tea.Cmd) { + return m, nil +} + +type updateFunc func(Model, tea.Msg) (Model, tea.Cmd) + +func getUpdateMap() map[VexPage]updateFunc { + + updateMap := make(map[VexPage]updateFunc) + updateMap[LOGIN] = loginUpdate + updateMap[REGISTER] = registerUpdate + updateMap[ENTRIES] = entriesUpdate + updateMap[FEEDS] = feedsUpdate + updateMap[TAGS] = tagsUpdate + updateMap[IGNORED] = ignoredUpdate + updateMap[PREVIEW] = previewUpdate + return updateMap +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + updateMap := getUpdateMap() + var cmds []tea.Cmd + var cmd tea.Cmd + + m, cmd = m.GlobalUpdate(msg) + cmds = append(cmds, cmd) + m, cmd = updateMap[m.page](m, msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} diff --git a/tui/pages.go b/tui/cmd/views.go similarity index 53% rename from tui/pages.go rename to tui/cmd/views.go index ea11a64..0f113ca 100644 --- a/tui/pages.go +++ b/tui/cmd/views.go @@ -1,33 +1,22 @@ package main import ( - "fmt" - "github.com/charmbracelet/lipgloss" + . "github.com/zoryia/vex/tui/pages" ) -const ( - LOGIN = "LOGIN" - REGISTER = "REGISTER" - ENTRIES = "ENTRIES" - FEEDS = "FEEDS" - TAGS = "TAGS" -) - -type VexPage string - -func (m Model) LoginView() string { +func (m Model) AuthView() string { return lipgloss.JoinHorizontal( lipgloss.Left, - m.auth.loginForm.View(), - m.auth.registerForm.View(), + m.Auth.LoginForm.View(), + m.Auth.RegisterForm.View(), ) } func (m Model) EntriesView() string { - return "" + return lipgloss.JoinVertical(lipgloss.Left, m.queryInput.View(), m.list.View()) } func (m Model) FeedsView() string { - return fmt.Sprintf("%s ", *m.auth.jwt) + return m.Feeds.View() } func (m Model) TagsView() string { return "" @@ -35,15 +24,17 @@ func (m Model) TagsView() string { func (m Model) View() string { switch m.page { case LOGIN: - return m.LoginView() + return m.AuthView() case REGISTER: - return m.LoginView() + return m.AuthView() case ENTRIES: return m.EntriesView() case FEEDS: return m.FeedsView() case TAGS: return m.TagsView() + case PREVIEW: + return m.Preview.View() } - return m.textInput.View() + m.list.View() + return "Really unexpected state, get help" } diff --git a/tui/entry.go b/tui/entry.go deleted file mode 100644 index 6af4fca..0000000 --- a/tui/entry.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import ( - "time" -) - -type Feed struct { - Id string `json:"id"` - Name string `json:"name"` - Url string `json:"url"` - FaviconUrl string `json:"faviconUrl"` - Tags []string `json:"tags"` -} - -type Entry struct { - Id string `json:"id"` - ArticleTitle string `json:"title"` - Content string `json:"content"` - Link string `json:"link"` - Date time.Time `json:"time"` - - Author *string `json:"author"` // author not always specified - IsRead bool `json:"isRead"` - IsBookmarked bool `json:"IsBookmarked"` - IsIgnored bool `json:"isIgnored"` - IsReadLater bool `json:"isReadLater"` - Feed Feed `json:"feed"` -} diff --git a/tui/go.mod b/tui/go.mod index 04012b9..c199caa 100644 --- a/tui/go.mod +++ b/tui/go.mod @@ -1,31 +1,43 @@ -module vex.tui +module github.com/zoryia/vex/tui go 1.22.2 require ( + github.com/badoux/checkmail v1.2.4 github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.26.1 + github.com/charmbracelet/glamour v0.7.0 github.com/charmbracelet/huh v0.3.0 + github.com/charmbracelet/lipgloss v0.10.0 + github.com/google/uuid v1.6.0 ) require ( + github.com/alecthomas/chroma/v2 v2.8.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/lipgloss v0.10.0 // indirect + github.com/dlclark/regexp2 v1.4.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.0 // 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.15 // indirect + github.com/microcosm-cc/bluemonday v1.0.25 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect + github.com/yuin/goldmark v1.5.4 // indirect + github.com/yuin/goldmark-emoji v1.0.2 // indirect + golang.org/x/net v0.21.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/term v0.19.0 // indirect - golang.org/x/text v0.13.0 // indirect + golang.org/x/text v0.14.0 // indirect ) diff --git a/tui/go.sum b/tui/go.sum index 319a10c..bc59410 100644 --- a/tui/go.sum +++ b/tui/go.sum @@ -1,19 +1,39 @@ +github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink= +github.com/alecthomas/assert/v2 v2.2.1/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= +github.com/alecthomas/chroma/v2 v2.8.0 h1:w9WJUjFFmHHB2e8mRpL9jjy3alYDlU0QLDezj1xE264= +github.com/alecthomas/chroma/v2 v2.8.0/go.mod h1:yrkMI9807G1ROx13fhe1v6PN2DDeaR73L3d+1nmYQtw= +github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= +github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/badoux/checkmail v1.2.4 h1:4zMjdYDjE2Q7xF06VNfyN8P9JGU7epLjNb+Yu5OThVI= +github.com/badoux/checkmail v1.2.4/go.mod h1:XroCOBU5zzZJcLvgwU15I+2xXyCdTWXyR9MGfRhBYy0= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= github.com/charmbracelet/bubbletea v0.26.1 h1:xujcQeF73rh4jwu3+zhfQsvV18x+7zIjlw7/CYbzGJ0= github.com/charmbracelet/bubbletea v0.26.1/go.mod h1:FzKr7sKoO8iFVcdIBM9J0sJOcQv5nDQaYwsee3kpbgo= +github.com/charmbracelet/glamour v0.7.0 h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng= +github.com/charmbracelet/glamour v0.7.0/go.mod h1:jUMh5MeihljJPQbJ/wf4ldw2+yBP59+ctV36jASy7ps= github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE= github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA= github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= +github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -22,9 +42,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE 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.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg= +github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE= 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= @@ -33,12 +56,21 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= +github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= +github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -47,5 +79,5 @@ golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/tui/main.go b/tui/main.go deleted file mode 100644 index 7f3b856..0000000 --- a/tui/main.go +++ /dev/null @@ -1,268 +0,0 @@ -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.ArticleTitle -} - -func (e Entry) Title() string { - return e.ArticleTitle -} - -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 { - ti := textinput.New() - ti.Placeholder = "Pikachu" - ti.Focus() - ti.CharLimit = 156 - ti.Width = 56 - return &Model{textInput: ti, auth: Auth{loginForm: getLoginForm(), registerForm: getRegisterForm(), 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", ArticleTitle: "yay", Content: "ouin ouin ouin", Link: "awd", Date: time.Now(), IsRead: false, IsIgnored: false, IsReadLater: false, IsBookmarked: false, Feed: f}, - Entry{Id: "2", ArticleTitle: "grrrrr", Content: "ouin ouin ouin", Link: "awd", Date: time.Now(), IsRead: false, IsIgnored: false, IsReadLater: false, IsBookmarked: false, Feed: f}, - Entry{Id: "3", ArticleTitle: "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.loginForm.Init(), m.auth.registerForm.Init(), checkJwt(m.auth.jwt)) - -} - -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 invalidJwtMsg: - m.auth.jwt = new(string) - m.page = LOGIN - return m, nil - case loginSuccessMsg: - *m.auth.jwt = msg.string - m.page = FEEDS - return m, m.getEverything() - - case registerSuccessMsg: - *m.auth.jwt = msg.string - m.page = FEEDS - return m, m.getEverything() - - case tea.KeyMsg: - switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: - return m, tea.Quit - case tea.KeyCtrlT: - if m.page == LOGIN { - m.page = REGISTER - } else if m.page == REGISTER { - m.page = LOGIN - } - - } - switch { - case key.Matches(msg, m.textInput.KeyMap.DeleteCharacterBackward): //TODO: add only when query - 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() - } - } - } - var cmds []tea.Cmd - - // Process the form - // LOGIN - if m.page == LOGIN { - form, cmd := m.auth.loginForm.Update(msg) - if f, ok := form.(*huh.Form); ok { - m.auth.loginForm = f - cmds = append(cmds, cmd) - } - - if m.auth.loginForm.State == huh.StateCompleted { - username := m.auth.loginForm.GetString("email") - password := m.auth.loginForm.GetString("password") - cmds = append(cmds, login(username, password)) - } - } - - if m.page == REGISTER { - - // Process the form - // LOGIN - registerForm, cmd := m.auth.registerForm.Update(msg) - if f, ok := registerForm.(*huh.Form); ok { - m.auth.registerForm = f - cmds = append(cmds, cmd) - } - - if m.auth.registerForm.State == huh.StateCompleted { - username := m.auth.registerForm.GetString("username") - password := m.auth.registerForm.GetString("password") - email := m.auth.registerForm.GetString("email") - cmds = append(cmds, register(username, password, email)) - } - - } - var cmd tea.Cmd - 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("vex.log", "") - m := New() - p := tea.NewProgram(m) - if _, err := p.Run(); err != nil { - os.Exit(1) - } -} diff --git a/tui/models/entry.go b/tui/models/entry.go new file mode 100644 index 0000000..ae33fbe --- /dev/null +++ b/tui/models/entry.go @@ -0,0 +1,35 @@ +package models + +import ( + "fmt" + "time" + + "github.com/google/uuid" +) + +type Entry struct { + Id uuid.UUID `json:"id"` + ArticleTitle string `json:"title"` + Content string `json:"content"` + Link string `json:"link"` + Date time.Time `json:"date"` + + Authors []string `json:"authors"` // author not always specified + IsRead bool `json:"isRead"` + IsBookmarked bool `json:"IsBookmarked"` + IsIgnored bool `json:"isIgnored"` + IsReadLater bool `json:"isReadLater"` + Feed Feed `json:"feed"` +} + +func (e Entry) FilterValue() string { + return e.ArticleTitle +} + +func (e Entry) Title() string { + return e.ArticleTitle +} + +func (e Entry) Description() string { + return fmt.Sprintf("%s", "my desc") // TODO: real description (tags and author + date ?) +} diff --git a/tui/models/feed.go b/tui/models/feed.go new file mode 100644 index 0000000..c947d6b --- /dev/null +++ b/tui/models/feed.go @@ -0,0 +1,30 @@ +package models + +import ( + "fmt" + "time" + + "github.com/google/uuid" +) + +type Feed struct { + Id uuid.UUID `json:"id"` + Name string `json:"name"` + Url string `json:"link"` + FaviconUrl string `json:"faviconUrl"` + Tags []string `json:"tags"` + AddedDate time.Time `json:"addedDate"` + SubmitterId uuid.UUID `json:"submitterId"` +} + +func (f Feed) FilterValue() string { + return f.Name +} + +func (f Feed) Title() string { + return f.Name +} + +func (f Feed) Description() string { + return fmt.Sprintf("%s", "my desc") // TODO: real description (tags, submitter, error status, last sync) +} diff --git a/tui/auth.go b/tui/pages/auth/auth.go similarity index 53% rename from tui/auth.go rename to tui/pages/auth/auth.go index b649383..dadf8a9 100644 --- a/tui/auth.go +++ b/tui/pages/auth/auth.go @@ -1,13 +1,14 @@ -package main +package auth import ( + "github.com/badoux/checkmail" huh "github.com/charmbracelet/huh" ) -type Auth struct { - loginForm *huh.Form - registerForm *huh.Form - jwt *string +type Model struct { + LoginForm *huh.Form + RegisterForm *huh.Form + Jwt *string } func getLoginForm() *huh.Form { @@ -15,7 +16,14 @@ func getLoginForm() *huh.Form { huh.NewGroup( huh.NewInput(). Title("Email"). - Key("email"), + Key("email").Validate( + func(s string) error { + err := checkmail.ValidateFormat(s) + if err != nil { + return err + } + return nil + }), huh.NewInput(). Title("Password"). Key("password"). @@ -28,7 +36,14 @@ func getRegisterForm() *huh.Form { huh.NewGroup( huh.NewInput(). Title("Email"). - Key("email"), + Key("email").Validate( + func(s string) error { + err := checkmail.ValidateFormat(s) + if err != nil { + return err + } + return nil + }), huh.NewInput(). Title("Username"). Key("username"), @@ -42,3 +57,8 @@ func getRegisterForm() *huh.Form { Password(true), )).WithWidth(40) } + +func New() Model { + return Model{RegisterForm: getRegisterForm(), LoginForm: getLoginForm(), Jwt: new(string)} + +} diff --git a/tui/pages/feeds/keymaps.go b/tui/pages/feeds/keymaps.go new file mode 100644 index 0000000..bab8407 --- /dev/null +++ b/tui/pages/feeds/keymaps.go @@ -0,0 +1,21 @@ +package feeds + +import "github.com/charmbracelet/bubbles/key" + +type ListKeyMap struct { + GoToPosts key.Binding + AddFeed key.Binding +} + +func FeedsKeyMaps() *ListKeyMap { + return &ListKeyMap{ + GoToPosts: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "Go to posts"), + ), + AddFeed: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "Add feed"), + ), + } +} diff --git a/tui/pages/feeds/model.go b/tui/pages/feeds/model.go new file mode 100644 index 0000000..0b51e08 --- /dev/null +++ b/tui/pages/feeds/model.go @@ -0,0 +1,65 @@ +package feeds + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/huh" + "github.com/google/uuid" + + "github.com/zoryia/vex/tui/models" +) + +type Model struct { + List list.Model + AddFeed *huh.Form +} + +func (m Model) View() string { + return m.List.View() +} + +func initFeedsList() list.Model { + feeds := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + feeds.Title = "Feeds" + feeds.SetFilteringEnabled(false) + feeds.DisableQuitKeybindings() + feeds.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + FeedsKeyMaps().AddFeed, + FeedsKeyMaps().GoToPosts, + } + } + feeds.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + FeedsKeyMaps().AddFeed, + FeedsKeyMaps().GoToPosts, + } + } + feeds.SetItems([]list.Item{ + models.Feed{Id: uuid.UUID{}, Tags: []string{"Devops", "Kubernetes"}, Name: "zwindler", Url: "zwindler.blog", FaviconUrl: "zwindler.blog.favicon"}, + models.Feed{Id: uuid.UUID{}, Tags: []string{"Devops", "Kubernetes"}, Name: "zwindler", Url: "zwindler.blog", FaviconUrl: "zwindler.blog.favicon"}, + models.Feed{Id: uuid.UUID{}, Tags: []string{"Devops", "Kubernetes"}, Name: "zwindler", Url: "zwindler.blog", FaviconUrl: "zwindler.blog.favicon"}, + }) + return feeds +} + +func AddFeedForm(tags []string) *huh.Form { + return huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Feed Url"). + Key("url"), + huh.NewMultiSelect[string](). + Title("Tags"). + Key("tags"). + Options( + + huh.NewOptions(tags...)..., + ), + )).WithWidth(40) +} + +func New() Model { + return Model{List: initFeedsList(), AddFeed: AddFeedForm([]string{})} + +} diff --git a/tui/pages/pages.go b/tui/pages/pages.go new file mode 100644 index 0000000..2984f4a --- /dev/null +++ b/tui/pages/pages.go @@ -0,0 +1,13 @@ +package pages + +const ( + LOGIN = "LOGIN" + REGISTER = "REGISTER" + ENTRIES = "ENTRIES" + FEEDS = "FEEDS" + TAGS = "TAGS" + IGNORED = "IGNORED" + PREVIEW = "PREVIEW" +) + +type VexPage string diff --git a/tui/pages/preview/model.go b/tui/pages/preview/model.go new file mode 100644 index 0000000..860bac9 --- /dev/null +++ b/tui/pages/preview/model.go @@ -0,0 +1,88 @@ +package preview + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + "github.com/zoryia/vex/tui/models" + + "errors" +) + +type Model struct { + Entry models.Entry + Viewport viewport.Model +} + +// RenderMarkdown renders the markdown content with glamour. +func RenderMarkdown(width int, content string) (string, error) { + background := "light" + + if lipgloss.HasDarkBackground() { + background = "dark" + } + + r, _ := glamour.NewTermRenderer( + glamour.WithWordWrap(width), + glamour.WithStandardStyle(background), + ) + + out, err := r.Render(content) + if err != nil { + return "", errors.Unwrap(err) + } + + return out, nil +} + +func renderMarkdownCmd(width int, entry models.Entry) tea.Cmd { + return func() tea.Msg { + _, err := RenderMarkdown(width, entry.Content) + if err != nil { + //return errorMsg(err) + } + return nil + // return renderMarkdownMsg(markdownContent) + } +} + +var ( + titleStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Right = "├" + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + }() + + infoStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Left = "┤" + return titleStyle.Copy().BorderStyle(b) + }() +) + +// TODO: render as markdown with glamour +func (m Model) View() string { + return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.Viewport.View(), m.footerView()) +} + +func (m Model) VerticalMarginHeight() int { + headerHeight := lipgloss.Height(m.headerView()) + footerHeight := lipgloss.Height(m.footerView()) + return headerHeight + footerHeight +} + +func (m Model) headerView() string { + title := titleStyle.Render(m.Entry.ArticleTitle) + line := strings.Repeat("─", max(0, m.Viewport.Width-lipgloss.Width(title))) + return lipgloss.JoinHorizontal(lipgloss.Center, title, line) +} + +func (m Model) footerView() string { + info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.Viewport.ScrollPercent()*100)) + line := strings.Repeat("─", max(0, m.Viewport.Width-lipgloss.Width(info))) + return lipgloss.JoinHorizontal(lipgloss.Center, line, info) +}