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 1eea49d..0cd44cf 100644
--- a/docs/configuration.md
+++ b/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
+
+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:
+
+
+
+### Custom API
+
+
### 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 |
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..56b35a5 100644
--- a/go.mod
+++ b/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
)
diff --git a/go.sum b/go.sum
index 28cb1ae..be33712 100644
--- a/go.sum
+++ b/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=
diff --git a/internal/assets/static/icons/codeberg.svg b/internal/assets/static/icons/codeberg.svg
new file mode 100644
index 0000000..3bb3c9f
--- /dev/null
+++ b/internal/assets/static/icons/codeberg.svg
@@ -0,0 +1 @@
+
diff --git a/internal/assets/static/js/main.js b/internal/assets/static/js/main.js
index 228f57d..25d78ca 100644
--- a/internal/assets/static/js/main.js
+++ b/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, 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() {
@@ -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 {
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..533feed 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;
@@ -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;
diff --git a/internal/assets/static/js/utils.js b/internal/assets/static/js/utils.js
new file mode 100644
index 0000000..ddf7e4f
--- /dev/null
+++ b/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);
+}
diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css
index c2a4acd..3c342a0 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;
}
@@ -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%;
diff --git a/internal/assets/templates.go b/internal/assets/templates.go
index 85abb69..324f8ca 100644
--- a/internal/assets/templates.go
+++ b/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 {
diff --git a/internal/assets/templates/bookmarks.html b/internal/assets/templates/bookmarks.html
index a4e2c97..25afa4d 100644
--- a/internal/assets/templates/bookmarks.html
+++ b/internal/assets/templates/bookmarks.html
@@ -8,9 +8,9 @@
{{ .StreamTitle }}
+