ソースを参照

Merge branch 'release/v0.7.0' into main

Svilen Markov 8 ヶ月 前
コミット
becf34b0d9

+ 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

+ 93 - 4
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)
@@ -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-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.
 
@@ -890,7 +904,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:
 
@@ -933,6 +947,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).
 
@@ -948,12 +1023,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.
 
@@ -1082,6 +1161,7 @@ Properties for each site:
 | icon | string | no | |
 | allow-insecure | boolean | no | false |
 | same-tab | boolean | no | false |
+| alt-status-codes | array | no | |
 
 `title`
 
@@ -1107,7 +1187,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`
 
@@ -1117,6 +1197,15 @@ 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, Codeberg or Docker Hub.
 
@@ -1385,7 +1474,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`
 
@@ -1539,7 +1628,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=

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

@@ -1,4 +1,5 @@
 import { setupPopovers } from './popover.js';
+import { setupMasonries } from './masonry.js';
 import { throttledDebounce, isElementVisible } from './utils.js';
 
 async function fetchPageContent(pageData) {
@@ -502,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, 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() {
@@ -547,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;
             });
         }
     }
@@ -581,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;

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

@@ -23,3 +23,7 @@ export function throttledDebounce(callback, maxDebounceTimes, 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);
+}

+ 23 - 0
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;
 }
@@ -1339,6 +1350,10 @@ details[open] .summary::after {
     transform: translate(-50%, -50%);
 }
 
+.clock-time {
+    min-width: 8ch;
+}
+
 .clock-time span {
     color: var(--color-text-highlight);
 }
@@ -1493,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 {

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

+ 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"><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 }}

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

+ 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",

+ 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

+ 16 - 6
internal/glance/glance.go

@@ -75,6 +75,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
 }
 
@@ -140,15 +141,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]
+
+			if page.PrimaryColumnIndex == -1 && column.Size == "full" {
+				page.PrimaryColumnIndex = int8(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]
+			for w := range column.Widgets {
+				widget := column.Widgets[w]
 				app.widgetByID[widget.GetID()] = widget
 
 				widget.SetProviders(providers)

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

+ 31 - 10
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
 }

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

+ 9 - 7
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"
 	}
 
@@ -51,6 +52,7 @@ type Monitor struct {
 		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:"-"`
@@ -82,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)
 		}
 	}
 }

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