basic tabs

This commit is contained in:
Erik Winter 2023-12-23 12:08:58 +01:00
parent 617d02b89e
commit 9f82044171
13 changed files with 319 additions and 55 deletions

View File

@ -2,5 +2,5 @@
run-server: run-server:
go run ./cmd/api-service/service.go -apikey localOnly go run ./cmd/api-service/service.go -apikey localOnly
run-cli: run-tui:
go run ./cmd/terminal-client/main.go go run ./cmd/terminal-client/main.go

52
client/emdb.go Normal file
View File

@ -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
}

View File

@ -1,9 +1,9 @@
package clients package client
import ( import (
"time" "time"
"ewintr.nl/emdb/movie" "ewintr.nl/emdb/model"
tmdb "github.com/cyruzin/golang-tmdb" tmdb "github.com/cyruzin/golang-tmdb"
) )
@ -24,33 +24,36 @@ func NewTMDB(apikey string) (*TMDB, error) {
}, nil }, nil
} }
func (t TMDB) Search(query string) ([]movie.Movie, error) { func (t TMDB) Search(query string) ([]model.Movie, error) {
return []movie.Movie{ return []model.Movie{
{Title: "movie1", Year: 2020, Summary: "summary1"}, {Title: "movie1", Year: 2020, Summary: "summary1"},
{Title: "movie2", Year: 2020, Summary: "summary2"}, {Title: "movie2", Year: 2020, Summary: "summary2"},
{Title: "movie3", Year: 2020, Summary: "summary3"}, {Title: "movie3", Year: 2020, Summary: "summary3"},
}, nil }, 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) result, err := t.c.GetMovieDetails(int(id), nil)
if err != nil { if err != nil {
return movie.Movie{}, err return model.Movie{}, err
} }
var year int var year int
@ -58,7 +61,7 @@ func (t TMDB) GetMovie(id int64) (movie.Movie, error) {
year = release.Year() year = release.Year()
} }
return movie.Movie{ return model.Movie{
Title: result.Title, Title: result.Title,
TMDBID: result.ID, TMDBID: result.ID,
Year: year, Year: year,

View File

@ -9,16 +9,16 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"ewintr.nl/emdb/movie" "ewintr.nl/emdb/model"
"github.com/google/uuid" "github.com/google/uuid"
) )
type MovieAPI struct { type MovieAPI struct {
repo movie.MovieRepository repo model.MovieRepository
logger *slog.Logger logger *slog.Logger
} }
func NewMovieAPI(repo movie.MovieRepository, logger *slog.Logger) *MovieAPI { func NewMovieAPI(repo model.MovieRepository, logger *slog.Logger) *MovieAPI {
return &MovieAPI{ return &MovieAPI{
repo: repo, repo: repo,
logger: logger.With("api", "movie"), 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() defer r.Body.Close()
var movie *movie.Movie var movie *model.Movie
if err := json.Unmarshal(body, &movie); err != nil { if err := json.Unmarshal(body, &movie); err != nil {
Error(w, http.StatusBadRequest, "could not unmarshal request body", err, logger) Error(w, http.StatusBadRequest, "could not unmarshal request body", err, logger)
return return

View File

@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"ewintr.nl/emdb/movie" "ewintr.nl/emdb/model"
"github.com/google/uuid" "github.com/google/uuid"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@ -59,7 +59,7 @@ func NewSQLite(dbPath string) (*SQLite, error) {
return s, nil return s, nil
} }
func (s *SQLite) Store(m *movie.Movie) error { func (s *SQLite) Store(m *model.Movie) error {
if m.ID == "" { if m.ID == "" {
m.ID = uuid.New().String() m.ID = uuid.New().String()
} }
@ -92,7 +92,7 @@ func (s *SQLite) Delete(id string) error {
return nil return nil
} }
func (s *SQLite) FindOne(id string) (*movie.Movie, error) { func (s *SQLite) FindOne(id string) (*model.Movie, error) {
row := s.db.QueryRow(` row := s.db.QueryRow(`
SELECT tmdb_id, imdb_id, title, english_title, year, directors, summary, watched_on, rating, comment SELECT tmdb_id, imdb_id, title, english_title, year, directors, summary, watched_on, rating, comment
FROM movie FROM movie
@ -101,7 +101,7 @@ WHERE id=?`, id)
return nil, row.Err() return nil, row.Err()
} }
m := &movie.Movie{ m := &model.Movie{
ID: id, ID: id,
} }
var directors string var directors string
@ -113,7 +113,7 @@ WHERE id=?`, id)
return m, nil return m, nil
} }
func (s *SQLite) FindAll() ([]*movie.Movie, error) { func (s *SQLite) FindAll() ([]*model.Movie, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT tmdb_id, imdb_id, title, english_title, year, directors, summary, watched_on, rating, comment SELECT tmdb_id, imdb_id, title, english_title, year, directors, summary, watched_on, rating, comment
FROM movie`) FROM movie`)
@ -121,10 +121,10 @@ FROM movie`)
return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err)
} }
movies := make([]*movie.Movie, 0) movies := make([]*model.Movie, 0)
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
m := &movie.Movie{} m := &model.Movie{}
var directors string 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 { 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) return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err)

View File

@ -2,28 +2,23 @@ package main
import ( import (
"fmt" "fmt"
"net/http"
"os" "os"
"ewintr.nl/emdb/cmd/terminal-client/clients"
"ewintr.nl/emdb/cmd/terminal-client/tui" "ewintr.nl/emdb/cmd/terminal-client/tui"
) )
func main() { 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 { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
p := tui.New(tdb)
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
}
type EMDBClient struct {
c *http.Client
} }

View File

@ -0,0 +1 @@
package tui

View File

@ -0,0 +1,7 @@
package tui
type Config struct {
TMDBAPIKey string
EMDBAPIKey string
EMDBBaseURL string
}

View File

@ -0,0 +1 @@
package tui

View File

@ -3,11 +3,11 @@ package tui
import ( import (
"fmt" "fmt"
"ewintr.nl/emdb/movie" "ewintr.nl/emdb/model"
) )
type Movie struct { type Movie struct {
m movie.Movie m model.Movie
} }
func (m Movie) FilterValue() string { func (m Movie) FilterValue() string {

View File

@ -3,7 +3,7 @@ package tui
import ( import (
"fmt" "fmt"
"ewintr.nl/emdb/cmd/terminal-client/clients" "ewintr.nl/emdb/client"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
@ -11,7 +11,7 @@ import (
) )
type modelSearch struct { type modelSearch struct {
tmdb *clients.TMDB tmdb *client.TMDB
focused string focused string
searchInput textinput.Model searchInput textinput.Model
searchResults list.Model searchResults list.Model

View File

@ -1,13 +1,218 @@
package tui package tui
import ( 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" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
) )
func New(tmdb *clients.TMDB) *tea.Program { var (
m := modelSearch{ docStyle = lipgloss.NewStyle().Padding(1)
tmdb: tmdb, 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
} }

View File

@ -1,4 +1,4 @@
package movie package model
type Movie struct { type Movie struct {
ID string `json:"id"` ID string `json:"id"`