From e25611bde49fe2db28a006aca3ea49eece046c5f Mon Sep 17 00:00:00 2001 From: Bobby Date: Sun, 8 Sep 2024 18:34:58 -0400 Subject: Image Rendering. Recommendations Thingy. Basic Home --- config/colors.go | 15 +++++ config/constants.go | 4 ++ go.mod | 7 ++- go.sum | 4 ++ lib/auth.go | 111 ++++++++++++++++++++++++++++++++++ lib/image_cache.go | 98 ++++++++++++++++++++++++++++++ lib/image_renderer.go | 158 +++++++++++++++++++++++++++++++++++++++++++++++++ lib/recommendations.go | 79 +++++++++++++++++++++++++ lib/user.go | 45 ++++++++++++++ main.go | 29 ++------- screens/home.go | 78 ++++++++++++++++++++++-- screens/screens.go | 8 ++- utils/auth.go | 92 ---------------------------- 13 files changed, 603 insertions(+), 125 deletions(-) create mode 100644 config/colors.go create mode 100644 lib/auth.go create mode 100644 lib/image_cache.go create mode 100644 lib/image_renderer.go create mode 100644 lib/recommendations.go create mode 100644 lib/user.go delete mode 100644 utils/auth.go diff --git a/config/colors.go b/config/colors.go new file mode 100644 index 0000000..8824511 --- /dev/null +++ b/config/colors.go @@ -0,0 +1,15 @@ +package config + +import "github.com/charmbracelet/lipgloss" + +type ColorConfig struct { + Primary lipgloss.AdaptiveColor + Text lipgloss.AdaptiveColor +} + +var ( + Colors = ColorConfig{ + Primary: lipgloss.AdaptiveColor{Light: "#2F51A2", Dark: "#2F51A2"}, + Text: lipgloss.AdaptiveColor{Light: "#F5F5F5", Dark: "#F5F5F5"}, + } +) diff --git a/config/constants.go b/config/constants.go index 47c3ab1..d04a8aa 100644 --- a/config/constants.go +++ b/config/constants.go @@ -8,11 +8,15 @@ import ( var ( AppName = "yato" + PrettyAppName = "Yato" Version = "0.1.0" MALOAuthBaseURL = "https://myanimelist.net/v1/oauth2/authorize" MALClientID string MALClientSecret string MALRedirectURI = "http://localhost:42069/authenticate" + MALAPIBaseURL = "https://api.myanimelist.net/v2" + JikanAPIBaseURL = "https://api.jikan.moe/v4" + ConfigDir, _ = os.UserConfigDir() ) // These variables will be set by the linker during build diff --git a/go.mod b/go.mod index 8b0735c..b87dca5 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,14 @@ go 1.21.4 require ( github.com/charmbracelet/bubbletea v1.1.0 + github.com/charmbracelet/lipgloss v0.13.0 + golang.org/x/image v0.20.0 golang.org/x/term v0.24.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/lipgloss v0.13.0 // indirect github.com/charmbracelet/x/ansi v0.2.3 // indirect github.com/charmbracelet/x/term v0.2.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -23,6 +25,5 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.25.0 // indirect - golang.org/x/text v0.3.8 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/text v0.18.0 // indirect ) diff --git a/go.sum b/go.sum index 7ced19a..370deff 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1n 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= +golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw= +golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -39,6 +41,8 @@ golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib/auth.go b/lib/auth.go new file mode 100644 index 0000000..b5bc06a --- /dev/null +++ b/lib/auth.go @@ -0,0 +1,111 @@ +package lib + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "yato/config" +) + +func GetNewCodeVerifier() (string, error) { + bytes := make([]byte, 100) + _, err := rand.Read(bytes) + if err != nil { + return "", fmt.Errorf("failed to generate code verifier: %w", err) + } + + encoded := base64.RawURLEncoding.EncodeToString(bytes) + + // Truncate to 128 characters + if len(encoded) > 128 { + encoded = encoded[:128] + } + + return encoded, nil +} + +func GetOAuthURL(codeVerifier string) string { + return fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=plain", + config.MALOAuthBaseURL, config.MALClientID, config.MALRedirectURI, codeVerifier) +} + +func OpenBrowser(url string) error { + var err error + + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + err = fmt.Errorf("unsupported platform") + } + + return err +} + +func ExchangeToken(code, codeVerifier string) (*config.MyAnimeListConfig, error) { + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("client_id", config.MALClientID) + data.Set("client_secret", config.MALClientSecret) + data.Set("redirect_uri", config.MALRedirectURI) + data.Set("code", code) + + if codeVerifier != "" { + data.Set("code_verifier", codeVerifier) + } + + // POST request to MAL API + req, err := http.NewRequest("POST", "https://myanimelist.net/v1/oauth2/token", strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var malConfig config.MyAnimeListConfig + var tokenResponse struct { + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + + if err := json.Unmarshal(body, &tokenResponse); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + malConfig.TokenType = tokenResponse.TokenType + malConfig.ExpiresIn = tokenResponse.ExpiresIn + malConfig.AccessToken = tokenResponse.AccessToken + malConfig.RefreshToken = tokenResponse.RefreshToken + + return &malConfig, nil +} diff --git a/lib/image_cache.go b/lib/image_cache.go new file mode 100644 index 0000000..58cd09b --- /dev/null +++ b/lib/image_cache.go @@ -0,0 +1,98 @@ +package lib + +import ( + "fmt" + "image" + "image/jpeg" + "io" + "net/http" + "os" + "path/filepath" + "yato/config" +) + +// ImageCache handles caching and retrieving images +type ImageCache struct { + cacheDir string +} + +// NewImageCache creates a new ImageCache +func NewImageCache() *ImageCache { + cacheDir := filepath.Join(config.ConfigDir, config.AppName, "cache") + return &ImageCache{cacheDir: cacheDir} +} + +// GetImage retrieves an image, either from cache or by downloading it +func (c *ImageCache) GetImage(mediaType string, malID int, size string, url string) (image.Image, error) { + cachePath := c.getCachePath(mediaType, malID, size) + + // Check if the image is already cached + if img, err := c.loadFromCache(cachePath); err == nil { + return img, nil + } + + // If not cached, download and cache the image + return c.downloadAndCache(url, cachePath) +} + +func (c *ImageCache) getCachePath(mediaType string, malID int, size string) string { + return filepath.Join(c.cacheDir, mediaType, fmt.Sprintf("%d", malID), size+".jpg") +} + +func (c *ImageCache) loadFromCache(path string) (image.Image, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + img, err := jpeg.Decode(file) + if err != nil { + return nil, err + } + + return img, nil +} + +func (c *ImageCache) downloadAndCache(url, cachePath string) (image.Image, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to download image: %s", resp.Status) + } + + // Ensure the cache directory exists + if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil { + return nil, err + } + + // Create the cache file + cacheFile, err := os.Create(cachePath) + if err != nil { + return nil, err + } + defer cacheFile.Close() + + // Download and write to cache file + _, err = io.Copy(cacheFile, resp.Body) + if err != nil { + return nil, err + } + + // Reset file pointer and decode the image + _, err = cacheFile.Seek(0, 0) + if err != nil { + return nil, err + } + + img, err := jpeg.Decode(cacheFile) + if err != nil { + return nil, err + } + + return img, nil +} diff --git a/lib/image_renderer.go b/lib/image_renderer.go new file mode 100644 index 0000000..03a8059 --- /dev/null +++ b/lib/image_renderer.go @@ -0,0 +1,158 @@ +package lib + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/color" + "image/jpeg" + "image/png" + "os" + "strings" + + "golang.org/x/image/draw" +) + +type ImageRenderer struct { + method string +} + +func NewImageRenderer() *ImageRenderer { + method := determineRenderMethod() + return &ImageRenderer{method: method} +} + +func determineRenderMethod() string { + if os.Getenv("TERM") == "xterm-kitty" { + return "kitty" + } else if os.Getenv("TERM_PROGRAM") == "iTerm.app" { + return "iterm2" + } else if os.Getenv("TERM") == "xterm-256color" && os.Getenv("VTE_VERSION") != "" { + return "sixel" + } + return "none" +} + +func (r *ImageRenderer) RenderImage(img image.Image, width, height int) string { + switch r.method { + case "kitty": + return r.renderKitty(img, width, height) + case "iterm2": + return r.renderITerm2(img, width, height) + case "sixel": + return r.renderSixel(img, width, height) + case "ascii": + return r.renderASCII(img, width, height) + default: + return "" + } +} + +func (r *ImageRenderer) renderKitty(img image.Image, width, height int) string { + resized := image.NewRGBA(image.Rect(0, 0, width, height)) + draw.NearestNeighbor.Scale(resized, resized.Rect, img, img.Bounds(), draw.Over, nil) + + var buf bytes.Buffer + png.Encode(&buf, resized) + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + + // Split the encoded data into chunks + const chunkSize = 4096 + chunks := make([]string, 0, (len(encoded)+chunkSize-1)/chunkSize) + for i := 0; i < len(encoded); i += chunkSize { + end := i + chunkSize + if end > len(encoded) { + end = len(encoded) + } + chunks = append(chunks, encoded[i:end]) + } + + // Build the Kitty graphics protocol command + var result strings.Builder + for i, chunk := range chunks { + if i == 0 { + result.WriteString(fmt.Sprintf("\033_Ga=T,f=100,s=%d,v=%d,m=1;", width, height)) + } else { + result.WriteString("\033_Gm=1;") + } + result.WriteString(chunk) + result.WriteString("\033\\") + } + + // Final chunk + result.WriteString("\033_Gm=0;\033\\") + + return result.String() +} + +func (r *ImageRenderer) renderITerm2(img image.Image, width, height int) string { + // Implement iTerm2 inline image protocol + var buf bytes.Buffer + jpeg.Encode(&buf, img, nil) + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + return fmt.Sprintf("\033]1337;File=inline=1;width=%dpx;height=%dpx:%s\a", width, height, encoded) +} + +func (r *ImageRenderer) renderSixel(img image.Image, width, height int) string { + resized := image.NewRGBA(image.Rect(0, 0, width, height)) + draw.NearestNeighbor.Scale(resized, resized.Rect, img, img.Bounds(), draw.Over, nil) + + // Convert to Sixel + var sb strings.Builder + sb.WriteString("\033Pq") // Start Sixel sequence + sb.WriteString("\"1;1;") // Set color mode and aspect ratio + sb.WriteString(fmt.Sprintf("%d;%d", width, height)) + sb.WriteString("\n") + + // Simple color quantization (this can be improved) + palette := make(map[color.Color]int) + colorIndex := 0 + + for y := 0; y < height; y++ { + sixelRow := make([]int, width) + for x := 0; x < width; x++ { + c := resized.At(x, y) + if _, exists := palette[c]; !exists { + palette[c] = colorIndex + colorIndex++ + r, g, b, _ := c.RGBA() + sb.WriteString(fmt.Sprintf("#%d;2;%d;%d;%d", palette[c], r>>8, g>>8, b>>8)) + } + sixelRow[x] = palette[c] + } + + // Encode sixel data + for i := 0; i < 6; i++ { + for _, colorIdx := range sixelRow { + sb.WriteByte(byte('?' + ((colorIdx >> i) & 1))) + } + sb.WriteByte('-') + } + sb.WriteByte('\n') + } + + sb.WriteString("\033\\") // End Sixel sequence + return sb.String() +} + +func (r *ImageRenderer) renderASCII(img image.Image, width, height int) string { + // Implement a simple ASCII art renderer + // This is a very basic implementation and can be improved + bounds := img.Bounds() + ascii := "" + for y := bounds.Min.Y; y < bounds.Max.Y; y += height / 10 { + for x := bounds.Min.X; x < bounds.Max.X; x += width / 20 { + c := img.At(x, y) + r, g, b, _ := c.RGBA() + avg := (r + g + b) / 3 + if avg > 32768 { + ascii += " " + } else { + ascii += "#" + } + } + ascii += "\n" + } + return ascii +} diff --git a/lib/recommendations.go b/lib/recommendations.go new file mode 100644 index 0000000..6875a1f --- /dev/null +++ b/lib/recommendations.go @@ -0,0 +1,79 @@ +package lib + +import ( + "encoding/json" + "fmt" + "net/http" + "yato/config" +) + +type Recommendation struct { + MALId string `json:"mal_id"` + URL string `json:"url"` + Entry []struct { + MALId int `json:"mal_id"` + URL string `json:"url"` + Images struct { + JPG struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + LargeImageURL string `json:"large_image_url"` + } `json:"jpg"` + WebP struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + LargeImageURL string `json:"large_image_url"` + } `json:"webp"` + } `json:"images"` + Title string `json:"title"` + } `json:"entry"` + Content string `json:"content"` + Date string `json:"date"` + User struct { + URL string `json:"url"` + Username string `json:"username"` + } `json:"user"` +} + +type recommendationsResponse struct { + Pagination struct { + LastVisiblePage int `json:"last_visible_page"` + HasNextPage bool `json:"has_next_page"` + } `json:"pagination"` + Data []Recommendation `json:"data"` +} + +func getRecentRecommendations(mediaType string) ([]Recommendation, error) { + url := fmt.Sprintf("%s/recommendations/%s", config.JikanAPIBaseURL, mediaType) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var response recommendationsResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return response.Data, nil +} + +func GetRecentAnimeRecommendations() ([]Recommendation, error) { + return getRecentRecommendations("anime") +} + +func GetRecentMangaRecommendations() ([]Recommendation, error) { + return getRecentRecommendations("manga") +} diff --git a/lib/user.go b/lib/user.go new file mode 100644 index 0000000..cbfde00 --- /dev/null +++ b/lib/user.go @@ -0,0 +1,45 @@ +package lib + +import ( + "encoding/json" + "fmt" + "net/http" + "yato/config" +) + +type MALUser struct { + ID int `json:"id"` + Name string `json:"name"` + Birthday string `json:"birthday"` + Location string `json:"location"` + JoinedAt string `json:"joined_at"` + Picture string `json:"picture"` +} + +func CurrentUser() (*MALUser, error) { + var user MALUser + + req, err := http.NewRequest("GET", "https://api.myanimelist.net/v2/users/@me", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.GetConfig().MyAnimeList.AccessToken)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &user, nil +} diff --git a/main.go b/main.go index 4764a3c..caeff9c 100644 --- a/main.go +++ b/main.go @@ -6,11 +6,9 @@ import ( "log" "net/http" "os" - "os/exec" - "runtime" "yato/config" + "yato/lib" "yato/screens" - "yato/utils" tea "github.com/charmbracelet/bubbletea" ) @@ -50,12 +48,12 @@ func StartApp() { func StartOAuthFlow() { var err error - codeVerifier, err = utils.GetNewCodeVerifier() + codeVerifier, err = lib.GetNewCodeVerifier() if err != nil { log.Fatalf("failed to generate code verifier: %s", err) } - url := utils.GetOAuthURL(codeVerifier) + url := lib.GetOAuthURL(codeVerifier) server := &http.Server{Addr: ":42069"} http.HandleFunc("/authenticate", handleOAuthCallback) @@ -66,7 +64,7 @@ func StartOAuthFlow() { } }() - if err := openBrowser(url); err != nil { + if err := lib.OpenBrowser(url); err != nil { log.Printf("failed to open browser: %v. Visit %s in your browser to authenticate.", err, url) } @@ -89,7 +87,7 @@ func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { return } - malConfig, err := utils.ExchangeToken(code, codeVerifier) + malConfig, err := lib.ExchangeToken(code, codeVerifier) if err != nil { errorChan <- fmt.Errorf("failed to exchange token: %w", err) http.Error(w, "failed to exchange token", http.StatusInternalServerError) @@ -106,20 +104,3 @@ func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Authentication successful! You can now close this tab.")) authorizationChan <- struct{}{} } - -func openBrowser(url string) error { - var err error - - switch runtime.GOOS { - case "linux": - err = exec.Command("xdg-open", url).Start() - case "windows": - err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - err = exec.Command("open", url).Start() - default: - err = fmt.Errorf("unsupported platform") - } - - return err -} diff --git a/screens/home.go b/screens/home.go index 64e4705..fa88fb5 100644 --- a/screens/home.go +++ b/screens/home.go @@ -1,12 +1,33 @@ package screens -import tea "github.com/charmbracelet/bubbletea" +import ( + "fmt" + "yato/config" + "yato/lib" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) type HomeScreen struct { + RecentAnimeRecommendations []lib.Recommendation + RecentMangaRecommendations []lib.Recommendation + imageCache *lib.ImageCache + imageRenderer *lib.ImageRenderer } func homeScreen() tea.Model { - return HomeScreen{} + recentAnimeRecommendations, _ := lib.GetRecentAnimeRecommendations() + recentMangaRecommendations, _ := lib.GetRecentMangaRecommendations() + imageCache := lib.NewImageCache() + imageRenderer := lib.NewImageRenderer() + + return HomeScreen{ + RecentAnimeRecommendations: recentAnimeRecommendations, + RecentMangaRecommendations: recentMangaRecommendations, + imageCache: imageCache, + imageRenderer: imageRenderer, + } } func (h HomeScreen) Init() tea.Cmd { @@ -15,7 +36,7 @@ func (h HomeScreen) Init() tea.Cmd { func (h HomeScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg, ok := msg.(tea.KeyMsg); ok { - if msg.Type == tea.KeyCtrlC { + if msg.String() == "q" { return h, tea.Quit } } @@ -24,5 +45,54 @@ func (h HomeScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (h HomeScreen) View() string { - return "Home" + w := lipgloss.Width + + // Top bar, Content, Status bar + topBarStyle := lipgloss.NewStyle(). + Foreground(config.Colors.Text). + Background(config.Colors.Primary) + + mainText := topBarStyle.Padding(0, 0, 0, 1).Render(config.PrettyAppName + " | [H]ome | [A]nime | [M]anga | [S]earch | [C]ommunity | [P]rofile | [O]ptions | [Q]uit") + userText := topBarStyle.Padding(0, 1, 0, 0).Render("User: " + globals.CurrentUser.Name + " | [L]ogout") + separator := topBarStyle.Width(globals.width - w(mainText) - w(userText)).Render("") + + topBar := lipgloss.JoinHorizontal( + lipgloss.Top, + mainText, + separator, + userText, + ) + + content := "" + // Top 5 recommendations + for i, rec := range h.RecentAnimeRecommendations { + if i == 5 { + break + } + content += h.renderRecommendation("anime", rec) + } + + for i, rec := range h.RecentMangaRecommendations { + if i == 5 { + break + } + content += h.renderRecommendation("manga", rec) + } + + return lipgloss.JoinVertical( + lipgloss.Top, + topBar, + content, + ) + +} + +func (h HomeScreen) renderRecommendation(mediaType string, rec lib.Recommendation) string { + img, err := h.imageCache.GetImage(mediaType, rec.Entry[0].MALId, "small", rec.Entry[0].Images.JPG.SmallImageURL) + if err != nil { + return fmt.Sprintf("%s -> %s\n", rec.Entry[0].Title, rec.Entry[1].Title) + } + + renderedImage := h.imageRenderer.RenderImage(img, 20, 30) + return fmt.Sprintf("%s%s -> %s\n", renderedImage, rec.Entry[0].Title, rec.Entry[1].Title) } diff --git a/screens/screens.go b/screens/screens.go index 1675ec7..f04878f 100644 --- a/screens/screens.go +++ b/screens/screens.go @@ -2,6 +2,7 @@ package screens import ( "os" + "yato/lib" tea "github.com/charmbracelet/bubbletea" "golang.org/x/term" @@ -12,8 +13,9 @@ type ScreenSwitcher struct { } type Globals struct { - width int - height int + width int + height int + CurrentUser *lib.MALUser } var globals Globals @@ -65,5 +67,7 @@ func Initialize() tea.Model { globals.width = width globals.height = height + globals.CurrentUser, _ = lib.CurrentUser() + return screen() } diff --git a/utils/auth.go b/utils/auth.go deleted file mode 100644 index 7745237..0000000 --- a/utils/auth.go +++ /dev/null @@ -1,92 +0,0 @@ -package utils - -import ( - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "yato/config" -) - -func GetNewCodeVerifier() (string, error) { - bytes := make([]byte, 100) - _, err := rand.Read(bytes) - if err != nil { - return "", fmt.Errorf("failed to generate code verifier: %w", err) - } - - encoded := base64.RawURLEncoding.EncodeToString(bytes) - - // Truncate to 128 characters - if len(encoded) > 128 { - encoded = encoded[:128] - } - - return encoded, nil -} - -func GetOAuthURL(codeVerifier string) string { - return fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=plain", - config.MALOAuthBaseURL, config.MALClientID, config.MALRedirectURI, codeVerifier) -} - -func ExchangeToken(code, codeVerifier string) (*config.MyAnimeListConfig, error) { - data := url.Values{} - data.Set("grant_type", "authorization_code") - data.Set("client_id", config.MALClientID) - data.Set("client_secret", config.MALClientSecret) - data.Set("redirect_uri", config.MALRedirectURI) - data.Set("code", code) - - if codeVerifier != "" { - data.Set("code_verifier", codeVerifier) - } - - // POST request to MAL API - req, err := http.NewRequest("POST", "https://myanimelist.net/v1/oauth2/token", strings.NewReader(data.Encode())) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - var malConfig config.MyAnimeListConfig - var tokenResponse struct { - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - } - - if err := json.Unmarshal(body, &tokenResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - malConfig.TokenType = tokenResponse.TokenType - malConfig.ExpiresIn = tokenResponse.ExpiresIn - malConfig.AccessToken = tokenResponse.AccessToken - malConfig.RefreshToken = tokenResponse.RefreshToken - - return &malConfig, nil -} -- cgit v1.2.3