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

This commit is contained in:
Svilen Markov 2024-11-16 07:20:49 +00:00 committed by GitHub
commit 5e576a58e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 961 additions and 209 deletions

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

11
go.mod
View File

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

19
go.sum
View File

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

View File

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

After

Width:  |  Height:  |  Size: 300 B

View File

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

View File

@ -0,0 +1,53 @@
import { clamp } from "./utils.js";
export function setupMasonries() {
const masonryContainers = document.getElementsByClassName("masonry");
for (let i = 0; i < masonryContainers.length; i++) {
const container = masonryContainers[i];
const options = {
minColumnWidth: container.dataset.minColumnWidth || 330,
maxColumns: container.dataset.maxColumns || 6,
};
const items = Array.from(container.children);
let previousColumnsCount = 0;
const render = function() {
const columnsCount = clamp(
Math.floor(container.offsetWidth / options.minColumnWidth),
1,
Math.min(options.maxColumns, items.length)
);
if (columnsCount === previousColumnsCount) {
return;
} else {
container.textContent = "";
previousColumnsCount = columnsCount;
}
const columnsFragment = document.createDocumentFragment();
for (let i = 0; i < columnsCount; i++) {
const column = document.createElement("div");
column.className = "masonry-column";
columnsFragment.append(column);
}
// poor man's masonry
// TODO: add an option that allows placing items in the
// shortest column instead of iterating the columns in order
for (let i = 0; i < items.length; i++) {
columnsFragment.children[i % columnsCount].appendChild(items[i]);
}
container.append(columnsFragment);
};
const observer = new ResizeObserver(() => requestAnimationFrame(render));
observer.observe(container);
}
}

View File

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

View File

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

View File

@ -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%;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="masonry" data-max-columns="{{ .MaxColumns }}">
{{ range .Widgets }}
{{ .Render }}
{{ end }}
</div>
{{ end }}

View File

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

View File

@ -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
internal/feed/codeberg.go Normal file
View File

@ -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
internal/feed/custom-api.go Normal file
View File

@ -0,0 +1,148 @@
package feed
import (
"bytes"
"errors"
"html/template"
"io"
"log/slog"
"net/http"
"github.com/glanceapp/glance/internal/assets"
"github.com/tidwall/gjson"
)
func FetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
emptyBody := template.HTML("")
resp, err := defaultClient.Do(req)
if err != nil {
return emptyBody, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return emptyBody, err
}
body := string(bodyBytes)
if !gjson.Valid(body) {
truncatedBody, isTruncated := limitStringLength(body, 100)
if isTruncated {
truncatedBody += "... <truncated>"
}
slog.Error("invalid response JSON in custom API widget", "URL", req.URL.String(), "body", truncatedBody)
return emptyBody, errors.New("invalid response JSON")
}
var templateBuffer bytes.Buffer
data := CustomAPITemplateData{
JSON: DecoratedGJSONResult{gjson.Parse(body)},
Response: resp,
}
err = tmpl.Execute(&templateBuffer, &data)
if err != nil {
return emptyBody, err
}
return template.HTML(templateBuffer.String()), nil
}
type DecoratedGJSONResult struct {
gjson.Result
}
type CustomAPITemplateData struct {
JSON DecoratedGJSONResult
Response *http.Response
}
func GJsonResultArrayToDecoratedResultArray(results []gjson.Result) []DecoratedGJSONResult {
decoratedResults := make([]DecoratedGJSONResult, len(results))
for i, result := range results {
decoratedResults[i] = DecoratedGJSONResult{result}
}
return decoratedResults
}
func (r *DecoratedGJSONResult) Array(key string) []DecoratedGJSONResult {
if key == "" {
return GJsonResultArrayToDecoratedResultArray(r.Result.Array())
}
return GJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
}
func (r *DecoratedGJSONResult) String(key string) string {
if key == "" {
return r.Result.String()
}
return r.Get(key).String()
}
func (r *DecoratedGJSONResult) Int(key string) int64 {
if key == "" {
return r.Result.Int()
}
return r.Get(key).Int()
}
func (r *DecoratedGJSONResult) Float(key string) float64 {
if key == "" {
return r.Result.Float()
}
return r.Get(key).Float()
}
func (r *DecoratedGJSONResult) Bool(key string) bool {
if key == "" {
return r.Result.Bool()
}
return r.Get(key).Bool()
}
var CustomAPITemplateFuncs = func() template.FuncMap {
funcs := template.FuncMap{
"toFloat": func(a int64) float64 {
return float64(a)
},
"toInt": func(a float64) int64 {
return int64(a)
},
"mathexpr": func(left float64, op string, right float64) float64 {
if right == 0 {
return 0
}
switch op {
case "+":
return left + right
case "-":
return left - right
case "*":
return left * right
case "/":
return left / right
default:
return 0
}
},
}
for key, value := range assets.GlobalTemplateFunctions {
funcs[key] = value
}
return funcs
}()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
feed, err := feedParser.ParseURLWithContext(request.Url, ctx)
for key, value := range request.Headers {
req.Header.Add(key, value)
}
resp, err := defaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, request.Url)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
feed, err := feedParser.ParseString(string(body))
if err != nil {
return nil, err

View File

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

View File

@ -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 config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets {
widget := config.Pages[p].Columns[c].Widgets[w]
for c := range page.Columns {
column := &page.Columns[c]
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),

View File

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

View File

@ -0,0 +1,48 @@
package widget
import (
"context"
"sync"
"time"
)
type containerWidgetBase struct {
Widgets Widgets `yaml:"widgets"`
}
func (widget *containerWidgetBase) Update(ctx context.Context) {
var wg sync.WaitGroup
now := time.Now()
for w := range widget.Widgets {
widget := widget.Widgets[w]
if !widget.RequiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.Update(ctx)
}()
}
wg.Wait()
}
func (widget *containerWidgetBase) SetProviders(providers *Providers) {
for i := range widget.Widgets {
widget.Widgets[i].SetProviders(providers)
}
}
func (widget *containerWidgetBase) RequiresUpdate(now *time.Time) bool {
for i := range widget.Widgets {
if widget.Widgets[i].RequiresUpdate(now) {
return true
}
}
return false
}

View File

@ -0,0 +1,70 @@
package widget
import (
"context"
"errors"
"fmt"
"html/template"
"net/http"
"time"
"github.com/glanceapp/glance/internal/assets"
"github.com/glanceapp/glance/internal/feed"
)
type CustomApi struct {
widgetBase `yaml:",inline"`
URL OptionalEnvString `yaml:"url"`
Template string `yaml:"template"`
Frameless bool `yaml:"frameless"`
Headers map[string]OptionalEnvString `yaml:"headers"`
APIRequest *http.Request `yaml:"-"`
compiledTemplate *template.Template `yaml:"-"`
CompiledHTML template.HTML `yaml:"-"`
}
func (widget *CustomApi) Initialize() error {
widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
if widget.URL == "" {
return errors.New("URL is required for the custom API widget")
}
if widget.Template == "" {
return errors.New("template is required for the custom API widget")
}
compiledTemplate, err := template.New("").Funcs(feed.CustomAPITemplateFuncs).Parse(widget.Template)
if err != nil {
return fmt.Errorf("failed parsing custom API widget template: %w", err)
}
widget.compiledTemplate = compiledTemplate
req, err := http.NewRequest(http.MethodGet, widget.URL.String(), nil)
if err != nil {
return err
}
for key, value := range widget.Headers {
req.Header.Add(key, value.String())
}
widget.APIRequest = req
return nil
}
func (widget *CustomApi) Update(ctx context.Context) {
compiledHTML, err := feed.FetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.CompiledHTML = compiledHTML
}
func (widget *CustomApi) Render() template.HTML {
return widget.render(widget, assets.CustomAPITemplate)
}

View File

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

View File

@ -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
}
prefix, key := groups[1], groups[2]
if prefix == `\` {
if len(whole) >= 2 {
return whole[1:]
} else {
return ""
}
}
value, found := os.LookupEnv(key)
if !found {
err = fmt.Errorf("environment variable %s not found", key)
return ""
}
return prefix + value
})
if err != nil {
return err
}
value, found := os.LookupEnv(matches[1])
if !found {
return fmt.Errorf("environment variable %s not found", matches[1])
}
*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
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,47 @@
package widget
import (
"context"
"html/template"
"time"
"github.com/glanceapp/glance/internal/assets"
)
type SplitColumn struct {
widgetBase `yaml:",inline"`
containerWidgetBase `yaml:",inline"`
MaxColumns int `yaml:"max-columns"`
}
func (widget *SplitColumn) Initialize() error {
widget.withError(nil).withTitle("Split Column").SetHideHeader(true)
for i := range widget.Widgets {
if err := widget.Widgets[i].Initialize(); err != nil {
return err
}
}
if widget.MaxColumns < 2 {
widget.MaxColumns = 2
}
return nil
}
func (widget *SplitColumn) Update(ctx context.Context) {
widget.containerWidgetBase.Update(ctx)
}
func (widget *SplitColumn) SetProviders(providers *Providers) {
widget.containerWidgetBase.SetProviders(providers)
}
func (widget *SplitColumn) RequiresUpdate(now *time.Time) bool {
return widget.containerWidgetBase.RequiresUpdate(now)
}
func (widget *SplitColumn) Render() template.HTML {
return widget.render(widget, assets.SplitColumnTemplate)
}

View File

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