Add initial crypto widget
This commit is contained in:
parent
7743664527
commit
e6272e35ee
7 changed files with 280 additions and 0 deletions
|
@ -25,6 +25,7 @@ var (
|
|||
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
||||
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
||||
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
|
||||
CryptoTemplate = compileTemplate("crypto.html", "widget-base.html")
|
||||
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
||||
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
|
||||
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
||||
|
|
39
internal/assets/templates/crypto.html
Normal file
39
internal/assets/templates/crypto.html
Normal file
|
@ -0,0 +1,39 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||
<ul class="list list-gap-20 list-with-separator">
|
||||
{{ range .Cryptos }}
|
||||
<li class="flex items-center gap-15">
|
||||
{{ template "crypto" . }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ else }}
|
||||
<div class="dynamic-columns">
|
||||
{{ range .Cryptos }}
|
||||
<div class="flex items-center gap-15">
|
||||
{{ template "crypto" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "crypto" }}
|
||||
<div class="shrink min-width-0">
|
||||
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
|
||||
<div class="text-truncate">{{ .Name }}</div>
|
||||
</div>
|
||||
|
||||
<a class="stock-chart" {{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
||||
<svg class="stock-chart shrink-0" viewBox="0 0 100 50">
|
||||
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<div class="stock-values shrink-0">
|
||||
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
||||
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
|
||||
</div>
|
||||
{{ end }}
|
99
internal/feed/coingecko.go
Normal file
99
internal/feed/coingecko.go
Normal file
|
@ -0,0 +1,99 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type coingeckoMarketChartResponse struct {
|
||||
Prices [][]float64 `json:"prices"`
|
||||
MarketCaps [][]float64 `json:"market_caps"`
|
||||
TotalVolumes [][]float64 `json:"total_volumes"`
|
||||
}
|
||||
|
||||
func fetchSingle(crypto *Cryptocurrency, days int) error {
|
||||
if crypto.Days == 0 {
|
||||
crypto.Days = days
|
||||
}
|
||||
|
||||
if crypto.Currency == "" {
|
||||
crypto.Currency = "usd"
|
||||
}
|
||||
|
||||
if crypto.SymbolLink == "" {
|
||||
crypto.SymbolLink = fmt.Sprintf("https://www.coingecko.com/en/coins/%s", crypto.ID)
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("https://api.coingecko.com/api/v3/coins/%s/market_chart?vs_currency=%s&days=%d", crypto.ID, crypto.Currency, crypto.Days)
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// perform request
|
||||
resp, err := defaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code %d for %s", resp.StatusCode, reqURL)
|
||||
}
|
||||
|
||||
var response coingeckoMarketChartResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(response.Prices) == 0 {
|
||||
return fmt.Errorf("no data in response for %s", reqURL)
|
||||
}
|
||||
|
||||
crypto.Price = response.Prices[len(response.Prices)-1][1]
|
||||
|
||||
// calculate the percent change
|
||||
crypto.PercentChange = percentChange(crypto.Price, response.Prices[0][1])
|
||||
|
||||
var prices []float64
|
||||
for _, price := range response.Prices {
|
||||
prices = append(prices, price[1])
|
||||
}
|
||||
|
||||
crypto.SvgChartPoints = SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
||||
|
||||
// finally convert the currency into the proper symbol
|
||||
crypto.Currency = currencyCodeToSymbol(crypto.Currency)
|
||||
return nil
|
||||
}
|
||||
|
||||
func FetchCryptoDataFromCoinGecko(cryptos Cryptocurrencies, days int) (Cryptocurrencies, error) {
|
||||
// truncate down to 30 cryptos
|
||||
// this is to prevent the rate limit from being hit
|
||||
// as the free tier of the CoinGecko API only allows 30 requests per minute
|
||||
|
||||
if len(cryptos) > 30 {
|
||||
cryptos = cryptos[:30]
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(cryptos))
|
||||
|
||||
for i := range cryptos {
|
||||
go func(crypto *Cryptocurrency) {
|
||||
defer wg.Done()
|
||||
err := fetchSingle(crypto, days)
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch crypto data", "error", err)
|
||||
}
|
||||
}(&cryptos[i])
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return cryptos, nil
|
||||
}
|
|
@ -209,3 +209,23 @@ func (v Videos) SortByNewest() Videos {
|
|||
|
||||
return v
|
||||
}
|
||||
|
||||
type Cryptocurrency struct {
|
||||
Name string `yaml:"name"`
|
||||
ID string `yaml:"id"` // see https://api.coingecko.com/api/v3/coins/list for a list of ids
|
||||
Currency string `yaml:"currency"` // Currency is the currency to convert the price to
|
||||
Days int `yaml:"days"` // Days is the number of days to fetch the price for. If 0, use the stock's default of 21 days
|
||||
SymbolLink string `yaml:"symbol-link"` // SymbolLink is the link to the symbol. Defaults to CoinGecko
|
||||
Symbol string `yaml:"symbol"` // Symbol is for display
|
||||
Price float64 `yaml:"-"`
|
||||
PercentChange float64 `yaml:"-"`
|
||||
SvgChartPoints string `yaml:"-"`
|
||||
}
|
||||
|
||||
type Cryptocurrencies []Cryptocurrency
|
||||
|
||||
func (t Cryptocurrencies) SortByPercentChange() {
|
||||
sort.Slice(t, func(i, j int) bool {
|
||||
return t[i].PercentChange > t[j].PercentChange
|
||||
})
|
||||
}
|
||||
|
|
|
@ -77,3 +77,76 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
|
|||
|
||||
return values
|
||||
}
|
||||
|
||||
func currencyCodeToSymbol(code string) string {
|
||||
currencySymbols := map[string]string{
|
||||
"btc": "₿",
|
||||
"eth": "Ξ",
|
||||
"ltc": "Ł",
|
||||
"bch": "₡",
|
||||
"bnb": "Ƀ",
|
||||
"eos": "", // no symbol
|
||||
"xrp": "", // no symbol
|
||||
"xlm": "", // no symbol
|
||||
"link": "", // no symbol
|
||||
"dot": "", // no symbol
|
||||
"yfi": "", // no symbol
|
||||
"usd": "$",
|
||||
"aed": "د.إ",
|
||||
"ars": "$",
|
||||
"aud": "AU$",
|
||||
"bdt": "৳",
|
||||
"bhd": ".د.ب",
|
||||
"bmd": "$",
|
||||
"brl": "R$",
|
||||
"cad": "CA$",
|
||||
"chf": "CHF",
|
||||
"clp": "$",
|
||||
"cny": "¥",
|
||||
"czk": "Kč",
|
||||
"dkk": "kr",
|
||||
"eur": "€",
|
||||
"gbp": "£",
|
||||
"gel": "ლ",
|
||||
"hkd": "HK$",
|
||||
"huf": "Ft",
|
||||
"idr": "Rp",
|
||||
"ils": "₪",
|
||||
"inr": "₹",
|
||||
"jpy": "¥",
|
||||
"krw": "₩",
|
||||
"kwd": "د.ك",
|
||||
"lkr": "Rs",
|
||||
"mmk": "K",
|
||||
"mxn": "$",
|
||||
"myr": "RM",
|
||||
"ngn": "₦",
|
||||
"nok": "kr",
|
||||
"nzd": "NZ$",
|
||||
"php": "₱",
|
||||
"pkr": "Rs",
|
||||
"pln": "zł",
|
||||
"rub": "₽",
|
||||
"sar": "ر.س",
|
||||
"sek": "kr",
|
||||
"sgd": "SGD",
|
||||
"thb": "฿",
|
||||
"try": "₺",
|
||||
"twd": "NT$",
|
||||
"uah": "₴",
|
||||
"vef": "Bs",
|
||||
"vnd": "₫",
|
||||
"zar": "R",
|
||||
"xdr": "", // no symbol
|
||||
"xag": "", // no symbol
|
||||
"xau": "", // no symbol
|
||||
"bits": "", // no symbol
|
||||
"sats": "", // no symbol
|
||||
}
|
||||
|
||||
symbol, ok := currencySymbols[code]
|
||||
if !ok {
|
||||
symbol = strings.ToUpper(code) + " "
|
||||
}
|
||||
return symbol
|
||||
}
|
||||
|
|
46
internal/widget/crypto.go
Normal file
46
internal/widget/crypto.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Crypto struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Cryptos feed.Cryptocurrencies `yaml:"cryptos"`
|
||||
Sort string `yaml:"sort-by"`
|
||||
Style string `yaml:"style"`
|
||||
Days int `yaml:"days"`
|
||||
}
|
||||
|
||||
func (widget *Crypto) Initialize() error {
|
||||
widget.withTitle("Crypto").withCacheDuration(time.Hour)
|
||||
|
||||
if widget.Days == 0 {
|
||||
widget.Days = 1
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Crypto) Update(ctx context.Context) {
|
||||
cryptos, err := feed.FetchCryptoDataFromCoinGecko(widget.Cryptos, widget.Days)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if widget.Sort == "percent-change" {
|
||||
cryptos.SortByPercentChange()
|
||||
}
|
||||
|
||||
widget.Cryptos = cryptos
|
||||
}
|
||||
|
||||
func (widget *Crypto) Render() template.HTML {
|
||||
return widget.render(widget, assets.CryptoTemplate)
|
||||
}
|
|
@ -33,6 +33,8 @@ func New(widgetType string) (Widget, error) {
|
|||
return &Videos{}, nil
|
||||
case "stocks":
|
||||
return &Stocks{}, nil
|
||||
case "crypto":
|
||||
return &Crypto{}, nil
|
||||
case "reddit":
|
||||
return &Reddit{}, nil
|
||||
case "rss":
|
||||
|
|
Loading…
Add table
Reference in a new issue