mirror of
https://github.com/glanceapp/glance.git
synced 2025-02-23 22:01:09 +01:00
Merge branch 'release/v0.7.0' into main
This commit is contained in:
commit
becf34b0d9
@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.22.5-alpine3.20 AS builder
|
FROM golang:1.23.1-alpine3.20 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . /app
|
COPY . /app
|
||||||
|
@ -15,6 +15,8 @@
|
|||||||
- [Reddit](#reddit)
|
- [Reddit](#reddit)
|
||||||
- [Search](#search-widget)
|
- [Search](#search-widget)
|
||||||
- [Group](#group)
|
- [Group](#group)
|
||||||
|
- [Split Column](#split-column)
|
||||||
|
- [Custom API](#custom-api)
|
||||||
- [Extension](#extension)
|
- [Extension](#extension)
|
||||||
- [Weather](#weather)
|
- [Weather](#weather)
|
||||||
- [Monitor](#monitor)
|
- [Monitor](#monitor)
|
||||||
@ -525,10 +527,22 @@ An array of RSS/atom feeds. The title can optionally be changed.
|
|||||||
| hide-categories | boolean | no | false | Only applicable for `detailed-list` style |
|
| hide-categories | boolean | no | false | Only applicable for `detailed-list` style |
|
||||||
| hide-description | boolean | no | false | Only applicable for `detailed-list` style |
|
| hide-description | boolean | no | false | Only applicable for `detailed-list` style |
|
||||||
| item-link-prefix | string | no | | |
|
| item-link-prefix | string | no | | |
|
||||||
|
| headers | key (string) & value (string) | no | | |
|
||||||
|
|
||||||
###### `item-link-prefix`
|
###### `item-link-prefix`
|
||||||
If an RSS feed isn't returning item links with a base domain and Glance has failed to automatically detect the correct domain you can manually add a prefix to each link with this property.
|
If an RSS feed isn't returning item links with a base domain and Glance has failed to automatically detect the correct domain you can manually add a prefix to each link with this property.
|
||||||
|
|
||||||
|
###### `headers`
|
||||||
|
Optionally specify the headers that will be sent with the request. Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: rss
|
||||||
|
feeds:
|
||||||
|
- url: https://domain.com/rss
|
||||||
|
headers:
|
||||||
|
User-Agent: Custom User Agent
|
||||||
|
```
|
||||||
|
|
||||||
##### `limit`
|
##### `limit`
|
||||||
The maximum number of articles to show.
|
The maximum number of articles to show.
|
||||||
|
|
||||||
@ -890,7 +904,7 @@ url: https://www.amazon.com/s?k={QUERY}
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Group
|
### Group
|
||||||
Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget within a group widget.
|
Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget or a split column widget within a group widget.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@ -933,6 +947,67 @@ Example:
|
|||||||
<<: *shared-properties
|
<<: *shared-properties
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Split Column
|
||||||
|
<!-- TODO: update -->
|
||||||
|
Splits a full sized column in half, allowing you to place widgets side by side. This is converted to a single column on mobile devices or if not enough width is available. Widgets are defined using a `widgets` property exactly as you would on a page column.
|
||||||
|
|
||||||
|
Example of a full page with an effective 4 column layout using two split column widgets inside of two full sized columns:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>View config</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
shared:
|
||||||
|
- &reddit-props
|
||||||
|
type: reddit
|
||||||
|
collapse-after: 4
|
||||||
|
show-thumbnails: true
|
||||||
|
|
||||||
|
pages:
|
||||||
|
- name: Split Column Demo
|
||||||
|
width: wide
|
||||||
|
columns:
|
||||||
|
- size: full
|
||||||
|
widgets:
|
||||||
|
- type: split-column
|
||||||
|
widgets:
|
||||||
|
- subreddit: gaming
|
||||||
|
<<: *reddit-props
|
||||||
|
- subreddit: worldnews
|
||||||
|
<<: *reddit-props
|
||||||
|
- subreddit: lifeprotips
|
||||||
|
<<: *reddit-props
|
||||||
|
show-thumbnails: false
|
||||||
|
- subreddit: askreddit
|
||||||
|
<<: *reddit-props
|
||||||
|
show-thumbnails: false
|
||||||
|
|
||||||
|
- size: full
|
||||||
|
widgets:
|
||||||
|
- type: split-column
|
||||||
|
widgets:
|
||||||
|
- subreddit: todayilearned
|
||||||
|
<<: *reddit-props
|
||||||
|
collapse-after: 2
|
||||||
|
- subreddit: aww
|
||||||
|
<<: *reddit-props
|
||||||
|
- subreddit: science
|
||||||
|
<<: *reddit-props
|
||||||
|
- subreddit: showerthoughts
|
||||||
|
<<: *reddit-props
|
||||||
|
show-thumbnails: false
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Preview:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Custom API
|
||||||
|
<!-- TODO -->
|
||||||
|
|
||||||
### Extension
|
### Extension
|
||||||
Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP).
|
Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP).
|
||||||
|
|
||||||
@ -948,12 +1023,16 @@ Display a widget provided by an external source (3rd party). If you want to lear
|
|||||||
| Name | Type | Required | Default |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| url | string | yes | |
|
| url | string | yes | |
|
||||||
|
| fallback-content-type | string | no | |
|
||||||
| allow-potentially-dangerous-html | boolean | no | false |
|
| allow-potentially-dangerous-html | boolean | no | false |
|
||||||
| parameters | key & value | no | |
|
| parameters | key & value | no | |
|
||||||
|
|
||||||
##### `url`
|
##### `url`
|
||||||
The URL of the extension.
|
The URL of the extension.
|
||||||
|
|
||||||
|
##### `fallback-content-type`
|
||||||
|
Optionally specify the fallback content type of the extension if the URL does not return a valid `Widget-Content-Type` header. Currently the only supported value for this property is `html`.
|
||||||
|
|
||||||
##### `allow-potentially-dangerous-html`
|
##### `allow-potentially-dangerous-html`
|
||||||
Whether to allow the extension to display HTML.
|
Whether to allow the extension to display HTML.
|
||||||
|
|
||||||
@ -1082,6 +1161,7 @@ Properties for each site:
|
|||||||
| icon | string | no | |
|
| icon | string | no | |
|
||||||
| allow-insecure | boolean | no | false |
|
| allow-insecure | boolean | no | false |
|
||||||
| same-tab | boolean | no | false |
|
| same-tab | boolean | no | false |
|
||||||
|
| alt-status-codes | array | no | |
|
||||||
|
|
||||||
`title`
|
`title`
|
||||||
|
|
||||||
@ -1107,7 +1187,7 @@ icon: si:adguard
|
|||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
>
|
>
|
||||||
> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
|
> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
|
||||||
|
|
||||||
`allow-insecure`
|
`allow-insecure`
|
||||||
|
|
||||||
@ -1117,6 +1197,15 @@ Whether to ignore invalid/self-signed certificates.
|
|||||||
|
|
||||||
Whether to open the link in the same or a new tab.
|
Whether to open the link in the same or a new tab.
|
||||||
|
|
||||||
|
`alt-status-codes`
|
||||||
|
|
||||||
|
Status codes other than 200 that you want to return "OK".
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alt-status-codes:
|
||||||
|
- 403
|
||||||
|
```
|
||||||
|
|
||||||
### Releases
|
### Releases
|
||||||
Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
|
Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
|
||||||
|
|
||||||
@ -1385,7 +1474,7 @@ icon: si:reddit
|
|||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
>
|
>
|
||||||
> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
|
> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
|
||||||
|
|
||||||
`same-tab`
|
`same-tab`
|
||||||
|
|
||||||
@ -1539,7 +1628,7 @@ Preview:
|
|||||||
An array of markets for which to display information about.
|
An array of markets for which to display information about.
|
||||||
|
|
||||||
##### `sort-by`
|
##### `sort-by`
|
||||||
By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change.
|
By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `change` for descending order based on the stock's percentage change (e.g. 1% would be sorted higher than -1%) or `absolute-change` for descending order based on the stock's absolute price change (e.g. -1% would be sorted higher than +0.5%).
|
||||||
|
|
||||||
###### Properties for each stock
|
###### Properties for each stock
|
||||||
| Name | Type | Required |
|
| Name | Type | Required |
|
||||||
|
BIN
docs/images/split-column-widget-preview.png
Normal file
BIN
docs/images/split-column-widget-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 328 KiB |
11
go.mod
11
go.mod
@ -1,19 +1,22 @@
|
|||||||
module github.com/glanceapp/glance
|
module github.com/glanceapp/glance
|
||||||
|
|
||||||
go 1.22.5
|
go 1.23.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/mmcdole/gofeed v1.3.0
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
golang.org/x/text v0.16.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
|
golang.org/x/text v0.18.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.9.2 // indirect
|
github.com/PuerkitoBio/goquery v1.10.0 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/mmcdole/goxpp v1.1.1 // indirect
|
github.com/mmcdole/goxpp v1.1.1 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
golang.org/x/net v0.27.0 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
golang.org/x/net v0.29.0 // indirect
|
||||||
)
|
)
|
||||||
|
19
go.sum
19
go.sum
@ -1,5 +1,5 @@
|
|||||||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
|
||||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
|
||||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@ -23,6 +23,13 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
@ -33,8 +40,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
|||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@ -54,8 +61,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { setupPopovers } from './popover.js';
|
import { setupPopovers } from './popover.js';
|
||||||
|
import { setupMasonries } from './masonry.js';
|
||||||
import { throttledDebounce, isElementVisible } from './utils.js';
|
import { throttledDebounce, isElementVisible } from './utils.js';
|
||||||
|
|
||||||
async function fetchPageContent(pageData) {
|
async function fetchPageContent(pageData) {
|
||||||
@ -502,9 +503,34 @@ function timeInZone(now, zone) {
|
|||||||
timeInZone = now
|
timeInZone = now
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
|
const diffInMinutes = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60);
|
||||||
|
|
||||||
return { time: timeInZone, diffInHours: diffInHours };
|
return { time: timeInZone, diffInMinutes: diffInMinutes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneDiffText(diffInMinutes) {
|
||||||
|
if (diffInMinutes == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = diffInMinutes < 0 ? "-" : "+";
|
||||||
|
const signText = diffInMinutes < 0 ? "behind" : "ahead";
|
||||||
|
|
||||||
|
diffInMinutes = Math.abs(diffInMinutes);
|
||||||
|
|
||||||
|
const hours = Math.floor(diffInMinutes / 60);
|
||||||
|
const minutes = diffInMinutes % 60;
|
||||||
|
const hourSuffix = hours == 1 ? "" : "s";
|
||||||
|
|
||||||
|
if (minutes == 0) {
|
||||||
|
return { text: `${sign}${hours}h`, title: `${hours} hour${hourSuffix} ${signText}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hours == 0) {
|
||||||
|
return { text: `${sign}${minutes}m`, title: `${minutes} minutes ${signText}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: `${sign}${hours}h~`, title: `${hours} hour${hourSuffix} and ${minutes} minutes ${signText}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupClocks() {
|
function setupClocks() {
|
||||||
@ -547,9 +573,11 @@ function setupClocks() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
updateCallbacks.push((now) => {
|
updateCallbacks.push((now) => {
|
||||||
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
||||||
setZoneTime(time);
|
setZoneTime(time);
|
||||||
diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
|
const { text, title } = zoneDiffText(diffInMinutes);
|
||||||
|
diffElement.textContent = text;
|
||||||
|
diffElement.title = title;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -581,6 +609,7 @@ async function setupPage() {
|
|||||||
setupCollapsibleLists();
|
setupCollapsibleLists();
|
||||||
setupCollapsibleGrids();
|
setupCollapsibleGrids();
|
||||||
setupGroups();
|
setupGroups();
|
||||||
|
setupMasonries();
|
||||||
setupDynamicRelativeTime();
|
setupDynamicRelativeTime();
|
||||||
setupLazyImages();
|
setupLazyImages();
|
||||||
} finally {
|
} finally {
|
||||||
|
53
internal/assets/static/js/masonry.js
Normal file
53
internal/assets/static/js/masonry.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
import { clamp } from "./utils.js";
|
||||||
|
|
||||||
|
export function setupMasonries() {
|
||||||
|
const masonryContainers = document.getElementsByClassName("masonry");
|
||||||
|
|
||||||
|
for (let i = 0; i < masonryContainers.length; i++) {
|
||||||
|
const container = masonryContainers[i];
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
minColumnWidth: container.dataset.minColumnWidth || 330,
|
||||||
|
maxColumns: container.dataset.maxColumns || 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = Array.from(container.children);
|
||||||
|
let previousColumnsCount = 0;
|
||||||
|
|
||||||
|
const render = function() {
|
||||||
|
const columnsCount = clamp(
|
||||||
|
Math.floor(container.offsetWidth / options.minColumnWidth),
|
||||||
|
1,
|
||||||
|
Math.min(options.maxColumns, items.length)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnsCount === previousColumnsCount) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
container.textContent = "";
|
||||||
|
previousColumnsCount = columnsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnsFragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
for (let i = 0; i < columnsCount; i++) {
|
||||||
|
const column = document.createElement("div");
|
||||||
|
column.className = "masonry-column";
|
||||||
|
columnsFragment.append(column);
|
||||||
|
}
|
||||||
|
|
||||||
|
// poor man's masonry
|
||||||
|
// TODO: add an option that allows placing items in the
|
||||||
|
// shortest column instead of iterating the columns in order
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
columnsFragment.children[i % columnsCount].appendChild(items[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.append(columnsFragment);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => requestAnimationFrame(render));
|
||||||
|
observer.observe(container);
|
||||||
|
}
|
||||||
|
}
|
@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showPopover() {
|
function showPopover() {
|
||||||
|
if (pendingTarget === null) return;
|
||||||
|
|
||||||
activeTarget = pendingTarget;
|
activeTarget = pendingTarget;
|
||||||
pendingTarget = null;
|
pendingTarget = null;
|
||||||
|
|
||||||
@ -109,9 +111,10 @@ function repositionContainer() {
|
|||||||
|
|
||||||
const containerBounds = containerElement.getBoundingClientRect();
|
const containerBounds = containerElement.getBoundingClientRect();
|
||||||
const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline"));
|
const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline"));
|
||||||
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverOffset || 0.5);
|
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverTargetOffset || 0.5);
|
||||||
const position = activeTarget.dataset.popoverPosition || "below";
|
const position = activeTarget.dataset.popoverPosition || "below";
|
||||||
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2));
|
const popoverOffest = activeTarget.dataset.popoverOffset || 0.5;
|
||||||
|
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width * popoverOffest));
|
||||||
|
|
||||||
if (left < 0) {
|
if (left < 0) {
|
||||||
containerElement.style.left = 0;
|
containerElement.style.left = 0;
|
||||||
@ -124,7 +127,7 @@ function repositionContainer() {
|
|||||||
} else {
|
} else {
|
||||||
containerElement.style.removeProperty("right");
|
containerElement.style.removeProperty("right");
|
||||||
containerElement.style.left = left + "px";
|
containerElement.style.left = left + "px";
|
||||||
containerElement.style.removeProperty("--triangle-offset");
|
containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + "px");
|
||||||
}
|
}
|
||||||
|
|
||||||
const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;
|
const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;
|
||||||
|
@ -23,3 +23,7 @@ export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
|||||||
export function isElementVisible(element) {
|
export function isElementVisible(element) {
|
||||||
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clamp(value, min, max) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
@ -440,6 +440,17 @@ kbd:active {
|
|||||||
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
|
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.masonry {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--widget-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.masonry-column {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.popover-container, [data-popover-html] {
|
.popover-container, [data-popover-html] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@ -1339,6 +1350,10 @@ details[open] .summary::after {
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clock-time {
|
||||||
|
min-width: 8ch;
|
||||||
|
}
|
||||||
|
|
||||||
.clock-time span {
|
.clock-time span {
|
||||||
color: var(--color-text-highlight);
|
color: var(--color-text-highlight);
|
||||||
}
|
}
|
||||||
@ -1493,6 +1508,14 @@ details[open] .summary::after {
|
|||||||
border: 2px solid var(--color-widget-background);
|
border: 2px solid var(--color-widget-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.twitch-stream-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 400px;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.reddit-card-thumbnail {
|
.reddit-card-thumbnail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -39,9 +39,11 @@ var (
|
|||||||
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
|
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
|
||||||
GroupTemplate = compileTemplate("group.html", "widget-base.html")
|
GroupTemplate = compileTemplate("group.html", "widget-base.html")
|
||||||
DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html")
|
DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html")
|
||||||
|
SplitColumnTemplate = compileTemplate("split-column.html", "widget-base.html")
|
||||||
|
CustomAPITemplate = compileTemplate("custom-api.html", "widget-base.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
var globalTemplateFunctions = template.FuncMap{
|
var GlobalTemplateFunctions = template.FuncMap{
|
||||||
"relativeTime": relativeTimeSince,
|
"relativeTime": relativeTimeSince,
|
||||||
"formatViewerCount": formatViewerCount,
|
"formatViewerCount": formatViewerCount,
|
||||||
"formatNumber": intl.Sprint,
|
"formatNumber": intl.Sprint,
|
||||||
@ -58,7 +60,7 @@ var globalTemplateFunctions = template.FuncMap{
|
|||||||
|
|
||||||
func compileTemplate(primary string, dependencies ...string) *template.Template {
|
func compileTemplate(primary string, dependencies ...string) *template.Template {
|
||||||
t, err := template.New(primary).
|
t, err := template.New(primary).
|
||||||
Funcs(globalTemplateFunctions).
|
Funcs(GlobalTemplateFunctions).
|
||||||
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
|
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
7
internal/assets/templates/custom-api.html
Normal file
7
internal/assets/templates/custom-api.html
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
{{ .CompiledHTML }}
|
||||||
|
{{ end }}
|
@ -6,7 +6,7 @@
|
|||||||
<div class="flex items-center gap-15">
|
<div class="flex items-center gap-15">
|
||||||
<div class="min-width-0">
|
<div class="min-width-0">
|
||||||
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
|
<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 title="{{ .Name }}" class="text-truncate">{{ .Name }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
<div class="mobile-navigation-icons">
|
<div class="mobile-navigation-icons">
|
||||||
<a class="mobile-navigation-label" href="#top">↑</a>
|
<a class="mobile-navigation-label" href="#top">↑</a>
|
||||||
{{ range $i, $column := .Page.Columns }}
|
{{ range $i, $column := .Page.Columns }}
|
||||||
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
|
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-10 collapsible-container single-line-titles" data-collapse-after="{{ .CollapseAfter }}">
|
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range .Releases }}
|
{{ range .Releases }}
|
||||||
<li>
|
<li>
|
||||||
<div class="flex items-center gap-10">
|
<div class="flex items-center gap-10">
|
||||||
|
11
internal/assets/templates/split-column.html
Normal file
11
internal/assets/templates/split-column.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="masonry" data-max-columns="{{ .MaxColumns }}">
|
||||||
|
{{ range .Widgets }}
|
||||||
|
{{ .Render }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
@ -5,9 +5,15 @@
|
|||||||
{{ range .Channels }}
|
{{ range .Channels }}
|
||||||
<li>
|
<li>
|
||||||
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
|
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
|
||||||
<div class="twitch-channel-avatar-container">
|
<div class="twitch-channel-avatar-container"{{ if .IsLive }} data-popover-type="html" data-popover-position="above" data-popover-margin="0.15rem" data-popover-offset="0.2"{{ end }}>
|
||||||
|
{{ if .IsLive }}
|
||||||
|
<div data-popover-html>
|
||||||
|
<img class="twitch-stream-preview" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_{{ .Login }}-440x248.jpg" loading="lazy" alt="">
|
||||||
|
<p class="margin-top-10 color-highlight text-truncate-3-lines">{{ .StreamTitle }}</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
{{ if .Exists }}
|
{{ if .Exists }}
|
||||||
<a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
|
<a href="https://twitch.tv/{{ .Login }}" target="_blank" rel="noreferrer">
|
||||||
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
|
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
|
148
internal/feed/custom-api.go
Normal file
148
internal/feed/custom-api.go
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
|
||||||
|
emptyBody := template.HTML("")
|
||||||
|
|
||||||
|
resp, err := defaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return emptyBody, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return emptyBody, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body := string(bodyBytes)
|
||||||
|
|
||||||
|
if !gjson.Valid(body) {
|
||||||
|
truncatedBody, isTruncated := limitStringLength(body, 100)
|
||||||
|
if isTruncated {
|
||||||
|
truncatedBody += "... <truncated>"
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Error("invalid response JSON in custom API widget", "URL", req.URL.String(), "body", truncatedBody)
|
||||||
|
return emptyBody, errors.New("invalid response JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
var templateBuffer bytes.Buffer
|
||||||
|
|
||||||
|
data := CustomAPITemplateData{
|
||||||
|
JSON: DecoratedGJSONResult{gjson.Parse(body)},
|
||||||
|
Response: resp,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tmpl.Execute(&templateBuffer, &data)
|
||||||
|
if err != nil {
|
||||||
|
return emptyBody, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.HTML(templateBuffer.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecoratedGJSONResult struct {
|
||||||
|
gjson.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomAPITemplateData struct {
|
||||||
|
JSON DecoratedGJSONResult
|
||||||
|
Response *http.Response
|
||||||
|
}
|
||||||
|
|
||||||
|
func GJsonResultArrayToDecoratedResultArray(results []gjson.Result) []DecoratedGJSONResult {
|
||||||
|
decoratedResults := make([]DecoratedGJSONResult, len(results))
|
||||||
|
|
||||||
|
for i, result := range results {
|
||||||
|
decoratedResults[i] = DecoratedGJSONResult{result}
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoratedResults
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DecoratedGJSONResult) Array(key string) []DecoratedGJSONResult {
|
||||||
|
if key == "" {
|
||||||
|
return GJsonResultArrayToDecoratedResultArray(r.Result.Array())
|
||||||
|
}
|
||||||
|
|
||||||
|
return GJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DecoratedGJSONResult) String(key string) string {
|
||||||
|
if key == "" {
|
||||||
|
return r.Result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Get(key).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DecoratedGJSONResult) Int(key string) int64 {
|
||||||
|
if key == "" {
|
||||||
|
return r.Result.Int()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Get(key).Int()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DecoratedGJSONResult) Float(key string) float64 {
|
||||||
|
if key == "" {
|
||||||
|
return r.Result.Float()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Get(key).Float()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DecoratedGJSONResult) Bool(key string) bool {
|
||||||
|
if key == "" {
|
||||||
|
return r.Result.Bool()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Get(key).Bool()
|
||||||
|
}
|
||||||
|
|
||||||
|
var CustomAPITemplateFuncs = func() template.FuncMap {
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"toFloat": func(a int64) float64 {
|
||||||
|
return float64(a)
|
||||||
|
},
|
||||||
|
"toInt": func(a float64) int64 {
|
||||||
|
return int64(a)
|
||||||
|
},
|
||||||
|
"mathexpr": func(left float64, op string, right float64) float64 {
|
||||||
|
if right == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
switch op {
|
||||||
|
case "+":
|
||||||
|
return left + right
|
||||||
|
case "-":
|
||||||
|
return left - right
|
||||||
|
case "*":
|
||||||
|
return left * right
|
||||||
|
case "/":
|
||||||
|
return left / right
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range assets.GlobalTemplateFunctions {
|
||||||
|
funcs[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return funcs
|
||||||
|
}()
|
@ -27,9 +27,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ExtensionRequestOptions struct {
|
type ExtensionRequestOptions struct {
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
Parameters map[string]string `yaml:"parameters"`
|
FallbackContentType string `yaml:"fallback-content-type"`
|
||||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
Parameters map[string]string `yaml:"parameters"`
|
||||||
|
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Extension struct {
|
type Extension struct {
|
||||||
@ -88,7 +89,11 @@ func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
|
|||||||
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
|
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
contentType = ExtensionContentUnknown
|
contentType, ok = ExtensionStringToType[options.FallbackContentType]
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
contentType = ExtensionContentUnknown
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension.Content = convertExtensionContent(options, body, contentType)
|
extension.Content = convertExtensionContent(options, body, contentType)
|
||||||
|
@ -189,12 +189,19 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
|
|||||||
minT := slices.Min(temperatures)
|
minT := slices.Min(temperatures)
|
||||||
maxT := slices.Max(temperatures)
|
maxT := slices.Max(temperatures)
|
||||||
|
|
||||||
|
temperaturesRange := float64(maxT - minT)
|
||||||
|
|
||||||
for i := 0; i < 12; i++ {
|
for i := 0; i < 12; i++ {
|
||||||
bars = append(bars, weatherColumn{
|
bars = append(bars, weatherColumn{
|
||||||
Temperature: temperatures[i],
|
Temperature: temperatures[i],
|
||||||
Scale: float64(temperatures[i]-minT) / float64(maxT-minT),
|
|
||||||
HasPrecipitation: precipitations[i],
|
HasPrecipitation: precipitations[i],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if temperaturesRange > 0 {
|
||||||
|
bars[i].Scale = float64(temperatures[i]-minT) / temperaturesRange
|
||||||
|
} else {
|
||||||
|
bars[i].Scale = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,6 +133,12 @@ func (t Markets) SortByAbsChange() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t Markets) SortByChange() {
|
||||||
|
sort.Slice(t, func(i, j int) bool {
|
||||||
|
return t[i].PercentChange > t[j].PercentChange
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var weatherCodeTable = map[int]string{
|
var weatherCodeTable = map[int]string{
|
||||||
0: "Clear Sky",
|
0: "Clear Sky",
|
||||||
1: "Mainly Clear",
|
1: "Mainly Clear",
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
package feed
|
package feed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@ -57,12 +58,13 @@ func shortenFeedDescriptionLen(description string, maxLen int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RSSFeedRequest struct {
|
type RSSFeedRequest struct {
|
||||||
Url string `yaml:"url"`
|
Url string `yaml:"url"`
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
HideCategories bool `yaml:"hide-categories"`
|
HideCategories bool `yaml:"hide-categories"`
|
||||||
HideDescription bool `yaml:"hide-description"`
|
HideDescription bool `yaml:"hide-description"`
|
||||||
ItemLinkPrefix string `yaml:"item-link-prefix"`
|
ItemLinkPrefix string `yaml:"item-link-prefix"`
|
||||||
IsDetailed bool `yaml:"-"`
|
Headers map[string]string `yaml:"headers"`
|
||||||
|
IsDetailed bool `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RSSFeedItems []RSSFeedItem
|
type RSSFeedItems []RSSFeedItem
|
||||||
@ -78,10 +80,31 @@ func (f RSSFeedItems) SortByNewest() RSSFeedItems {
|
|||||||
var feedParser = gofeed.NewParser()
|
var feedParser = gofeed.NewParser()
|
||||||
|
|
||||||
func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
req, err := http.NewRequest("GET", request.Url, nil)
|
||||||
defer cancel()
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
feed, err := feedParser.ParseURLWithContext(request.Url, ctx)
|
for key, value := range request.Headers {
|
||||||
|
req.Header.Add(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := defaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, request.Url)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
feed, err := feedParser.ParseString(string(body))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -28,6 +28,7 @@ type TwitchChannel struct {
|
|||||||
Login string
|
Login string
|
||||||
Exists bool
|
Exists bool
|
||||||
Name string
|
Name string
|
||||||
|
StreamTitle string
|
||||||
AvatarUrl string
|
AvatarUrl string
|
||||||
IsLive bool
|
IsLive bool
|
||||||
LiveSince time.Time
|
LiveSince time.Time
|
||||||
@ -77,6 +78,9 @@ type twitchStreamMetadataOperationResponse struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"game"`
|
} `json:"game"`
|
||||||
} `json:"stream"`
|
} `json:"stream"`
|
||||||
|
LastBroadcast *struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
} `json:"user"`
|
} `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,7 +146,10 @@ func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, err
|
|||||||
return categories, nil
|
return categories, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}]`
|
const twitchChannelStatusOperationRequestBody = `[
|
||||||
|
{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},
|
||||||
|
{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}
|
||||||
|
]`
|
||||||
|
|
||||||
// TODO: rework
|
// TODO: rework
|
||||||
// The operations for multiple channels can all be sent in a single request
|
// The operations for multiple channels can all be sent in a single request
|
||||||
@ -205,6 +212,10 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
|
|||||||
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
|
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
|
||||||
|
|
||||||
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
|
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
|
||||||
|
if streamMetadata.UserOrNull.LastBroadcast != nil {
|
||||||
|
result.StreamTitle = streamMetadata.UserOrNull.LastBroadcast.Title
|
||||||
|
}
|
||||||
|
|
||||||
if streamMetadata.UserOrNull.Stream.Game != nil {
|
if streamMetadata.UserOrNull.Stream.Game != nil {
|
||||||
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
|
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
|
||||||
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
|
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
|
||||||
|
@ -75,6 +75,7 @@ type Page struct {
|
|||||||
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
||||||
CenterVertically bool `yaml:"center-vertically"`
|
CenterVertically bool `yaml:"center-vertically"`
|
||||||
Columns []Column `yaml:"columns"`
|
Columns []Column `yaml:"columns"`
|
||||||
|
PrimaryColumnIndex int8 `yaml:"-"`
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,15 +141,24 @@ func NewApplication(config *Config) (*Application, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for p := range config.Pages {
|
for p := range config.Pages {
|
||||||
if config.Pages[p].Slug == "" {
|
page := &config.Pages[p]
|
||||||
config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
|
page.PrimaryColumnIndex = -1
|
||||||
|
|
||||||
|
if page.Slug == "" {
|
||||||
|
page.Slug = titleToSlug(page.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
|
app.slugToPage[page.Slug] = page
|
||||||
|
|
||||||
for c := range config.Pages[p].Columns {
|
for c := range page.Columns {
|
||||||
for w := range config.Pages[p].Columns[c].Widgets {
|
column := &page.Columns[c]
|
||||||
widget := config.Pages[p].Columns[c].Widgets[w]
|
|
||||||
|
if page.PrimaryColumnIndex == -1 && column.Size == "full" {
|
||||||
|
page.PrimaryColumnIndex = int8(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
for w := range column.Widgets {
|
||||||
|
widget := column.Widgets[w]
|
||||||
app.widgetByID[widget.GetID()] = widget
|
app.widgetByID[widget.GetID()] = widget
|
||||||
|
|
||||||
widget.SetProviders(providers)
|
widget.SetProviders(providers)
|
||||||
|
48
internal/widget/container.go
Normal file
48
internal/widget/container.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type containerWidgetBase struct {
|
||||||
|
Widgets Widgets `yaml:"widgets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *containerWidgetBase) Update(ctx context.Context) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for w := range widget.Widgets {
|
||||||
|
widget := widget.Widgets[w]
|
||||||
|
|
||||||
|
if !widget.RequiresUpdate(&now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
widget.Update(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *containerWidgetBase) SetProviders(providers *Providers) {
|
||||||
|
for i := range widget.Widgets {
|
||||||
|
widget.Widgets[i].SetProviders(providers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *containerWidgetBase) RequiresUpdate(now *time.Time) bool {
|
||||||
|
for i := range widget.Widgets {
|
||||||
|
if widget.Widgets[i].RequiresUpdate(now) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
70
internal/widget/custom-api.go
Normal file
70
internal/widget/custom-api.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
"github.com/glanceapp/glance/internal/feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomApi struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
URL OptionalEnvString `yaml:"url"`
|
||||||
|
Template string `yaml:"template"`
|
||||||
|
Frameless bool `yaml:"frameless"`
|
||||||
|
Headers map[string]OptionalEnvString `yaml:"headers"`
|
||||||
|
APIRequest *http.Request `yaml:"-"`
|
||||||
|
compiledTemplate *template.Template `yaml:"-"`
|
||||||
|
CompiledHTML template.HTML `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *CustomApi) Initialize() error {
|
||||||
|
widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
|
||||||
|
|
||||||
|
if widget.URL == "" {
|
||||||
|
return errors.New("URL is required for the custom API widget")
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Template == "" {
|
||||||
|
return errors.New("template is required for the custom API widget")
|
||||||
|
}
|
||||||
|
|
||||||
|
compiledTemplate, err := template.New("").Funcs(feed.CustomAPITemplateFuncs).Parse(widget.Template)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed parsing custom API widget template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.compiledTemplate = compiledTemplate
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, widget.URL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range widget.Headers {
|
||||||
|
req.Header.Add(key, value.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.APIRequest = req
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *CustomApi) Update(ctx context.Context) {
|
||||||
|
compiledHTML, err := feed.FetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.CompiledHTML = compiledHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *CustomApi) Render() template.HTML {
|
||||||
|
return widget.render(widget, assets.CustomAPITemplate)
|
||||||
|
}
|
@ -12,12 +12,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Extension struct {
|
type Extension struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
Parameters map[string]string `yaml:"parameters"`
|
FallbackContentType string `yaml:"fallback-content-type"`
|
||||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
Parameters map[string]string `yaml:"parameters"`
|
||||||
Extension feed.Extension `yaml:"-"`
|
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||||
cachedHTML template.HTML `yaml:"-"`
|
Extension feed.Extension `yaml:"-"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Extension) Initialize() error {
|
func (widget *Extension) Initialize() error {
|
||||||
@ -38,9 +39,10 @@ func (widget *Extension) Initialize() error {
|
|||||||
|
|
||||||
func (widget *Extension) Update(ctx context.Context) {
|
func (widget *Extension) Update(ctx context.Context) {
|
||||||
extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
|
extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
|
||||||
URL: widget.URL,
|
URL: widget.URL,
|
||||||
Parameters: widget.Parameters,
|
FallbackContentType: widget.FallbackContentType,
|
||||||
AllowHtml: widget.AllowHtml,
|
Parameters: widget.Parameters,
|
||||||
|
AllowHtml: widget.AllowHtml,
|
||||||
})
|
})
|
||||||
|
|
||||||
widget.canContinueUpdateAfterHandlingErr(err)
|
widget.canContinueUpdateAfterHandlingErr(err)
|
||||||
|
@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
|
var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
|
||||||
var EnvFieldPattern = regexp.MustCompile(`^\${([A-Z_]+)}$`)
|
var EnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
HSLHueMax = 360
|
HSLHueMax = 360
|
||||||
@ -133,21 +133,42 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := EnvFieldPattern.FindStringSubmatch(value)
|
replaced := EnvFieldPattern.ReplaceAllStringFunc(value, func(whole string) string {
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
if len(matches) != 2 {
|
groups := EnvFieldPattern.FindStringSubmatch(whole)
|
||||||
*f = OptionalEnvString(value)
|
|
||||||
|
|
||||||
return nil
|
if len(groups) != 3 {
|
||||||
|
return whole
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, key := groups[1], groups[2]
|
||||||
|
|
||||||
|
if prefix == `\` {
|
||||||
|
if len(whole) >= 2 {
|
||||||
|
return whole[1:]
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value, found := os.LookupEnv(key)
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
err = fmt.Errorf("environment variable %s not found", key)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix + value
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
value, found := os.LookupEnv(matches[1])
|
*f = OptionalEnvString(replaced)
|
||||||
|
|
||||||
if !found {
|
|
||||||
return fmt.Errorf("environment variable %s not found", matches[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
*f = OptionalEnvString(value)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -4,15 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glanceapp/glance/internal/assets"
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Group struct {
|
type Group struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
Widgets Widgets `yaml:"widgets"`
|
containerWidgetBase `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Group) Initialize() error {
|
func (widget *Group) Initialize() error {
|
||||||
@ -23,7 +22,9 @@ func (widget *Group) Initialize() error {
|
|||||||
widget.Widgets[i].SetHideHeader(true)
|
widget.Widgets[i].SetHideHeader(true)
|
||||||
|
|
||||||
if widget.Widgets[i].GetType() == "group" {
|
if widget.Widgets[i].GetType() == "group" {
|
||||||
return errors.New("nested groups are not allowed")
|
return errors.New("nested groups are not supported")
|
||||||
|
} else if widget.Widgets[i].GetType() == "split-column" {
|
||||||
|
return errors.New("split columns inside of groups are not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := widget.Widgets[i].Initialize(); err != nil {
|
if err := widget.Widgets[i].Initialize(); err != nil {
|
||||||
@ -35,40 +36,15 @@ func (widget *Group) Initialize() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Group) Update(ctx context.Context) {
|
func (widget *Group) Update(ctx context.Context) {
|
||||||
var wg sync.WaitGroup
|
widget.containerWidgetBase.Update(ctx)
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
for w := range widget.Widgets {
|
|
||||||
widget := widget.Widgets[w]
|
|
||||||
|
|
||||||
if !widget.RequiresUpdate(&now) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
widget.Update(ctx)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Group) SetProviders(providers *Providers) {
|
func (widget *Group) SetProviders(providers *Providers) {
|
||||||
for i := range widget.Widgets {
|
widget.containerWidgetBase.SetProviders(providers)
|
||||||
widget.Widgets[i].SetProviders(providers)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Group) RequiresUpdate(now *time.Time) bool {
|
func (widget *Group) RequiresUpdate(now *time.Time) bool {
|
||||||
for i := range widget.Widgets {
|
return widget.containerWidgetBase.RequiresUpdate(now)
|
||||||
if widget.Widgets[i].RequiresUpdate(now) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Group) Render() template.HTML {
|
func (widget *Group) Render() template.HTML {
|
||||||
|
@ -38,6 +38,10 @@ func (widget *Markets) Update(ctx context.Context) {
|
|||||||
markets.SortByAbsChange()
|
markets.SortByAbsChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if widget.Sort == "change" {
|
||||||
|
markets.SortByChange()
|
||||||
|
}
|
||||||
|
|
||||||
widget.Markets = markets
|
widget.Markets = markets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ package widget
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -10,8 +11,8 @@ import (
|
|||||||
"github.com/glanceapp/glance/internal/feed"
|
"github.com/glanceapp/glance/internal/feed"
|
||||||
)
|
)
|
||||||
|
|
||||||
func statusCodeToText(status int) string {
|
func statusCodeToText(status int, altStatusCodes []int) string {
|
||||||
if status == 200 {
|
if status == 200 || slices.Contains(altStatusCodes, status) {
|
||||||
return "OK"
|
return "OK"
|
||||||
}
|
}
|
||||||
if status == 404 {
|
if status == 404 {
|
||||||
@ -33,8 +34,8 @@ func statusCodeToText(status int) string {
|
|||||||
return strconv.Itoa(status)
|
return strconv.Itoa(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusCodeToStyle(status int) string {
|
func statusCodeToStyle(status int, altStatusCodes []int) string {
|
||||||
if status == 200 {
|
if status == 200 || slices.Contains(altStatusCodes, status) {
|
||||||
return "ok"
|
return "ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,6 +52,7 @@ type Monitor struct {
|
|||||||
SameTab bool `yaml:"same-tab"`
|
SameTab bool `yaml:"same-tab"`
|
||||||
StatusText string `yaml:"-"`
|
StatusText string `yaml:"-"`
|
||||||
StatusStyle string `yaml:"-"`
|
StatusStyle string `yaml:"-"`
|
||||||
|
AltStatusCodes []int `yaml:"alt-status-codes"`
|
||||||
} `yaml:"sites"`
|
} `yaml:"sites"`
|
||||||
ShowFailingOnly bool `yaml:"show-failing-only"`
|
ShowFailingOnly bool `yaml:"show-failing-only"`
|
||||||
HasFailing bool `yaml:"-"`
|
HasFailing bool `yaml:"-"`
|
||||||
@ -82,13 +84,13 @@ func (widget *Monitor) Update(ctx context.Context) {
|
|||||||
status := &statuses[i]
|
status := &statuses[i]
|
||||||
site.Status = status
|
site.Status = status
|
||||||
|
|
||||||
if status.Code >= 400 || status.TimedOut || status.Error != nil {
|
if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.TimedOut || status.Error != nil) {
|
||||||
widget.HasFailing = true
|
widget.HasFailing = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if !status.TimedOut {
|
if !status.TimedOut {
|
||||||
site.StatusText = statusCodeToText(status.Code)
|
site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes)
|
||||||
site.StatusStyle = statusCodeToStyle(status.Code)
|
site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
47
internal/widget/split-column.go
Normal file
47
internal/widget/split-column.go
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SplitColumn struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
containerWidgetBase `yaml:",inline"`
|
||||||
|
MaxColumns int `yaml:"max-columns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *SplitColumn) Initialize() error {
|
||||||
|
widget.withError(nil).withTitle("Split Column").SetHideHeader(true)
|
||||||
|
|
||||||
|
for i := range widget.Widgets {
|
||||||
|
if err := widget.Widgets[i].Initialize(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.MaxColumns < 2 {
|
||||||
|
widget.MaxColumns = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *SplitColumn) Update(ctx context.Context) {
|
||||||
|
widget.containerWidgetBase.Update(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *SplitColumn) SetProviders(providers *Providers) {
|
||||||
|
widget.containerWidgetBase.SetProviders(providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *SplitColumn) RequiresUpdate(now *time.Time) bool {
|
||||||
|
return widget.containerWidgetBase.RequiresUpdate(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *SplitColumn) Render() template.HTML {
|
||||||
|
return widget.render(widget, assets.SplitColumnTemplate)
|
||||||
|
}
|
@ -67,6 +67,10 @@ func New(widgetType string) (Widget, error) {
|
|||||||
widget = &Group{}
|
widget = &Group{}
|
||||||
case "dns-stats":
|
case "dns-stats":
|
||||||
widget = &DNSStats{}
|
widget = &DNSStats{}
|
||||||
|
case "split-column":
|
||||||
|
widget = &SplitColumn{}
|
||||||
|
case "custom-api":
|
||||||
|
widget = &CustomApi{}
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user