diff --git a/Dockerfile b/Dockerfile
index e4019ba..48f214b 100644
--- a/Dockerfile
+++ b/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
diff --git a/docs/configuration.md b/docs/configuration.md
index a2c7cbd..000edbb 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -15,6 +15,7 @@
- [Reddit](#reddit)
- [Search](#search-widget)
- [Group](#group)
+ - [Split Column](#split-column)
- [Extension](#extension)
- [Weather](#weather)
- [Monitor](#monitor)
@@ -890,7 +891,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 +934,63 @@ Example:
<<: *shared-properties
```
+### Split Column
+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:
+
+View config
+
+```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
+```
+
+
+Preview:
+
+
+
### 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 +1006,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.
@@ -1108,7 +1170,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`
@@ -1395,7 +1457,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`
@@ -1549,7 +1611,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 |
diff --git a/docs/images/split-column-widget-preview.png b/docs/images/split-column-widget-preview.png
new file mode 100644
index 0000000..f1931f8
Binary files /dev/null and b/docs/images/split-column-widget-preview.png differ
diff --git a/go.mod b/go.mod
index 17aa4d4..6b39a2e 100644
--- a/go.mod
+++ b/go.mod
@@ -1,19 +1,19 @@
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
+ 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
+ golang.org/x/net v0.29.0 // indirect
)
diff --git a/go.sum b/go.sum
index 28cb1ae..ed770ea 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
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=
@@ -35,6 +37,8 @@ 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=
@@ -56,6 +60,8 @@ 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=
diff --git a/internal/assets/static/js/main.js b/internal/assets/static/js/main.js
index ffa7eb7..ed8419a 100644
--- a/internal/assets/static/js/main.js
+++ b/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) {
@@ -581,6 +582,7 @@ async function setupPage() {
setupCollapsibleLists();
setupCollapsibleGrids();
setupGroups();
+ setupMasonries();
setupDynamicRelativeTime();
setupLazyImages();
} finally {
diff --git a/internal/assets/static/js/masonry.js b/internal/assets/static/js/masonry.js
new file mode 100644
index 0000000..45680f4
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/internal/assets/static/js/popover.js b/internal/assets/static/js/popover.js
index d6578ee..42a9b57 100644
--- a/internal/assets/static/js/popover.js
+++ b/internal/assets/static/js/popover.js
@@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() {
}
function showPopover() {
+ if (pendingTarget === null) return;
+
activeTarget = pendingTarget;
pendingTarget = null;
diff --git a/internal/assets/static/js/utils.js b/internal/assets/static/js/utils.js
index af02086..ddf7e4f 100644
--- a/internal/assets/static/js/utils.js
+++ b/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);
+}
diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css
index d5ab9bb..d8152c5 100644
--- a/internal/assets/static/main.css
+++ b/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;
}
@@ -1493,6 +1504,13 @@ details[open] .summary::after {
border: 2px solid var(--color-widget-background);
}
+.twitch-stream-preview {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ border-radius: var(--border-radius);
+ object-fit: cover;
+}
+
.reddit-card-thumbnail {
width: 100%;
height: 100%;
diff --git a/internal/assets/templates.go b/internal/assets/templates.go
index 85abb69..0533b75 100644
--- a/internal/assets/templates.go
+++ b/internal/assets/templates.go
@@ -39,6 +39,7 @@ 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")
)
var globalTemplateFunctions = template.FuncMap{
diff --git a/internal/assets/templates/page.html b/internal/assets/templates/page.html
index d2cee76..18d8b85 100644
--- a/internal/assets/templates/page.html
+++ b/internal/assets/templates/page.html
@@ -44,7 +44,7 @@