Merge 662dbb9f3a
into 7ce87c7168
This commit is contained in:
commit
a699d66b72
10 changed files with 346 additions and 0 deletions
|
@ -16,6 +16,7 @@
|
|||
* Clock
|
||||
* Calendar
|
||||
* Stocks
|
||||
* Cryptocurrencies
|
||||
* iframe
|
||||
* Twitch channels & top games
|
||||
* GitHub releases
|
||||
|
|
|
@ -1314,6 +1314,72 @@ The link to go to when clicking on the symbol.
|
|||
`chart-link`
|
||||
The link to go to when clicking on the chart.
|
||||
|
||||
### Crypto
|
||||
Display a list of cryptocurrencies, their current value, change for the day and a small single day chart. Data is taken from CoinGecko.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
- type: crypto
|
||||
cryptos:
|
||||
- name: Bitcoin
|
||||
symbol: BTC
|
||||
id: bitcoin
|
||||
- name: Ethereum
|
||||
symbol: ETH
|
||||
id: ethereum
|
||||
- name: Avalanche
|
||||
symbol: AVAX
|
||||
id: avalanche-2
|
||||
- name: Solana
|
||||
symbol: SOL
|
||||
id: solana
|
||||
- name: Dogecoin
|
||||
symbol: DOGE
|
||||
id: dogecoin
|
||||
```
|
||||
|
||||
Preview:
|
||||

|
||||
|
||||
#### Properties
|
||||
| Name | Type | Required | Default |
|
||||
| ---- | ---- | -------- | ------- |
|
||||
| cryptos | array | yes | |
|
||||
| style | string | no | |
|
||||
| sort-by | string | no | |
|
||||
| days | string | no | 1 |
|
||||
|
||||
#### Properties for each cryptocurrency
|
||||
| Name | Type | Required | Default |
|
||||
| ---- | ---- | -------- | ------- |
|
||||
| symbol | string | yes | |
|
||||
| name | string | yes | |
|
||||
| id | string | yes | |
|
||||
| currency | string | no | usd |
|
||||
| symbol-link | string | no | |
|
||||
| days | string | no | |
|
||||
|
||||
##### `cryptos`
|
||||
An array of cryptocurrencies for which to display information about.
|
||||
|
||||
##### `currency`
|
||||
The currency in which to display the value of the cryptocurrency. Possible values are `usd`, `eur`, `gbp`, `jpy`, `cny`, `krw`, `inr`, `rub`, `brl`, `cad`, `aud`, `chf`, `hkd`, `twd`, `sgd`, `thb`, `idr`, `myr`, `php`, `zar`, `sek`, `nok`, `dkk`, `pln`, `huf`, `czk`, `ils`, `try`, `clp`, `mxn`, `php`, `cop`, `ars`, `pen`, `vef`, `ngn`, `kes`, `egp`, `aed`, `sar`, `qar`, `omr`, `kwd`, `bhd`, `jod`, `ils`, `lbp`, `jmd`, `tt`, `ttd`, `bsd`, `gyd`, `htg`, `npr`, `lkr`, `mvr`, `mur`, `scr`, `xaf`, `xof`, `xpf`, `xdr`, `xag`, `xau`, `bits`, `sats`.
|
||||
|
||||
##### `symbol-link`
|
||||
The link to go to when clicking on the symbol. Defaults to the CoinGecko page for the cryptocurrency.
|
||||
|
||||
##### `days`
|
||||
Number of days to show in the chart.
|
||||
|
||||
##### `symbol`
|
||||
The symbol of the cryptocurrency.
|
||||
|
||||
##### `name`
|
||||
The name of the cryptocurrency.
|
||||
|
||||
##### `id`
|
||||
The ID of the cryptocurrency as seen on CoinGecko.
|
||||
|
||||
### Twitch Channels
|
||||
Display a list of channels from Twitch.
|
||||
|
||||
|
|
BIN
docs/images/crypto-widget-preview.png
Normal file
BIN
docs/images/crypto-widget-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 74 KiB |
|
@ -26,6 +26,7 @@ var (
|
|||
ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
|
||||
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
||||
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
||||
CryptoTemplate = compileTemplate("crypto.html", "widget-base.html")
|
||||
MarketsTemplate = compileTemplate("markets.html", "widget-base.html")
|
||||
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
||||
RSSDetailedListTemplate = compileTemplate("rss-detailed-list.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
|
||||
}
|
|
@ -214,3 +214,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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -79,6 +79,78 @@ 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
|
||||
}
|
||||
|
||||
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
|
||||
|
||||
|
|
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)
|
||||
}
|
|
@ -35,6 +35,8 @@ func New(widgetType string) (Widget, error) {
|
|||
return &Releases{}, nil
|
||||
case "videos":
|
||||
return &Videos{}, nil
|
||||
case "crypto":
|
||||
return &Crypto{}, nil
|
||||
case "markets", "stocks":
|
||||
return &Markets{}, nil
|
||||
case "reddit":
|
||||
|
|
Loading…
Add table
Reference in a new issue