From 9f820441711776ba15e27ed3ac28f5cf83958336 Mon Sep 17 00:00:00 2001 From: Erik Winter Date: Sat, 23 Dec 2023 12:08:58 +0100 Subject: [PATCH] basic tabs --- Makefile | 2 +- client/emdb.go | 52 +++++ .../clients => client}/tmdb.go | 47 ++-- cmd/api-service/server/movie.go | 8 +- cmd/api-service/server/sqlite.go | 14 +- cmd/terminal-client/main.go | 17 +- cmd/terminal-client/tui/command.go | 1 + cmd/terminal-client/tui/config.go | 7 + cmd/terminal-client/tui/import.go | 1 + cmd/terminal-client/tui/movie.go | 4 +- cmd/terminal-client/tui/search.go | 4 +- cmd/terminal-client/tui/tui.go | 215 +++++++++++++++++- {movie => model}/movie.go | 2 +- 13 files changed, 319 insertions(+), 55 deletions(-) create mode 100644 client/emdb.go rename {cmd/terminal-client/clients => client}/tmdb.go (56%) create mode 100644 cmd/terminal-client/tui/command.go create mode 100644 cmd/terminal-client/tui/config.go create mode 100644 cmd/terminal-client/tui/import.go rename {movie => model}/movie.go (97%) diff --git a/Makefile b/Makefile index be3ec8d..f6a3c43 100644 --- a/Makefile +++ b/Makefile @@ -2,5 +2,5 @@ run-server: go run ./cmd/api-service/service.go -apikey localOnly -run-cli: +run-tui: go run ./cmd/terminal-client/main.go \ No newline at end of file diff --git a/client/emdb.go b/client/emdb.go new file mode 100644 index 0000000..abb00e9 --- /dev/null +++ b/client/emdb.go @@ -0,0 +1,52 @@ +package client + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "ewintr.nl/emdb/model" +) + +type EMDB struct { + baseURL string + apiKey string + c *http.Client +} + +func NewEMDB(baseURL string, apiKey string) *EMDB { + return &EMDB{ + baseURL: baseURL, + apiKey: apiKey, + c: &http.Client{}, + } +} + +func (e *EMDB) GetMovies() ([]model.Movie, error) { + url := fmt.Sprintf("%s/movie", e.baseURL) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", e.apiKey) + + resp, err := e.c.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + defer resp.Body.Close() + + var movies []model.Movie + if err := json.Unmarshal(body, &movies); err != nil { + return nil, err + } + + return movies, nil +} diff --git a/cmd/terminal-client/clients/tmdb.go b/client/tmdb.go similarity index 56% rename from cmd/terminal-client/clients/tmdb.go rename to client/tmdb.go index 575fd62..bd24347 100644 --- a/cmd/terminal-client/clients/tmdb.go +++ b/client/tmdb.go @@ -1,9 +1,9 @@ -package clients +package client import ( "time" - "ewintr.nl/emdb/movie" + "ewintr.nl/emdb/model" tmdb "github.com/cyruzin/golang-tmdb" ) @@ -24,33 +24,36 @@ func NewTMDB(apikey string) (*TMDB, error) { }, nil } -func (t TMDB) Search(query string) ([]movie.Movie, error) { - return []movie.Movie{ +func (t TMDB) Search(query string) ([]model.Movie, error) { + return []model.Movie{ {Title: "movie1", Year: 2020, Summary: "summary1"}, {Title: "movie2", Year: 2020, Summary: "summary2"}, {Title: "movie3", Year: 2020, Summary: "summary3"}, }, nil - - results, err := t.c.GetSearchMovies(query, nil) - if err != nil { - return nil, err - } - - movies := make([]movie.Movie, len(results.Results)) - for i, result := range results.Results { - movies[i], err = t.GetMovie(result.ID) - if err != nil { - return nil, err - } - } - - return movies, nil } -func (t TMDB) GetMovie(id int64) (movie.Movie, error) { +//func (t TMDB) Search(query string) ([]model.Movie, error) { +// +// results, err := t.c.GetSearchMovies(query, nil) +// if err != nil { +// return nil, err +// } +// +// movies := make([]model.Movie, len(results.Results)) +// for i, result := range results.Results { +// movies[i], err = t.GetMovie(result.ID) +// if err != nil { +// return nil, err +// } +// } +// +// return movies, nil +//} + +func (t TMDB) GetMovie(id int64) (model.Movie, error) { result, err := t.c.GetMovieDetails(int(id), nil) if err != nil { - return movie.Movie{}, err + return model.Movie{}, err } var year int @@ -58,7 +61,7 @@ func (t TMDB) GetMovie(id int64) (movie.Movie, error) { year = release.Year() } - return movie.Movie{ + return model.Movie{ Title: result.Title, TMDBID: result.ID, Year: year, diff --git a/cmd/api-service/server/movie.go b/cmd/api-service/server/movie.go index 588edea..2e0d8ce 100644 --- a/cmd/api-service/server/movie.go +++ b/cmd/api-service/server/movie.go @@ -9,16 +9,16 @@ import ( "log/slog" "net/http" - "ewintr.nl/emdb/movie" + "ewintr.nl/emdb/model" "github.com/google/uuid" ) type MovieAPI struct { - repo movie.MovieRepository + repo model.MovieRepository logger *slog.Logger } -func NewMovieAPI(repo movie.MovieRepository, logger *slog.Logger) *MovieAPI { +func NewMovieAPI(repo model.MovieRepository, logger *slog.Logger) *MovieAPI { return &MovieAPI{ repo: repo, logger: logger.With("api", "movie"), @@ -78,7 +78,7 @@ func (api *MovieAPI) Store(w http.ResponseWriter, r *http.Request, urlID string) } defer r.Body.Close() - var movie *movie.Movie + var movie *model.Movie if err := json.Unmarshal(body, &movie); err != nil { Error(w, http.StatusBadRequest, "could not unmarshal request body", err, logger) return diff --git a/cmd/api-service/server/sqlite.go b/cmd/api-service/server/sqlite.go index 9fc2a8b..81f48d9 100644 --- a/cmd/api-service/server/sqlite.go +++ b/cmd/api-service/server/sqlite.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "ewintr.nl/emdb/movie" + "ewintr.nl/emdb/model" "github.com/google/uuid" _ "modernc.org/sqlite" ) @@ -59,7 +59,7 @@ func NewSQLite(dbPath string) (*SQLite, error) { return s, nil } -func (s *SQLite) Store(m *movie.Movie) error { +func (s *SQLite) Store(m *model.Movie) error { if m.ID == "" { m.ID = uuid.New().String() } @@ -92,7 +92,7 @@ func (s *SQLite) Delete(id string) error { return nil } -func (s *SQLite) FindOne(id string) (*movie.Movie, error) { +func (s *SQLite) FindOne(id string) (*model.Movie, error) { row := s.db.QueryRow(` SELECT tmdb_id, imdb_id, title, english_title, year, directors, summary, watched_on, rating, comment FROM movie @@ -101,7 +101,7 @@ WHERE id=?`, id) return nil, row.Err() } - m := &movie.Movie{ + m := &model.Movie{ ID: id, } var directors string @@ -113,7 +113,7 @@ WHERE id=?`, id) return m, nil } -func (s *SQLite) FindAll() ([]*movie.Movie, error) { +func (s *SQLite) FindAll() ([]*model.Movie, error) { rows, err := s.db.Query(` SELECT tmdb_id, imdb_id, title, english_title, year, directors, summary, watched_on, rating, comment FROM movie`) @@ -121,10 +121,10 @@ FROM movie`) return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) } - movies := make([]*movie.Movie, 0) + movies := make([]*model.Movie, 0) defer rows.Close() for rows.Next() { - m := &movie.Movie{} + m := &model.Movie{} var directors string if err := rows.Scan(&m.TMDBID, &m.IMDBID, &m.Title, &m.EnglishTitle, &m.Year, &directors, &m.Summary, &m.WatchedOn, &m.Rating, &m.Comment); err != nil { return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) diff --git a/cmd/terminal-client/main.go b/cmd/terminal-client/main.go index ea44712..60b15e9 100644 --- a/cmd/terminal-client/main.go +++ b/cmd/terminal-client/main.go @@ -2,28 +2,23 @@ package main import ( "fmt" - "net/http" "os" - "ewintr.nl/emdb/cmd/terminal-client/clients" "ewintr.nl/emdb/cmd/terminal-client/tui" ) func main() { - tdb, err := clients.NewTMDB(os.Getenv("TMDB_API_KEY")) + p, err := tui.New(tui.Config{ + TMDBAPIKey: os.Getenv("TMDB_API_KEY"), + EMDBAPIKey: os.Getenv("EMDB_API_KEY"), + EMDBBaseURL: "https://emdb.ewintr.nl", + }) if err != nil { fmt.Println(err) os.Exit(1) } - - p := tui.New(tdb) if _, err := p.Run(); err != nil { - fmt.Printf("Alas, there's been an error: %v", err) + fmt.Println(err) os.Exit(1) } - -} - -type EMDBClient struct { - c *http.Client } diff --git a/cmd/terminal-client/tui/command.go b/cmd/terminal-client/tui/command.go new file mode 100644 index 0000000..89aa0ee --- /dev/null +++ b/cmd/terminal-client/tui/command.go @@ -0,0 +1 @@ +package tui diff --git a/cmd/terminal-client/tui/config.go b/cmd/terminal-client/tui/config.go new file mode 100644 index 0000000..d82f812 --- /dev/null +++ b/cmd/terminal-client/tui/config.go @@ -0,0 +1,7 @@ +package tui + +type Config struct { + TMDBAPIKey string + EMDBAPIKey string + EMDBBaseURL string +} diff --git a/cmd/terminal-client/tui/import.go b/cmd/terminal-client/tui/import.go new file mode 100644 index 0000000..89aa0ee --- /dev/null +++ b/cmd/terminal-client/tui/import.go @@ -0,0 +1 @@ +package tui diff --git a/cmd/terminal-client/tui/movie.go b/cmd/terminal-client/tui/movie.go index 7c03ecd..bcf1f48 100644 --- a/cmd/terminal-client/tui/movie.go +++ b/cmd/terminal-client/tui/movie.go @@ -3,11 +3,11 @@ package tui import ( "fmt" - "ewintr.nl/emdb/movie" + "ewintr.nl/emdb/model" ) type Movie struct { - m movie.Movie + m model.Movie } func (m Movie) FilterValue() string { diff --git a/cmd/terminal-client/tui/search.go b/cmd/terminal-client/tui/search.go index ddc5c12..765c40d 100644 --- a/cmd/terminal-client/tui/search.go +++ b/cmd/terminal-client/tui/search.go @@ -3,7 +3,7 @@ package tui import ( "fmt" - "ewintr.nl/emdb/cmd/terminal-client/clients" + "ewintr.nl/emdb/client" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" @@ -11,7 +11,7 @@ import ( ) type modelSearch struct { - tmdb *clients.TMDB + tmdb *client.TMDB focused string searchInput textinput.Model searchResults list.Model diff --git a/cmd/terminal-client/tui/tui.go b/cmd/terminal-client/tui/tui.go index cc5f632..b20c68e 100644 --- a/cmd/terminal-client/tui/tui.go +++ b/cmd/terminal-client/tui/tui.go @@ -1,13 +1,218 @@ package tui import ( - "ewintr.nl/emdb/cmd/terminal-client/clients" + "fmt" + "strings" + + "ewintr.nl/emdb/client" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" ) -func New(tmdb *clients.TMDB) *tea.Program { - m := modelSearch{ - tmdb: tmdb, +var ( + docStyle = lipgloss.NewStyle().Padding(1) + colorNormalForeground = lipgloss.ANSIColor(termenv.ANSIWhite) + colorHighLightForeGround = lipgloss.ANSIColor(termenv.ANSIBrightWhite) + windowStyle = lipgloss.NewStyle(). + BorderForeground(colorHighLightForeGround). + Foreground(colorNormalForeground). + Padding(0, 1). + Align(lipgloss.Center). + Border(lipgloss.NormalBorder(), true) + tabPaneStyle = windowStyle.Copy().UnsetBorderTop() +) + +func New(conf Config) (*tea.Program, error) { + tabs := []string{"Erik's movie database", "The movie database"} + tabContent := []string{"Emdb", "TMDB"} + + tmdb, err := client.NewTMDB(conf.TMDBAPIKey) + if err != nil { + return nil, err } - return tea.NewProgram(m, tea.WithAltScreen()) + m := baseModel{ + config: conf, + emdb: client.NewEMDB(conf.EMDBBaseURL, conf.EMDBAPIKey), + tmdb: tmdb, + Tabs: tabs, + TabContent: tabContent, + } + return tea.NewProgram(m, tea.WithAltScreen()), nil +} + +type baseModel struct { + config Config + emdb *client.EMDB + tmdb *client.TMDB + Tabs []string + TabContent []string + activeTab int + //focused string + //searchInput textinput.Model + //searchResults list.Model + movieList list.Model + logContent string + ready bool + logViewport viewport.Model + windowSize tea.WindowSizeMsg + tabSize tea.WindowSizeMsg +} + +func (m baseModel) Init() tea.Cmd { + return nil +} + +func (m baseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + return m, tea.Quit + case "right", "tab": + m.Log("switch to next tab") + m.activeTab = min(m.activeTab+1, len(m.Tabs)-1) + return m, nil + case "left", "shift+tab": + m.Log("switch to previous tab") + m.activeTab = max(m.activeTab-1, 0) + return m, nil + } + case tea.WindowSizeMsg: + if !m.ready { + m.windowSize = msg + m.Log(fmt.Sprintf("new window size: %dx%d", msg.Width, msg.Height)) + m.initialModel(msg.Width, msg.Height) + } + } + + //switch m.focused { + //case "search": + // m.searchInput, cmd = m.searchInput.Update(msg) + //case "result": + // m.searchResults, cmd = m.searchResults.Update(msg) + //} + m.logViewport, cmd = m.logViewport.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func tabBorderWithBottom(left, middle, right string) lipgloss.Border { + border := lipgloss.NormalBorder() + border.BottomLeft = left + border.Bottom = middle + border.BottomRight = right + return border +} + +func (m *baseModel) Log(msg string) { + m.logContent = fmt.Sprintf("%s\n%s", m.logContent, msg) + m.logViewport.SetContent(m.logContent) + m.logViewport.GotoBottom() +} + +//func (m *model) Search() { +// m.Log("start search") +// movies, err := m.tmdb.Search(m.searchInput.Value()) +// if err != nil { +// m.Log(fmt.Sprintf("error: %v", err)) +// return +// } +// +// m.Log(fmt.Sprintf("found %d results", len(movies))) +// items := []list.Item{} +// for _, res := range movies { +// items = append(items, Movie{m: res}) +// } +// +// m.searchResults.SetItems(items) +// m.focused = "result" +//} + +func (m baseModel) View() string { + if !m.ready { + return "\n Initializing..." + } + + contentWidth := m.windowSize.Width - docStyle.GetHorizontalFrameSize() - docStyle.GetHorizontalFrameSize() + m.Log(fmt.Sprintf("content width: %d", contentWidth)) + doc := strings.Builder{} + doc.WriteString(m.renderMenu(contentWidth)) + doc.WriteString("\n") + doc.WriteString(m.renderTabContent(contentWidth)) + doc.WriteString("\n") + doc.WriteString(m.renderLog(contentWidth)) + return docStyle.Render(doc.String()) +} + +func (m *baseModel) renderMenu(width int) string { + var items []string + for i, t := range m.Tabs { + if i == m.activeTab { + items = append(items, lipgloss.NewStyle(). + Foreground(colorHighLightForeGround). + // Background(lipgloss.ANSIColor(termenv.ANSIBlack)). + Render(fmt.Sprintf(" * %s ", t))) + continue + } + + items = append(items, lipgloss.NewStyle(). + Foreground(colorNormalForeground). + // Background(lipgloss.ANSIColor(termenv.ANSIBlack)). + Render(fmt.Sprintf(" %s ", t))) + } + + return lipgloss.PlaceHorizontal(width, lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Top, items...)) +} + +func (m *baseModel) renderTabContent(width int) string { + content := m.TabContent[m.activeTab] + + return windowStyle.Width(width).Render(content) +} + +func (m *baseModel) renderLog(width int) string { + return windowStyle.Width(width).Render(m.logViewport.View()) +} + +func (m *baseModel) initialModel(width, height int) { + + si := textinput.New() + si.Placeholder = "title" + si.CharLimit = 156 + si.Width = 20 + //m.searchInput = si + //m.searchInput.Focus() + // + //m.searchResults = list.New([]list.Item{}, list.NewDefaultDelegate(), width, height-50) + //m.searchResults.Title = "Search results" + //m.searchResults.SetShowHelp(false) + + m.Log("fetch emdb movies") + ems, err := m.emdb.GetMovies() + if err != nil { + m.Log(err.Error()) + } + items := make([]list.Item, len(ems)) + for i, em := range ems { + items[i] = list.Item(Movie{m: em}) + } + m.Log(fmt.Sprintf("found %d movies in in emdb", len(items))) + + m.movieList = list.New(items, list.NewDefaultDelegate(), width, height-10) + m.movieList.Title = "Movies" + m.movieList.SetShowHelp(false) + + m.logViewport = viewport.New(width, 10) + m.logViewport.SetContent(m.logContent) + m.logViewport.KeyMap = viewport.KeyMap{} + //m.focused = "search" + m.ready = true } diff --git a/movie/movie.go b/model/movie.go similarity index 97% rename from movie/movie.go rename to model/movie.go index 8a04fc9..230a002 100644 --- a/movie/movie.go +++ b/model/movie.go @@ -1,4 +1,4 @@ -package movie +package model type Movie struct { ID string `json:"id"`