Переглянути джерело

Merge branch 'release/v0.7.0' into default-expand-mobile-navigation

Svilen Markov 7 місяців тому
батько
коміт
5e576a58e9
42 змінених файлів з 959 додано та 207 видалено
  1. 1 1
      Dockerfile
  2. 108 10
      docs/configuration.md
  3. BIN
      docs/images/split-column-widget-preview.png
  4. 7 4
      go.mod
  5. 13 6
      go.sum
  6. 1 0
      internal/assets/static/icons/codeberg.svg
  7. 41 33
      internal/assets/static/js/main.js
  8. 53 0
      internal/assets/static/js/masonry.js
  9. 6 3
      internal/assets/static/js/popover.js
  10. 29 0
      internal/assets/static/js/utils.js
  11. 28 3
      internal/assets/static/main.css
  12. 4 2
      internal/assets/templates.go
  13. 2 2
      internal/assets/templates/bookmarks.html
  14. 7 0
      internal/assets/templates/custom-api.html
  15. 1 1
      internal/assets/templates/markets.html
  16. 2 2
      internal/assets/templates/monitor.html
  17. 1 1
      internal/assets/templates/page.html
  18. 1 1
      internal/assets/templates/releases.html
  19. 11 0
      internal/assets/templates/split-column.html
  20. 8 2
      internal/assets/templates/twitch-channels.html
  21. 39 18
      internal/feed/adguard.go
  22. 39 0
      internal/feed/codeberg.go
  23. 148 0
      internal/feed/custom-api.go
  24. 9 4
      internal/feed/extension.go
  25. 8 1
      internal/feed/openmeteo.go
  26. 34 7
      internal/feed/pihole.go
  27. 6 0
      internal/feed/primitives.go
  28. 3 0
      internal/feed/releases.go
  29. 33 10
      internal/feed/rss.go
  30. 12 1
      internal/feed/twitch.go
  31. 19 6
      internal/glance/glance.go
  32. 5 18
      internal/widget/bookmarks.go
  33. 48 0
      internal/widget/container.go
  34. 70 0
      internal/widget/custom-api.go
  35. 11 9
      internal/widget/extension.go
  36. 73 16
      internal/widget/fields.go
  37. 8 32
      internal/widget/group.go
  38. 4 0
      internal/widget/markets.go
  39. 10 13
      internal/widget/monitor.go
  40. 5 1
      internal/widget/releases.go
  41. 47 0
      internal/widget/split-column.go
  42. 4 0
      internal/widget/widget.go

+ 1 - 1
Dockerfile

@@ -1,4 +1,4 @@
-FROM golang:1.22.5-alpine3.20 AS builder
+FROM golang:1.23.1-alpine3.20 AS builder
 
 WORKDIR /app
 COPY . /app

+ 108 - 10
docs/configuration.md

@@ -15,6 +15,8 @@
   - [Reddit](#reddit)
   - [Search](#search-widget)
   - [Group](#group)
+  - [Split Column](#split-column)
+  - [Custom API](#custom-api)
   - [Extension](#extension)
   - [Weather](#weather)
   - [Monitor](#monitor)
@@ -138,6 +140,10 @@ A number between 1 and 65,535, so long as that port isn't already used by anythi
 #### `base-url`
 The base URL that Glance is hosted under. No need to specify this unless you're using a reverse proxy and are hosting Glance under a directory. If that's the case then you can set this value to `/glance` or whatever the directory is called. Note that the forward slash (`/`) in the beginning is required unless you specify the full domain and path.
 
+> [!IMPORTANT]
+> You need to strip the `base-url` prefix before forwarding the request to the Glance server.
+> In Caddy you can do this using [`handle_path`](https://caddyserver.com/docs/caddyfile/directives/handle_path) or [`uri strip_prefix`](https://caddyserver.com/docs/caddyfile/directives/uri).
+
 #### `assets-path`
 The path to a directory that will be served by the server under the `/assets/` path. This is handy for widgets like the Monitor where you have to specify an icon URL and you want to self host all the icons rather than pointing to an external source.
 
@@ -307,6 +313,7 @@ pages:
 | title | string | yes | |
 | slug | string | no | |
 | width | string | no | |
+| center-vertically | boolean | no | false |
 | hide-desktop-navigation | boolean | no | false |
 | expand-mobile-page-navigation | boolean | no | false |
 | show-mobile-header | boolean | no | false |
@@ -329,6 +336,8 @@ The maximum width of the page on desktop. Possible values are `slim` and `wide`.
 >
 > When using `slim`, the maximum number of columns allowed for that page is `2`.
 
+#### `center-vertically`
+When set to `true`, vertically centers the content on the page. Has no effect if the content is taller than the height of the viewport.
 
 #### `hide-desktop-navigation`
 Whether to show the navigation links at the top of the page on desktop.
@@ -522,10 +531,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-description | boolean | no | false | Only applicable for `detailed-list` style |
 | item-link-prefix | string | no | | |
+| headers | key (string) & value (string) | no | | |
 
 ###### `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.
 
+###### `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`
 The maximum number of articles to show.
 
@@ -849,7 +870,7 @@ Either a value from the table below or a URL to a custom search engine. Use `{QU
 ##### `new-tab`
 When set to `true`, swaps the shortcuts for showing results in the same or new tab, defaulting to showing results in a new tab.
 
-##### `new-tab`
+##### `autofocus`
 When set to `true`, automatically focuses the search input on page load.
 
 ##### `bangs`
@@ -887,7 +908,7 @@ url: https://www.amazon.com/s?k={QUERY}
 ```
 
 ### 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:
 
@@ -930,6 +951,67 @@ Example:
       <<: *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:
+
+![](images/split-column-widget-preview.png)
+
+### Custom API
+<!-- TODO -->
+
 ### 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).
 
@@ -945,12 +1027,16 @@ Display a widget provided by an external source (3rd party). If you want to lear
 | Name | Type | Required | Default |
 | ---- | ---- | -------- | ------- |
 | url | string | yes | |
+| fallback-content-type | string | no | |
 | allow-potentially-dangerous-html | boolean | no | false |
 | parameters | key & value | no | |
 
 ##### `url`
 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`
 Whether to allow the extension to display HTML.
 
@@ -1079,6 +1165,7 @@ Properties for each site:
 | icon | string | no | |
 | allow-insecure | boolean | no | false |
 | same-tab | boolean | no | false |
+| alt-status-codes | array | no | |
 
 `title`
 
@@ -1094,7 +1181,7 @@ The URL which will be requested and its response will determine the status of th
 
 `icon`
 
-Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path). You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix:
+Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path). You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix:
 
 ```yaml
 icon: si:jellyfin
@@ -1104,7 +1191,7 @@ icon: si:adguard
 
 > [!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`
 
@@ -1114,8 +1201,17 @@ Whether to ignore invalid/self-signed certificates.
 
 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
-Display a list of latest releases for specific repositories on Github, GitLab or Docker Hub.
+Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
 
 Example:
 
@@ -1126,6 +1222,7 @@ Example:
     - go-gitea/gitea
     - jellyfin/jellyfin
     - glanceapp/glance
+    - codeberg:redict/redict
     - gitlab:fdroid/fdroidclient
     - dockerhub:gotify/server
 ```
@@ -1146,12 +1243,13 @@ Preview:
 | collapse-after | integer | no | 5 |
 
 ##### `repositories`
-A list of repositores to fetch the latest release for. Only the name/repo is required, not the full URL. A prefix can be specified for repositories hosted elsewhere such as GitLab and Docker Hub. Example:
+A list of repositores to fetch the latest release for. Only the name/repo is required, not the full URL. A prefix can be specified for repositories hosted elsewhere such as GitLab, Codeberg and Docker Hub. Example:
 
 ```yaml
 repositories:
   - gitlab:inkscape/inkscape
   - dockerhub:glanceapp/glance
+  - codeberg:redict/redict
 ```
 
 Official images on Docker Hub can be specified by ommiting the owner:
@@ -1173,7 +1271,7 @@ repositories:
 
 
 ##### `show-source-icon`
-Shows an icon of the source (GitHub/GitLab/Docker Hub) next to the repository name when set to `true`.
+Shows an icon of the source (GitHub/GitLab/Codeberg/Docker Hub) next to the repository name when set to `true`.
 
 ##### `token`
 Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if you're tracking lots of repositories or your cache time is low. To circumvent this you can [create a read only token from your Github account](https://github.com/settings/personal-access-tokens/new) and provide it here.
@@ -1370,7 +1468,7 @@ An array of groups which can optionally have a title and a custom color.
 
 `icon`
 
-URL pointing to an image. You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix:
+URL pointing to an image. You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix:
 
 ```yaml
 icon: si:gmail
@@ -1380,7 +1478,7 @@ icon: si:reddit
 
 > [!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`
 
@@ -1534,7 +1632,7 @@ Preview:
 An array of markets for which to display information about.
 
 ##### `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
 | Name | Type | Required |

BIN
docs/images/split-column-widget-preview.png


+ 7 - 4
go.mod

@@ -1,19 +1,22 @@
 module github.com/glanceapp/glance
 
-go 1.22.5
+go 1.23.1
 
 require (
 	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
 )
 
 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/json-iterator/go v1.1.12 // indirect
 	github.com/mmcdole/goxpp v1.1.1 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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
 )

+ 13 - 6
go.sum

@@ -1,5 +1,5 @@
-github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
-github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
+github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
+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/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
 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.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
 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=
 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=
@@ -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.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.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
-golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
+golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
+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-20220722155255-886fb9371eb4/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.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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
+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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

+ 1 - 0
internal/assets/static/icons/codeberg.svg

@@ -0,0 +1 @@
+<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.955.49A12 12 0 0 0 0 12.49a12 12 0 0 0 1.832 6.373L11.838 5.928a.187.14 0 0 1 .324 0l10.006 12.935A12 12 0 0 0 24 12.49a12 12 0 0 0-12-12 12 12 0 0 0-.045 0zm.375 6.467l4.416 16.553a12 12 0 0 0 5.137-4.213z"/></svg>

+ 41 - 33
internal/assets/static/js/main.js

@@ -1,27 +1,6 @@
 import { setupPopovers } from './popover.js';
-
-function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
-    let debounceTimeout;
-    let timesDebounced = 0;
-
-    return function () {
-        if (timesDebounced == maxDebounceTimes) {
-            clearTimeout(debounceTimeout);
-            timesDebounced = 0;
-            callback();
-            return;
-        }
-
-        clearTimeout(debounceTimeout);
-        timesDebounced++;
-
-        debounceTimeout = setTimeout(() => {
-            timesDebounced = 0;
-            callback();
-        }, debounceDelay);
-    };
-};
-
+import { setupMasonries } from './masonry.js';
+import { throttledDebounce, isElementVisible } from './utils.js';
 
 async function fetchPageContent(pageData) {
     // TODO: handle non 200 status codes/time outs
@@ -427,7 +406,7 @@ function setupCollapsibleGrids() {
 
         const button = attachExpandToggleButton(gridElement);
 
-        let cardsPerRow = 2;
+        let cardsPerRow;
 
         const resolveCollapsibleItems = () => {
             const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
@@ -457,12 +436,11 @@ function setupCollapsibleGrids() {
             }
         };
 
-        afterContentReady(() => {
-            cardsPerRow = getCardsPerRow();
-            resolveCollapsibleItems();
-        });
+        const observer = new ResizeObserver(() => {
+            if (!isElementVisible(gridElement)) {
+                return;
+            }
 
-        window.addEventListener("resize", () => {
             const newCardsPerRow = getCardsPerRow();
 
             if (cardsPerRow == newCardsPerRow) {
@@ -472,6 +450,8 @@ function setupCollapsibleGrids() {
             cardsPerRow = newCardsPerRow;
             resolveCollapsibleItems();
         });
+
+        afterContentReady(() => observer.observe(gridElement));
     }
 }
 
@@ -523,9 +503,34 @@ function timeInZone(now, zone) {
         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, 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 { time: timeInZone, diffInHours: diffInHours };
+    return { text: `${sign}${hours}h~`, title: `${hours} hour${hourSuffix} and ${minutes} minutes ${signText}` };
 }
 
 function setupClocks() {
@@ -568,9 +573,11 @@ function setupClocks() {
             );
 
             updateCallbacks.push((now) => {
-                const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
+                const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
                 setZoneTime(time);
-                diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
+                const { text, title } = zoneDiffText(diffInMinutes);
+                diffElement.textContent = text;
+                diffElement.title = title;
             });
         }
     }
@@ -602,6 +609,7 @@ async function setupPage() {
         setupCollapsibleLists();
         setupCollapsibleGrids();
         setupGroups();
+        setupMasonries();
         setupDynamicRelativeTime();
         setupLazyImages();
     } finally {

+ 53 - 0
internal/assets/static/js/masonry.js

@@ -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);
+    }
+}

+ 6 - 3
internal/assets/static/js/popover.js

@@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() {
 }
 
 function showPopover() {
+    if (pendingTarget === null) return;
+
     activeTarget = pendingTarget;
     pendingTarget = null;
 
@@ -109,9 +111,10 @@ function repositionContainer() {
 
     const containerBounds = containerElement.getBoundingClientRect();
     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 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) {
         containerElement.style.left = 0;
@@ -124,7 +127,7 @@ function repositionContainer() {
     } else {
         containerElement.style.removeProperty("right");
         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;

+ 29 - 0
internal/assets/static/js/utils.js

@@ -0,0 +1,29 @@
+export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
+    let debounceTimeout;
+    let timesDebounced = 0;
+
+    return function () {
+        if (timesDebounced == maxDebounceTimes) {
+            clearTimeout(debounceTimeout);
+            timesDebounced = 0;
+            callback();
+            return;
+        }
+
+        clearTimeout(debounceTimeout);
+        timesDebounced++;
+
+        debounceTimeout = setTimeout(() => {
+            timesDebounced = 0;
+            callback();
+        }, debounceDelay);
+    };
+};
+
+export function isElementVisible(element) {
+    return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
+}
+
+export function clamp(value, min, max) {
+    return Math.min(Math.max(value, min), max);
+}

+ 28 - 3
internal/assets/static/main.css

@@ -440,6 +440,17 @@ kbd:active {
     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] {
     display: none;
 }
@@ -851,6 +862,7 @@ details[open] .summary::after {
     border-bottom: 2px solid transparent;
     transition: color .3s, border-color .3s;
     font-size: var(--font-size-h3);
+    flex-shrink: 0;
 }
 
 .nav-item:not(.nav-item-current):hover {
@@ -1049,6 +1061,7 @@ details[open] .summary::after {
     border-radius: var(--border-radius);
     padding: 0.5rem;
     opacity: 0.7;
+    flex-shrink: 0;
 }
 
 .bookmarks-icon {
@@ -1057,7 +1070,7 @@ details[open] .summary::after {
     opacity: 0.8;
 }
 
-:root:not(.light-scheme) .simple-icon {
+:root:not(.light-scheme) .flat-icon {
     filter: invert(1);
 }
 
@@ -1337,6 +1350,10 @@ details[open] .summary::after {
     transform: translate(-50%, -50%);
 }
 
+.clock-time {
+    min-width: 8ch;
+}
+
 .clock-time span {
     color: var(--color-text-highlight);
 }
@@ -1353,7 +1370,7 @@ details[open] .summary::after {
     transition: filter 0.3s, opacity 0.3s;
 }
 
-.monitor-site-icon.simple-icon {
+.monitor-site-icon.flat-icon {
     opacity: 0.7;
 }
 
@@ -1361,7 +1378,7 @@ details[open] .summary::after {
     opacity: 1;
 }
 
-.monitor-site:hover .monitor-site-icon:not(.simple-icon) {
+.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
     filter: grayscale(0);
 }
 
@@ -1491,6 +1508,14 @@ details[open] .summary::after {
     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 {
     width: 100%;
     height: 100%;

+ 4 - 2
internal/assets/templates.go

@@ -39,9 +39,11 @@ var (
 	ExtensionTemplate             = compileTemplate("extension.html", "widget-base.html")
 	GroupTemplate                 = compileTemplate("group.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,
 	"formatViewerCount": formatViewerCount,
 	"formatNumber":      intl.Sprint,
@@ -58,7 +60,7 @@ var globalTemplateFunctions = template.FuncMap{
 
 func compileTemplate(primary string, dependencies ...string) *template.Template {
 	t, err := template.New(primary).
-		Funcs(globalTemplateFunctions).
+		Funcs(GlobalTemplateFunctions).
 		ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
 
 	if err != nil {

+ 2 - 2
internal/assets/templates/bookmarks.html

@@ -8,9 +8,9 @@
         <ul class="list list-gap-2">
         {{ range .Links }}
         <li class="flex items-center gap-10">
-            {{ if ne "" .Icon }}
+            {{ if ne "" .Icon.URL }}
             <div class="bookmarks-icon-container">
-                <img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
+                <img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
             </div>
             {{ end }}
             <a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>

+ 7 - 0
internal/assets/templates/custom-api.html

@@ -0,0 +1,7 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }}
+
+{{ define "widget-content" }}
+{{ .CompiledHTML }}
+{{ end }}

+ 1 - 1
internal/assets/templates/markets.html

@@ -6,7 +6,7 @@
     <div class="flex items-center gap-15">
         <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>
-            <div class="text-truncate">{{ .Name }}</div>
+            <div title="{{ .Name }}" class="text-truncate">{{ .Name }}</div>
         </div>
 
         <a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>

+ 2 - 2
internal/assets/templates/monitor.html

@@ -21,8 +21,8 @@
 {{ end }}
 
 {{ define "site" }}
-{{ if .IconUrl }}
-<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
+{{ if .Icon.URL }}
+<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
 {{ end }}
 <div class="min-width-0">
     <a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>

+ 1 - 1
internal/assets/templates/page.html

@@ -44,7 +44,7 @@
         <div class="mobile-navigation-icons">
             <a class="mobile-navigation-label" href="#top">↑</a>
             {{ 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 }}
             <label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"{{ if .Page.ExpandMobilePageNavigation }} checked{{ end }}><div class="hamburger-icon"></div></label>
         </div>

+ 1 - 1
internal/assets/templates/releases.html

@@ -1,7 +1,7 @@
 {{ template "widget-base.html" . }}
 
 {{ 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 }}
     <li>
         <div class="flex items-center gap-10">

+ 11 - 0
internal/assets/templates/split-column.html

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

+ 8 - 2
internal/assets/templates/twitch-channels.html

@@ -5,9 +5,15 @@
     {{ range .Channels }}
     <li>
         <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 }}
-                <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">
                 </a>
                 {{ else }}

+ 39 - 18
internal/feed/adguard.go

@@ -31,10 +31,13 @@ func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error
 		return nil, err
 	}
 
+	var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
+
 	stats := &DNSStats{
-		TotalQueries:   responseJson.TotalQueries,
-		BlockedQueries: responseJson.BlockedQueries,
-		ResponseTime:   int(responseJson.ResponseTime * 1000),
+		TotalQueries:      responseJson.TotalQueries,
+		BlockedQueries:    responseJson.BlockedQueries,
+		ResponseTime:      int(responseJson.ResponseTime * 1000),
+		TopBlockedDomains: make([]DNSStatsBlockedDomain, 0, topBlockedDomainsCount),
 	}
 
 	if stats.TotalQueries <= 0 {
@@ -43,8 +46,6 @@ func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error
 
 	stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
 
-	var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
-
 	for i := 0; i < topBlockedDomainsCount; i++ {
 		domain := responseJson.TopBlockedDomains[i]
 		var firstDomain string
@@ -59,31 +60,51 @@ func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error
 		}
 
 		stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{
-			Domain:         firstDomain,
-			PercentBlocked: int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100),
+			Domain: firstDomain,
 		})
+
+		if stats.BlockedQueries > 0 {
+			stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100)
+		}
 	}
 
-	// Adguard _should_ return data for the last 24 hours in a 1 hour interval
-	if len(responseJson.QueriesSeries) != 24 || len(responseJson.BlockedSeries) != 24 {
-		return stats, nil
+	queriesSeries := responseJson.QueriesSeries
+	blockedSeries := responseJson.BlockedSeries
+
+	const bars = 8
+	const hoursSpan = 24
+	const hoursPerBar int = hoursSpan / bars
+
+	if len(queriesSeries) > hoursSpan {
+		queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
+	} else if len(queriesSeries) < hoursSpan {
+		queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
+	}
+
+	if len(blockedSeries) > hoursSpan {
+		blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
+	} else if len(blockedSeries) < hoursSpan {
+		blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
 	}
 
 	maxQueriesInSeries := 0
 
-	for i := 0; i < 8; i++ {
+	for i := 0; i < bars; i++ {
 		queries := 0
 		blocked := 0
 
-		for j := 0; j < 3; j++ {
-			queries += responseJson.QueriesSeries[i*3+j]
-			blocked += responseJson.BlockedSeries[i*3+j]
+		for j := 0; j < hoursPerBar; j++ {
+			queries += queriesSeries[i*hoursPerBar+j]
+			blocked += blockedSeries[i*hoursPerBar+j]
 		}
 
 		stats.Series[i] = DNSStatsSeries{
-			Queries:        queries,
-			Blocked:        blocked,
-			PercentBlocked: int(float64(blocked) / float64(queries) * 100),
+			Queries: queries,
+			Blocked: blocked,
+		}
+
+		if queries > 0 {
+			stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
 		}
 
 		if queries > maxQueriesInSeries {
@@ -91,7 +112,7 @@ func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error
 		}
 	}
 
-	for i := 0; i < 8; i++ {
+	for i := 0; i < bars; i++ {
 		stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
 	}
 

+ 39 - 0
internal/feed/codeberg.go

@@ -0,0 +1,39 @@
+package feed
+
+import (
+	"fmt"
+	"net/http"
+)
+
+type codebergReleaseResponseJson struct {
+	TagName     string `json:"tag_name"`
+	PublishedAt string `json:"published_at"`
+	HtmlUrl     string `json:"html_url"`
+}
+
+func fetchLatestCodebergRelease(request *ReleaseRequest) (*AppRelease, error) {
+	httpRequest, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf(
+			"https://codeberg.org/api/v1/repos/%s/releases/latest",
+			request.Repository,
+		),
+		nil,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest)
+
+	if err != nil {
+		return nil, err
+	}
+	return &AppRelease{
+		Source:       ReleaseSourceCodeberg,
+		Name:         request.Repository,
+		Version:      normalizeVersionFormat(response.TagName),
+		NotesUrl:     response.HtmlUrl,
+		TimeReleased: parseRFC3339Time(response.PublishedAt),
+	}, nil
+}

+ 148 - 0
internal/feed/custom-api.go

@@ -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
+}()

+ 9 - 4
internal/feed/extension.go

@@ -27,9 +27,10 @@ const (
 )
 
 type ExtensionRequestOptions struct {
-	URL        string            `yaml:"url"`
-	Parameters map[string]string `yaml:"parameters"`
-	AllowHtml  bool              `yaml:"allow-potentially-dangerous-html"`
+	URL                 string            `yaml:"url"`
+	FallbackContentType string            `yaml:"fallback-content-type"`
+	Parameters          map[string]string `yaml:"parameters"`
+	AllowHtml           bool              `yaml:"allow-potentially-dangerous-html"`
 }
 
 type Extension struct {
@@ -88,7 +89,11 @@ func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
 	contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
 
 	if !ok {
-		contentType = ExtensionContentUnknown
+		contentType, ok = ExtensionStringToType[options.FallbackContentType]
+
+		if !ok {
+			contentType = ExtensionContentUnknown
+		}
 	}
 
 	extension.Content = convertExtensionContent(options, body, contentType)

+ 8 - 1
internal/feed/openmeteo.go

@@ -189,12 +189,19 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
 		minT := slices.Min(temperatures)
 		maxT := slices.Max(temperatures)
 
+		temperaturesRange := float64(maxT - minT)
+
 		for i := 0; i < 12; i++ {
 			bars = append(bars, weatherColumn{
 				Temperature:      temperatures[i],
-				Scale:            float64(temperatures[i]-minT) / float64(maxT-minT),
 				HasPrecipitation: precipitations[i],
 			})
+
+			if temperaturesRange > 0 {
+				bars[i].Scale = float64(temperatures[i]-minT) / temperaturesRange
+			} else {
+				bars[i].Scale = 1
+			}
 		}
 	}
 

+ 34 - 7
internal/feed/pihole.go

@@ -1,20 +1,42 @@
 package feed
 
 import (
+	"encoding/json"
 	"errors"
+	"log/slog"
 	"net/http"
 	"sort"
 	"strings"
 )
 
 type piholeStatsResponse struct {
-	TotalQueries      int            `json:"dns_queries_today"`
-	QueriesSeries     map[int64]int  `json:"domains_over_time"`
-	BlockedQueries    int            `json:"ads_blocked_today"`
-	BlockedSeries     map[int64]int  `json:"ads_over_time"`
-	BlockedPercentage float64        `json:"ads_percentage_today"`
-	TopBlockedDomains map[string]int `json:"top_ads"`
-	DomainsBlocked    int            `json:"domains_being_blocked"`
+	TotalQueries      int                     `json:"dns_queries_today"`
+	QueriesSeries     map[int64]int           `json:"domains_over_time"`
+	BlockedQueries    int                     `json:"ads_blocked_today"`
+	BlockedSeries     map[int64]int           `json:"ads_over_time"`
+	BlockedPercentage float64                 `json:"ads_percentage_today"`
+	TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
+	DomainsBlocked    int                     `json:"domains_being_blocked"`
+}
+
+// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
+// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
+type piholeTopBlockedDomains map[string]int
+
+func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
+	// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
+	// because of the UnmarshalJSON method getting called recursively
+	temp := make(map[string]int)
+
+	err := json.Unmarshal(data, &temp)
+
+	if err != nil {
+		*p = make(piholeTopBlockedDomains)
+	} else {
+		*p = temp
+	}
+
+	return nil
 }
 
 func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) {
@@ -63,6 +85,11 @@ func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) {
 
 	// Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
 	if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 {
+		slog.Warn(
+			"DNS stats for pihole: did not get expected 144 data points",
+			"len(queries)", len(responseJson.QueriesSeries),
+			"len(blocked)", len(responseJson.BlockedSeries),
+		)
 		return stats, nil
 	}
 

+ 6 - 0
internal/feed/primitives.go

@@ -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{
 	0:  "Clear Sky",
 	1:  "Mainly Clear",

+ 3 - 0
internal/feed/releases.go

@@ -9,6 +9,7 @@ import (
 type ReleaseSource string
 
 const (
+	ReleaseSourceCodeberg  ReleaseSource = "codeberg"
 	ReleaseSourceGithub    ReleaseSource = "github"
 	ReleaseSourceGitlab    ReleaseSource = "gitlab"
 	ReleaseSourceDockerHub ReleaseSource = "dockerhub"
@@ -57,6 +58,8 @@ func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) {
 
 func fetchLatestReleaseTask(request *ReleaseRequest) (*AppRelease, error) {
 	switch request.Source {
+	case ReleaseSourceCodeberg:
+		return fetchLatestCodebergRelease(request)
 	case ReleaseSourceGithub:
 		return fetchLatestGithubRelease(request)
 	case ReleaseSourceGitlab:

+ 33 - 10
internal/feed/rss.go

@@ -1,10 +1,11 @@
 package feed
 
 import (
-	"context"
 	"fmt"
 	"html"
+	"io"
 	"log/slog"
+	"net/http"
 	"net/url"
 	"regexp"
 	"sort"
@@ -57,12 +58,13 @@ func shortenFeedDescriptionLen(description string, maxLen int) string {
 }
 
 type RSSFeedRequest struct {
-	Url             string `yaml:"url"`
-	Title           string `yaml:"title"`
-	HideCategories  bool   `yaml:"hide-categories"`
-	HideDescription bool   `yaml:"hide-description"`
-	ItemLinkPrefix  string `yaml:"item-link-prefix"`
-	IsDetailed      bool   `yaml:"-"`
+	Url             string            `yaml:"url"`
+	Title           string            `yaml:"title"`
+	HideCategories  bool              `yaml:"hide-categories"`
+	HideDescription bool              `yaml:"hide-description"`
+	ItemLinkPrefix  string            `yaml:"item-link-prefix"`
+	Headers         map[string]string `yaml:"headers"`
+	IsDetailed      bool              `yaml:"-"`
 }
 
 type RSSFeedItems []RSSFeedItem
@@ -78,10 +80,31 @@ func (f RSSFeedItems) SortByNewest() RSSFeedItems {
 var feedParser = gofeed.NewParser()
 
 func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
-	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
-	defer cancel()
+	req, err := http.NewRequest("GET", request.Url, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	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.ParseURLWithContext(request.Url, ctx)
+	feed, err := feedParser.ParseString(string(body))
 
 	if err != nil {
 		return nil, err

+ 12 - 1
internal/feed/twitch.go

@@ -28,6 +28,7 @@ type TwitchChannel struct {
 	Login        string
 	Exists       bool
 	Name         string
+	StreamTitle  string
 	AvatarUrl    string
 	IsLive       bool
 	LiveSince    time.Time
@@ -77,6 +78,9 @@ type twitchStreamMetadataOperationResponse struct {
 				Name string `json:"name"`
 			} `json:"game"`
 		} `json:"stream"`
+		LastBroadcast *struct {
+			Title string `json:"title"`
+		}
 	} `json:"user"`
 }
 
@@ -142,7 +146,10 @@ func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, err
 	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
 // 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
 
 		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 {
 				result.Category = streamMetadata.UserOrNull.Stream.Game.Name
 				result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug

+ 19 - 6
internal/glance/glance.go

@@ -76,6 +76,7 @@ type Page struct {
 	HideDesktopNavigation      bool     `yaml:"hide-desktop-navigation"`
 	CenterVertically           bool     `yaml:"center-vertically"`
 	Columns                    []Column `yaml:"columns"`
+	PrimaryColumnIndex         int8     `yaml:"-"`
 	mu                         sync.Mutex
 }
 
@@ -141,15 +142,24 @@ func NewApplication(config *Config) (*Application, error) {
 	}
 
 	for p := range config.Pages {
-		if config.Pages[p].Slug == "" {
-			config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
+		page := &config.Pages[p]
+		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 page.Columns {
+			column := &page.Columns[c]
 
-		for c := range config.Pages[p].Columns {
-			for w := range config.Pages[p].Columns[c].Widgets {
-				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
 
 				widget.SetProviders(providers)
@@ -276,6 +286,9 @@ func (a *Application) Serve() error {
 
 	mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
 	mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
+	mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	})
 
 	mux.Handle(
 		fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),

+ 5 - 18
internal/widget/bookmarks.go

@@ -13,30 +13,17 @@ type Bookmarks struct {
 		Title string         `yaml:"title"`
 		Color *HSLColorField `yaml:"color"`
 		Links []struct {
-			Title        string `yaml:"title"`
-			URL          string `yaml:"url"`
-			Icon         string `yaml:"icon"`
-			IsSimpleIcon bool   `yaml:"-"`
-			SameTab      bool   `yaml:"same-tab"`
-			HideArrow    bool   `yaml:"hide-arrow"`
+			Title     string     `yaml:"title"`
+			URL       string     `yaml:"url"`
+			Icon      CustomIcon `yaml:"icon"`
+			SameTab   bool       `yaml:"same-tab"`
+			HideArrow bool       `yaml:"hide-arrow"`
 		} `yaml:"links"`
 	} `yaml:"groups"`
 }
 
 func (widget *Bookmarks) Initialize() error {
 	widget.withTitle("Bookmarks").withError(nil)
-
-	for g := range widget.Groups {
-		for l := range widget.Groups[g].Links {
-			if widget.Groups[g].Links[l].Icon == "" {
-				continue
-			}
-
-			link := &widget.Groups[g].Links[l]
-			link.Icon, link.IsSimpleIcon = toSimpleIconIfPrefixed(link.Icon)
-		}
-	}
-
 	widget.cachedHTML = widget.render(widget, assets.BookmarksTemplate)
 
 	return nil

+ 48 - 0
internal/widget/container.go

@@ -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 - 0
internal/widget/custom-api.go

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

+ 11 - 9
internal/widget/extension.go

@@ -12,12 +12,13 @@ import (
 )
 
 type Extension struct {
-	widgetBase `yaml:",inline"`
-	URL        string            `yaml:"url"`
-	Parameters map[string]string `yaml:"parameters"`
-	AllowHtml  bool              `yaml:"allow-potentially-dangerous-html"`
-	Extension  feed.Extension    `yaml:"-"`
-	cachedHTML template.HTML     `yaml:"-"`
+	widgetBase          `yaml:",inline"`
+	URL                 string            `yaml:"url"`
+	FallbackContentType string            `yaml:"fallback-content-type"`
+	Parameters          map[string]string `yaml:"parameters"`
+	AllowHtml           bool              `yaml:"allow-potentially-dangerous-html"`
+	Extension           feed.Extension    `yaml:"-"`
+	cachedHTML          template.HTML     `yaml:"-"`
 }
 
 func (widget *Extension) Initialize() error {
@@ -38,9 +39,10 @@ func (widget *Extension) Initialize() error {
 
 func (widget *Extension) Update(ctx context.Context) {
 	extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
-		URL:        widget.URL,
-		Parameters: widget.Parameters,
-		AllowHtml:  widget.AllowHtml,
+		URL:                 widget.URL,
+		FallbackContentType: widget.FallbackContentType,
+		Parameters:          widget.Parameters,
+		AllowHtml:           widget.AllowHtml,
 	})
 
 	widget.canContinueUpdateAfterHandlingErr(err)

+ 73 - 16
internal/widget/fields.go

@@ -13,7 +13,7 @@ import (
 )
 
 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 (
 	HSLHueMax        = 360
@@ -133,21 +133,42 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
 		return err
 	}
 
-	matches := EnvFieldPattern.FindStringSubmatch(value)
+	replaced := EnvFieldPattern.ReplaceAllStringFunc(value, func(whole string) string {
+		if err != nil {
+			return ""
+		}
 
-	if len(matches) != 2 {
-		*f = OptionalEnvString(value)
+		groups := EnvFieldPattern.FindStringSubmatch(whole)
 
-		return nil
-	}
+		if len(groups) != 3 {
+			return whole
+		}
 
-	value, found := os.LookupEnv(matches[1])
+		prefix, key := groups[1], groups[2]
 
-	if !found {
-		return fmt.Errorf("environment variable %s not found", matches[1])
+		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
 	}
 
-	*f = OptionalEnvString(value)
+	*f = OptionalEnvString(replaced)
 
 	return nil
 }
@@ -156,13 +177,49 @@ func (f *OptionalEnvString) String() string {
 	return string(*f)
 }
 
-func toSimpleIconIfPrefixed(icon string) (string, bool) {
-	if !strings.HasPrefix(icon, "si:") {
-		return icon, false
+type CustomIcon struct {
+	URL        string
+	IsFlatIcon bool
+	// TODO: along with whether the icon is flat, we also need to know
+	// whether the icon is black or white by default in order to properly
+	// invert the color based on the theme being light or dark
+}
+
+func (i *CustomIcon) UnmarshalYAML(node *yaml.Node) error {
+	var value string
+	if err := node.Decode(&value); err != nil {
+		return err
 	}
 
-	icon = strings.TrimPrefix(icon, "si:")
-	icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
+	prefix, icon, found := strings.Cut(value, ":")
+	if !found {
+		i.URL = value
+		return nil
+	}
 
-	return icon, true
+	switch prefix {
+	case "si":
+		i.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
+		i.IsFlatIcon = true
+	case "di":
+		// syntax: di:<icon_name>[.svg|.png]
+		// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
+		// otherwise, specify the extension of either .svg or .png to use either of the CDN offerings
+		// any other extension will be interpreted as .svg
+		basename, ext, found := strings.Cut(icon, ".")
+		if !found {
+			ext = "svg"
+			basename = icon
+		}
+
+		if ext != "svg" && ext != "png" {
+			ext = "svg"
+		}
+
+		i.URL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/" + ext + "/" + basename + "." + ext
+	default:
+		i.URL = value
+	}
+
+	return nil
 }

+ 8 - 32
internal/widget/group.go

@@ -4,15 +4,14 @@ import (
 	"context"
 	"errors"
 	"html/template"
-	"sync"
 	"time"
 
 	"github.com/glanceapp/glance/internal/assets"
 )
 
 type Group struct {
-	widgetBase `yaml:",inline"`
-	Widgets    Widgets `yaml:"widgets"`
+	widgetBase          `yaml:",inline"`
+	containerWidgetBase `yaml:",inline"`
 }
 
 func (widget *Group) Initialize() error {
@@ -23,7 +22,9 @@ func (widget *Group) Initialize() error {
 		widget.Widgets[i].SetHideHeader(true)
 
 		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 {
@@ -35,40 +36,15 @@ func (widget *Group) Initialize() error {
 }
 
 func (widget *Group) 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()
+	widget.containerWidgetBase.Update(ctx)
 }
 
 func (widget *Group) SetProviders(providers *Providers) {
-	for i := range widget.Widgets {
-		widget.Widgets[i].SetProviders(providers)
-	}
+	widget.containerWidgetBase.SetProviders(providers)
 }
 
 func (widget *Group) RequiresUpdate(now *time.Time) bool {
-	for i := range widget.Widgets {
-		if widget.Widgets[i].RequiresUpdate(now) {
-			return true
-		}
-	}
-
-	return false
+	return widget.containerWidgetBase.RequiresUpdate(now)
 }
 
 func (widget *Group) Render() template.HTML {

+ 4 - 0
internal/widget/markets.go

@@ -38,6 +38,10 @@ func (widget *Markets) Update(ctx context.Context) {
 		markets.SortByAbsChange()
 	}
 
+	if widget.Sort == "change" {
+		markets.SortByChange()
+	}
+
 	widget.Markets = markets
 }
 

+ 10 - 13
internal/widget/monitor.go

@@ -3,6 +3,7 @@ package widget
 import (
 	"context"
 	"html/template"
+	"slices"
 	"strconv"
 	"time"
 
@@ -10,8 +11,8 @@ import (
 	"github.com/glanceapp/glance/internal/feed"
 )
 
-func statusCodeToText(status int) string {
-	if status == 200 {
+func statusCodeToText(status int, altStatusCodes []int) string {
+	if status == 200 || slices.Contains(altStatusCodes, status) {
 		return "OK"
 	}
 	if status == 404 {
@@ -33,8 +34,8 @@ func statusCodeToText(status int) string {
 	return strconv.Itoa(status)
 }
 
-func statusCodeToStyle(status int) string {
-	if status == 200 {
+func statusCodeToStyle(status int, altStatusCodes []int) string {
+	if status == 200 || slices.Contains(altStatusCodes, status) {
 		return "ok"
 	}
 
@@ -47,11 +48,11 @@ type Monitor struct {
 		*feed.SiteStatusRequest `yaml:",inline"`
 		Status                  *feed.SiteStatus `yaml:"-"`
 		Title                   string           `yaml:"title"`
-		IconUrl                 string           `yaml:"icon"`
-		IsSimpleIcon            bool             `yaml:"-"`
+		Icon                    CustomIcon       `yaml:"icon"`
 		SameTab                 bool             `yaml:"same-tab"`
 		StatusText              string           `yaml:"-"`
 		StatusStyle             string           `yaml:"-"`
+		AltStatusCodes          []int            `yaml:"alt-status-codes"`
 	} `yaml:"sites"`
 	ShowFailingOnly bool `yaml:"show-failing-only"`
 	HasFailing      bool `yaml:"-"`
@@ -60,10 +61,6 @@ type Monitor struct {
 func (widget *Monitor) Initialize() error {
 	widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
 
-	for i := range widget.Sites {
-		widget.Sites[i].IconUrl, widget.Sites[i].IsSimpleIcon = toSimpleIconIfPrefixed(widget.Sites[i].IconUrl)
-	}
-
 	return nil
 }
 
@@ -87,13 +84,13 @@ func (widget *Monitor) Update(ctx context.Context) {
 		status := &statuses[i]
 		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
 		}
 
 		if !status.TimedOut {
-			site.StatusText = statusCodeToText(status.Code)
-			site.StatusStyle = statusCodeToStyle(status.Code)
+			site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes)
+			site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes)
 		}
 	}
 }

+ 5 - 1
internal/widget/releases.go

@@ -40,7 +40,6 @@ func (widget *Releases) Initialize() error {
 	for _, repository := range widget.Repositories {
 		parts := strings.SplitN(repository, ":", 2)
 		var request *feed.ReleaseRequest
-
 		if len(parts) == 1 {
 			request = &feed.ReleaseRequest{
 				Source:     feed.ReleaseSourceGithub,
@@ -65,6 +64,11 @@ func (widget *Releases) Initialize() error {
 					Source:     feed.ReleaseSourceDockerHub,
 					Repository: parts[1],
 				}
+			} else if parts[0] == string(feed.ReleaseSourceCodeberg) {
+				request = &feed.ReleaseRequest{
+					Source:     feed.ReleaseSourceCodeberg,
+					Repository: parts[1],
+				}
 			} else {
 				return errors.New("invalid repository source " + parts[0])
 			}

+ 47 - 0
internal/widget/split-column.go

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

+ 4 - 0
internal/widget/widget.go

@@ -67,6 +67,10 @@ func New(widgetType string) (Widget, error) {
 		widget = &Group{}
 	case "dns-stats":
 		widget = &DNSStats{}
+	case "split-column":
+		widget = &SplitColumn{}
+	case "custom-api":
+		widget = &CustomApi{}
 	default:
 		return nil, fmt.Errorf("unknown widget type: %s", widgetType)
 	}