Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91324e8de7 | ||
|
|
d63afc2c6d | ||
|
|
315e39d61e | ||
|
|
b1077531bf | ||
|
|
9ecc937dc4 | ||
|
|
7202b68733 | ||
|
|
6c5b7a3f4c | ||
|
|
36d5ae023f | ||
|
|
478c08f6a7 | ||
|
|
cae90d16ba | ||
|
|
fbc07bd142 | ||
|
|
4a4d3e1755 | ||
|
|
f243a4938f | ||
|
|
9416de1497 | ||
|
|
283a5fcfd0 | ||
|
|
c88fd526e5 | ||
|
|
f0541ea5c8 | ||
|
|
8f986f1403 | ||
|
|
de9a192ba4 | ||
|
|
88d8fa56fb | ||
|
|
9044e640bc | ||
|
|
808f3c1436 | ||
|
|
d103c81df1 | ||
|
|
429f0be675 | ||
|
|
5a093f42b0 | ||
|
|
ded435df0e | ||
|
|
e52374fa24 | ||
|
|
b94647efc9 | ||
|
|
d512770c10 | ||
|
|
ea52318be6 | ||
|
|
9e5023522b | ||
|
|
4d0fdd9b11 | ||
|
|
78725c8591 | ||
|
|
f7adaad1c5 | ||
|
|
ab093cb232 | ||
|
|
2aaff02db8 | ||
|
|
f5bfd9d4d1 | ||
|
|
9e3639eb54 | ||
|
|
b4094b28bd | ||
|
|
b294839b79 | ||
|
|
14a21de37f | ||
|
|
d9239acbce | ||
|
|
a2247c0b6c | ||
|
|
c1aaec5ffc | ||
|
|
bcef9fbd61 | ||
|
|
32db59fda2 | ||
|
|
92bc68b61a | ||
|
|
a6382b2e1d | ||
|
|
571cdaf618 | ||
|
|
aa3b2c3b1b | ||
|
|
9bbf73db97 | ||
|
|
dc950e7ec2 | ||
|
|
91ca57e242 | ||
|
|
95541ef5e1 | ||
|
|
1ace129a58 | ||
|
|
af04605d7d | ||
|
|
0ec9cf4aaa | ||
|
|
a5b0664b9c | ||
|
|
c67eb4d2c0 | ||
|
|
e99ee4f774 | ||
|
|
74e6c5c960 | ||
|
|
f801da88a7 | ||
|
|
66dcd1fa34 | ||
|
|
4f9e48cc17 | ||
|
|
95702a2e53 |
@@ -1 +1,2 @@
|
|||||||
github: [glanceapp]
|
github: [glanceapp]
|
||||||
|
patreon: glanceapp
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ builds:
|
|||||||
- 386
|
- 386
|
||||||
goarm:
|
goarm:
|
||||||
- 7
|
- 7
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X github.com/glanceapp/glance/internal/glance.buildVersion={{ .Tag }}
|
- -s -w -X github.com/glanceapp/glance/internal/glance.buildVersion={{ .Tag }}
|
||||||
|
|
||||||
|
|||||||
+2
-5
@@ -1,16 +1,13 @@
|
|||||||
FROM golang:1.24.2-alpine3.21 AS builder
|
FROM golang:1.26.3-alpine3.22 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . /app
|
COPY . /app
|
||||||
RUN CGO_ENABLED=0 go build .
|
RUN CGO_ENABLED=0 go build .
|
||||||
|
|
||||||
FROM alpine:3.21
|
FROM alpine:3.22
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/glance .
|
COPY --from=builder /app/glance .
|
||||||
|
|
||||||
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
|
|
||||||
CMD wget --spider -q http://localhost:8080/api/healthz
|
|
||||||
|
|
||||||
EXPOSE 8080/tcp
|
EXPOSE 8080/tcp
|
||||||
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
FROM alpine:3.21
|
FROM alpine:3.22
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY glance .
|
COPY glance .
|
||||||
|
|
||||||
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
|
|
||||||
CMD wget --spider -q http://localhost:8080/api/healthz
|
|
||||||
|
|
||||||
EXPOSE 8080/tcp
|
EXPOSE 8080/tcp
|
||||||
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
<p align="center"><em>What if you could see everything at a...</em></p>
|
<p align="center"><img src="docs/logo.png"></p>
|
||||||
<h1 align="center">Glance</h1>
|
<h1 align="center">Glance</h1>
|
||||||
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a> • <a href="https://github.com/sponsors/glanceapp">Sponsor</a></p>
|
<p align="center">
|
||||||
<p align="center"><a href="https://github.com/glanceapp/community-widgets">Community widgets</a> • <a href="docs/preconfigured-pages.md">Preconfigured pages</a> • <a href="docs/themes.md">Themes</a></p>
|
<a href="#installation">Install</a> •
|
||||||
|
<a href="docs/configuration.md#configuring-glance">Configuration</a> •
|
||||||
|
<a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a> •
|
||||||
|
<a href="https://github.com/sponsors/glanceapp">Sponsor</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/glanceapp/community-widgets">Community widgets</a> •
|
||||||
|
<a href="docs/preconfigured-pages.md">Preconfigured pages</a> •
|
||||||
|
<a href="docs/themes.md">Themes</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">A lightweight, highly customizable dashboard that displays<br> your feeds in a beautiful, streamlined interface</p>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -17,7 +28,7 @@
|
|||||||
* Docker containers status
|
* Docker containers status
|
||||||
* Server stats
|
* Server stats
|
||||||
* Custom widgets
|
* Custom widgets
|
||||||
* [and many more...](docs/configuration.md)
|
* [and many more...](docs/configuration.md#configuring-glance)
|
||||||
|
|
||||||
### Fast and lightweight
|
### Fast and lightweight
|
||||||
* Low memory usage
|
* Low memory usage
|
||||||
@@ -46,8 +57,7 @@ Easily create your own theme by tweaking a few numbers or choose from one of the
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
Configuration is done through YAML files, to learn more about how the layout works, how to add more pages and how to configure widgets, visit the [configuration documentation](docs/configuration.md).
|
Configuration is done through YAML files, to learn more about how the layout works, how to add more pages and how to configure widgets, visit the [configuration documentation](docs/configuration.md#configuring-glance).
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Preview example configuration file</strong></summary>
|
<summary><strong>Preview example configuration file</strong></summary>
|
||||||
<br>
|
<br>
|
||||||
@@ -257,8 +267,9 @@ Download and extract the executable from the [latest release](https://github.com
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
Glance can also be installed through the following 3rd party channels:
|
Glance can also be installed through the following 3rd party channels:
|
||||||
* [Proxmox VE Helper Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=glance)
|
* [Proxmox VE Helper Script](https://community-scripts.org/scripts/glance?id=glance)
|
||||||
* [NixOS package](https://search.nixos.org/packages?channel=unstable&show=glance)
|
* [NixOS package](https://search.nixos.org/packages?channel=unstable&show=glance)
|
||||||
|
* [Hostinger](https://www.hostinger.com/vps/docker/glance)
|
||||||
* [Coolify.io](https://coolify.io/docs/services/glance/)
|
* [Coolify.io](https://coolify.io/docs/services/glance/)
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|||||||
+44
-28
@@ -6,6 +6,7 @@
|
|||||||
- [Environment variables](#environment-variables)
|
- [Environment variables](#environment-variables)
|
||||||
- [Other ways of providing tokens/passwords/secrets](#other-ways-of-providing-tokenspasswordssecrets)
|
- [Other ways of providing tokens/passwords/secrets](#other-ways-of-providing-tokenspasswordssecrets)
|
||||||
- [Including other config files](#including-other-config-files)
|
- [Including other config files](#including-other-config-files)
|
||||||
|
- [Icons](#icons)
|
||||||
- [Config schema](#config-schema)
|
- [Config schema](#config-schema)
|
||||||
- [Authentication](#authentication)
|
- [Authentication](#authentication)
|
||||||
- [Server](#server)
|
- [Server](#server)
|
||||||
@@ -148,14 +149,14 @@ pages:
|
|||||||
columns:
|
columns:
|
||||||
- size: full
|
- size: full
|
||||||
widgets:
|
widgets:
|
||||||
$include: rss.yml
|
- $include: rss.yml
|
||||||
- name: News
|
- name: News
|
||||||
columns:
|
columns:
|
||||||
- size: full
|
- size: full
|
||||||
widgets:
|
widgets:
|
||||||
- type: group
|
- type: group
|
||||||
widgets:
|
widgets:
|
||||||
$include: rss.yml
|
- $include: rss.yml
|
||||||
- type: reddit
|
- type: reddit
|
||||||
subreddit: news
|
subreddit: news
|
||||||
```
|
```
|
||||||
@@ -185,6 +186,30 @@ docker run --rm -v ./glance.yml:/app/config/glance.yml glanceapp/glance config:p
|
|||||||
|
|
||||||
This assumes that the config you want to print is in your current working directory and is named `glance.yml`.
|
This assumes that the config you want to print is in your current working directory and is named `glance.yml`.
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
For widgets which provide you with the ability to specify icons such as the monitor, bookmarks, docker containers, etc, you can use the `icon` property to specify a URL to an image or use icon names from multiple libraries via prefixes:
|
||||||
|
|
||||||
|
```yml
|
||||||
|
icon: si:immich # si for Simple icons https://simpleicons.org/
|
||||||
|
icon: sh:immich # sh for selfh.st icons https://selfh.st/icons/
|
||||||
|
icon: di:immich # di for Dashboard icons https://github.com/homarr-labs/dashboard-icons
|
||||||
|
icon: mdi:camera # mdi for Material Design icons https://pictogrammers.com/library/mdi/
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
>
|
||||||
|
> The 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.
|
||||||
|
|
||||||
|
Icons from the Simple icons library as well as Material Design icons will automatically invert their color to match your light or dark theme, however you may want to enable this manually for other icons. To do this, you can use the `auto-invert` prefix:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
icon: auto-invert https://example.com/path/to/icon.png # with a URL
|
||||||
|
icon: auto-invert sh:glance-dark # with a selfh.st icon
|
||||||
|
```
|
||||||
|
|
||||||
|
This expects the icon to be black and will automatically invert it to white when using a dark theme.
|
||||||
|
|
||||||
## Config schema
|
## Config schema
|
||||||
|
|
||||||
For property descriptions, validation and autocompletion of the config within your IDE, @not-first has kindly created a [schema](https://github.com/not-first/glance-schema). Massive thanks to them for this, go check it out and give them a star!
|
For property descriptions, validation and autocompletion of the config within your IDE, @not-first has kindly created a [schema](https://github.com/not-first/glance-schema). Massive thanks to them for this, go check it out and give them a star!
|
||||||
@@ -389,10 +414,12 @@ Example:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
theme:
|
theme:
|
||||||
|
# This will be the default theme
|
||||||
background-color: 100 20 10
|
background-color: 100 20 10
|
||||||
primary-color: 40 90 40
|
primary-color: 40 90 40
|
||||||
contrast-multiplier: 1.1
|
contrast-multiplier: 1.1
|
||||||
|
|
||||||
|
disable-picker: false
|
||||||
presets:
|
presets:
|
||||||
gruvbox-dark:
|
gruvbox-dark:
|
||||||
background-color: 0 0 16
|
background-color: 0 0 16
|
||||||
@@ -421,6 +448,7 @@ If you don't want to spend time configuring your own theme, there are [several a
|
|||||||
| contrast-multiplier | number | no | 1 |
|
| contrast-multiplier | number | no | 1 |
|
||||||
| text-saturation-multiplier | number | no | 1 |
|
| text-saturation-multiplier | number | no | 1 |
|
||||||
| custom-css-file | string | no | |
|
| custom-css-file | string | no | |
|
||||||
|
| disable-picker | bool | false | |
|
||||||
| presets | object | no | |
|
| presets | object | no | |
|
||||||
|
|
||||||
#### `light`
|
#### `light`
|
||||||
@@ -466,8 +494,11 @@ theme:
|
|||||||
>
|
>
|
||||||
> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets.
|
> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets.
|
||||||
|
|
||||||
|
#### `disable-picker`
|
||||||
|
When set to `true` hides the theme picker and disables the abiltity to switch between themes. All users who previously picked a non-default theme will be switched over to the default theme.
|
||||||
|
|
||||||
#### `presets`
|
#### `presets`
|
||||||
Define additional theme presets that can be selected from the theme switcher on the page. For each preset, you can specify the same properties as for the default theme, such as `background-color`, `primary-color`, `positive-color`, `negative-color`, `contrast-multiplier`, etc., except for the `custom-css-file` property.
|
Define additional theme presets that can be selected from the theme picker on the page. For each preset, you can specify the same properties as for the default theme, such as `background-color`, `primary-color`, `positive-color`, `negative-color`, `contrast-multiplier`, etc., except for the `custom-css-file` property.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -866,6 +897,11 @@ A list of playlist IDs:
|
|||||||
- PL8mG-RkN2uTxTK4m_Vl2dYR9yE41kRdBg
|
- PL8mG-RkN2uTxTK4m_Vl2dYR9yE41kRdBg
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The playlist ID can be found in its link which is in the form of
|
||||||
|
```
|
||||||
|
https://www.youtube.com...&list={ID}&...
|
||||||
|
```
|
||||||
|
|
||||||
##### `limit`
|
##### `limit`
|
||||||
The maximum number of videos to show.
|
The maximum number of videos to show.
|
||||||
|
|
||||||
@@ -1951,17 +1987,7 @@ If the monitored service returns an error, the user will be redirected here. If
|
|||||||
|
|
||||||
`icon`
|
`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 or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix:
|
See [Icons](#icons) for more information on how to specify icons.
|
||||||
|
|
||||||
```yaml
|
|
||||||
icon: si:jellyfin
|
|
||||||
icon: si:gitea
|
|
||||||
icon: si:adguard
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
>
|
|
||||||
> 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.
|
|
||||||
|
|
||||||
`timeout`
|
`timeout`
|
||||||
|
|
||||||
@@ -1990,7 +2016,7 @@ HTTP Basic Authentication credentials for protected sites.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
basic-auth:
|
basic-auth:
|
||||||
usename: your-username
|
username: your-username
|
||||||
password: your-password
|
password: your-password
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2141,7 +2167,7 @@ Alternatively, you can also define the values within your `glance.yml` via the `
|
|||||||
- type: docker-containers
|
- type: docker-containers
|
||||||
containers:
|
containers:
|
||||||
container_name_1:
|
container_name_1:
|
||||||
title: Container Name
|
name: Container Name
|
||||||
description: Description of the container
|
description: Description of the container
|
||||||
url: https://container.domain.com
|
url: https://container.domain.com
|
||||||
icon: si:container-icon
|
icon: si:container-icon
|
||||||
@@ -2269,7 +2295,7 @@ Whether to only show running containers. If set to `true` only containers that a
|
|||||||
| Name | Description |
|
| Name | Description |
|
||||||
| ---- | ----------- |
|
| ---- | ----------- |
|
||||||
| glance.name | The name displayed in the UI. If not specified, the name of the container will be used. |
|
| glance.name | The name displayed in the UI. If not specified, the name of the container will be used. |
|
||||||
| glance.icon | The icon displayed in the UI. Can be an external URL or an icon prefixed with si:, sh: or di: like with the bookmarks and monitor widgets |
|
| glance.icon | See [Icons](#icons) for more information on how to specify icons |
|
||||||
| glance.url | The URL that the user will be redirected to when clicking on the container. |
|
| glance.url | The URL that the user will be redirected to when clicking on the container. |
|
||||||
| glance.same-tab | Whether to open the link in the same or a new tab. Default is `false`. |
|
| glance.same-tab | Whether to open the link in the same or a new tab. Default is `false`. |
|
||||||
| glance.description | A short description displayed in the UI. Default is empty. |
|
| glance.description | A short description displayed in the UI. Default is empty. |
|
||||||
@@ -2584,17 +2610,7 @@ An array of groups which can optionally have a title and a custom color.
|
|||||||
|
|
||||||
`icon`
|
`icon`
|
||||||
|
|
||||||
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:
|
See [Icons](#icons) for more information on how to specify icons.
|
||||||
|
|
||||||
```yaml
|
|
||||||
icon: si:gmail
|
|
||||||
icon: si:youtube
|
|
||||||
icon: si:reddit
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
>
|
|
||||||
> 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`
|
`same-tab`
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -238,7 +238,7 @@ Output:
|
|||||||
<div>90</div>
|
<div>90</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
Other operations include `add`, `mul`, and `div`.
|
Other operations include `add`, `mul`, `div` and `mod`.
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
@@ -415,6 +415,14 @@ The following functions are available on the `JSON` object:
|
|||||||
- `Array(key string) []JSON`: Returns the value of the key as an array of `JSON` objects.
|
- `Array(key string) []JSON`: Returns the value of the key as an array of `JSON` objects.
|
||||||
- `Exists(key string) bool`: Returns true if the key exists in the JSON object.
|
- `Exists(key string) bool`: Returns true if the key exists in the JSON object.
|
||||||
|
|
||||||
|
The following functions are available on the `Options` object:
|
||||||
|
|
||||||
|
- `StringOr(key string, default string) string`: Returns the value of the key as a string, or the default value if the key does not exist.
|
||||||
|
- `IntOr(key string, default int) int`: Returns the value of the key as an integer, or the default value if the key does not exist.
|
||||||
|
- `FloatOr(key string, default float) float`: Returns the value of the key as a float, or the default value if the key does not exist.
|
||||||
|
- `BoolOr(key string, default bool) bool`: Returns the value of the key as a boolean, or the default value if the key does not exist.
|
||||||
|
- `JSON(key string) JSON`: Returns the value of the key as a stringified `JSON` object, or throws an error if the key does not exist.
|
||||||
|
|
||||||
The following helper functions provided by Glance are available:
|
The following helper functions provided by Glance are available:
|
||||||
|
|
||||||
- `toFloat(i int) float`: Converts an integer to a float.
|
- `toFloat(i int) float`: Converts an integer to a float.
|
||||||
@@ -431,6 +439,7 @@ The following helper functions provided by Glance are available:
|
|||||||
- `sub(a, b float) float`: Subtracts two numbers.
|
- `sub(a, b float) float`: Subtracts two numbers.
|
||||||
- `mul(a, b float) float`: Multiplies two numbers.
|
- `mul(a, b float) float`: Multiplies two numbers.
|
||||||
- `div(a, b float) float`: Divides two numbers.
|
- `div(a, b float) float`: Divides two numbers.
|
||||||
|
- `mod(a, b int) int`: Remainder after dividing a by b (a % b).
|
||||||
- `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k.
|
- `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k.
|
||||||
- `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000.
|
- `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000.
|
||||||
- `trimPrefix(prefix string, str string) string`: Trims the prefix from a string.
|
- `trimPrefix(prefix string, str string) string`: Trims the prefix from a string.
|
||||||
@@ -447,17 +456,19 @@ The following helper functions provided by Glance are available:
|
|||||||
- `concat(strings ...string) string`: Concatenates multiple strings together.
|
- `concat(strings ...string) string`: Concatenates multiple strings together.
|
||||||
- `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key.
|
- `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key.
|
||||||
- `percentChange(current float, previous float) float`: Calculates the percentage change between two numbers.
|
- `percentChange(current float, previous float) float`: Calculates the percentage change between two numbers.
|
||||||
|
- `startOfDay(t time.Time) time.Time`: Returns the start of the day for a given time.
|
||||||
|
- `endOfDay(t time.Time) time.Time`: Returns the end of the day for a given time.
|
||||||
|
|
||||||
The following helper functions provided by Go's `text/template` are available:
|
The following helper functions provided by Go's `text/template` are available:
|
||||||
|
|
||||||
- `eq(a, b any) bool`: Compares two values for equality.
|
- `eq(a, b any) bool`: Compares two values for equality.
|
||||||
- `ne(a, b any) bool`: Compares two values for inequality.
|
- `ne(a, b any) bool`: Compares two values for inequality.
|
||||||
- `lt(a, b any) bool`: Compares two values for less than.
|
- `lt(a, b any) bool`: Compares two values for less than.
|
||||||
- `lte(a, b any) bool`: Compares two values for less than or equal to.
|
- `le(a, b any) bool`: Compares two values for less than or equal to.
|
||||||
- `gt(a, b any) bool`: Compares two values for greater than.
|
- `gt(a, b any) bool`: Compares two values for greater than.
|
||||||
- `gte(a, b any) bool`: Compares two values for greater than or equal to.
|
- `ge(a, b any) bool`: Compares two values for greater than or equal to.
|
||||||
- `and(a, b bool) bool`: Returns true if both values are true.
|
- `and(args ...bool) bool`: Returns true if **all** arguments are true; accepts two or more boolean values.
|
||||||
- `or(a, b bool) bool`: Returns true if either value is true.
|
- `or(args ...bool) bool`: Returns true if **any** argument is true; accepts two or more boolean values.
|
||||||
- `not(a bool) bool`: Returns the opposite of the value.
|
- `not(a bool) bool`: Returns the opposite of the value.
|
||||||
- `index(a any, b int) any`: Returns the value at the specified index of an array.
|
- `index(a any, b int) any`: Returns the value at the specified index of an array.
|
||||||
- `len(a any) int`: Returns the length of an array.
|
- `len(a any) int`: Returns the length of an array.
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ pages:
|
|||||||
channels:
|
channels:
|
||||||
- theprimeagen
|
- theprimeagen
|
||||||
- j_blow
|
- j_blow
|
||||||
- piratesoftware
|
- giantwaffle
|
||||||
- cohhcarnage
|
- cohhcarnage
|
||||||
- christitustech
|
- christitustech
|
||||||
- EJ_SA
|
- EJ_SA
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 367 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
+80
-80
@@ -86,92 +86,92 @@ Pull requests with your page configurations are welcome!
|
|||||||
<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
|
<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Markets
|
- name: Markets
|
||||||
columns:
|
columns:
|
||||||
- size: small
|
- size: small
|
||||||
widgets:
|
widgets:
|
||||||
- type: markets
|
- type: markets
|
||||||
title: Indices
|
title: Indices
|
||||||
markets:
|
markets:
|
||||||
- symbol: SPY
|
- symbol: SPY
|
||||||
name: S&P 500
|
name: S&P 500
|
||||||
- symbol: DX-Y.NYB
|
- symbol: DX-Y.NYB
|
||||||
name: Dollar Index
|
name: Dollar Index
|
||||||
|
|
||||||
- type: markets
|
- type: markets
|
||||||
title: Crypto
|
title: Crypto
|
||||||
markets:
|
markets:
|
||||||
- symbol: BTC-USD
|
- symbol: BTC-USD
|
||||||
name: Bitcoin
|
name: Bitcoin
|
||||||
- symbol: ETH-USD
|
- symbol: ETH-USD
|
||||||
name: Ethereum
|
name: Ethereum
|
||||||
|
|
||||||
- type: markets
|
- type: markets
|
||||||
title: Stocks
|
title: Stocks
|
||||||
sort-by: absolute-change
|
sort-by: absolute-change
|
||||||
markets:
|
markets:
|
||||||
- symbol: NVDA
|
- symbol: NVDA
|
||||||
name: NVIDIA
|
name: NVIDIA
|
||||||
- symbol: AAPL
|
- symbol: AAPL
|
||||||
name: Apple
|
name: Apple
|
||||||
- symbol: MSFT
|
- symbol: MSFT
|
||||||
name: Microsoft
|
name: Microsoft
|
||||||
- symbol: GOOGL
|
- symbol: GOOGL
|
||||||
name: Google
|
name: Google
|
||||||
- symbol: AMD
|
- symbol: AMD
|
||||||
name: AMD
|
name: AMD
|
||||||
- symbol: RDDT
|
- symbol: RDDT
|
||||||
name: Reddit
|
name: Reddit
|
||||||
- symbol: AMZN
|
- symbol: AMZN
|
||||||
name: Amazon
|
name: Amazon
|
||||||
- symbol: TSLA
|
- symbol: TSLA
|
||||||
name: Tesla
|
name: Tesla
|
||||||
- symbol: INTC
|
- symbol: INTC
|
||||||
name: Intel
|
name: Intel
|
||||||
- symbol: META
|
- symbol: META
|
||||||
name: Meta
|
name: Meta
|
||||||
|
|
||||||
- size: full
|
- size: full
|
||||||
widgets:
|
widgets:
|
||||||
- type: rss
|
- type: rss
|
||||||
title: News
|
title: News
|
||||||
style: horizontal-cards
|
style: horizontal-cards
|
||||||
feeds:
|
feeds:
|
||||||
- url: https://feeds.bloomberg.com/markets/news.rss
|
- url: https://feeds.bloomberg.com/markets/news.rss
|
||||||
title: Bloomberg
|
title: Bloomberg
|
||||||
- url: https://moxie.foxbusiness.com/google-publisher/markets.xml
|
- url: https://moxie.foxbusiness.com/google-publisher/markets.xml
|
||||||
title: Fox Business
|
title: Fox Business
|
||||||
- url: https://moxie.foxbusiness.com/google-publisher/technology.xml
|
- url: https://moxie.foxbusiness.com/google-publisher/technology.xml
|
||||||
title: Fox Business
|
title: Fox Business
|
||||||
|
|
||||||
- type: group
|
- type: group
|
||||||
widgets:
|
widgets:
|
||||||
- type: reddit
|
- type: reddit
|
||||||
show-thumbnails: true
|
show-thumbnails: true
|
||||||
subreddit: technology
|
subreddit: technology
|
||||||
- type: reddit
|
- type: reddit
|
||||||
show-thumbnails: true
|
show-thumbnails: true
|
||||||
subreddit: wallstreetbets
|
subreddit: wallstreetbets
|
||||||
|
|
||||||
- type: videos
|
- type: videos
|
||||||
style: grid-cards
|
style: grid-cards
|
||||||
collapse-after-rows: 3
|
collapse-after-rows: 3
|
||||||
channels:
|
channels:
|
||||||
- UCvSXMi2LebwJEM1s4bz5IBA # New Money
|
- UCvSXMi2LebwJEM1s4bz5IBA # New Money
|
||||||
- UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan
|
- UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan
|
||||||
- UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve
|
- UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve
|
||||||
|
|
||||||
- size: small
|
- size: small
|
||||||
widgets:
|
widgets:
|
||||||
- type: rss
|
- type: rss
|
||||||
title: News
|
title: News
|
||||||
limit: 30
|
limit: 30
|
||||||
collapse-after: 13
|
collapse-after: 13
|
||||||
feeds:
|
feeds:
|
||||||
- url: https://www.ft.com/technology?format=rss
|
- url: https://www.ft.com/technology?format=rss
|
||||||
title: Financial Times
|
title: Financial Times
|
||||||
- url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml
|
- url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml
|
||||||
title: Wall Street Journal
|
title: Wall Street Journal
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,28 @@ theme:
|
|||||||
negative-color: 0 100 67
|
negative-color: 0 100 67
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Shades of Purple
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 243 33 25
|
||||||
|
contrast-multiplier: 1.2
|
||||||
|
primary-color: 50 100 49
|
||||||
|
positive-color: 98 82 71
|
||||||
|
negative-color: 12 77 52
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neon Pink
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 240 27 11
|
||||||
|
contrast-multiplier: 1.5
|
||||||
|
primary-color: 321 100 71
|
||||||
|
positive-color: 165 78 51
|
||||||
|
negative-color: 360 100 71
|
||||||
|
```
|
||||||
|
|
||||||
## Light
|
## Light
|
||||||
|
|
||||||
### Catppuccin Latte
|
### Catppuccin Latte
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
module github.com/glanceapp/glance
|
module github.com/glanceapp/glance
|
||||||
|
|
||||||
go 1.24.2
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/mmcdole/gofeed v1.3.0
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
github.com/shirou/gopsutil/v4 v4.25.4
|
github.com/refraction-networking/utls v1.8.2
|
||||||
|
github.com/shirou/gopsutil/v4 v4.25.5
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.38.0
|
||||||
|
golang.org/x/net v0.40.0
|
||||||
golang.org/x/text v0.25.0
|
golang.org/x/text v0.25.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.10.3 // indirect
|
github.com/PuerkitoBio/goquery v1.10.3 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
github.com/ebitengine/purego v0.8.2 // indirect
|
github.com/ebitengine/purego v0.8.4 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.4 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
||||||
github.com/mmcdole/goxpp v1.1.1 // indirect
|
github.com/mmcdole/goxpp v1.1.1 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
@@ -28,6 +32,5 @@ require (
|
|||||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/net v0.40.0 // indirect
|
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
|
||||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
|
||||||
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
|
|
||||||
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
|
|
||||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||||
|
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||||
|
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
|
||||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
@@ -20,11 +16,12 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
|||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
|
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||||
@@ -40,12 +37,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
|
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
|
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
|
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
@@ -57,12 +52,8 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
|
|||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
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 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
|
|
||||||
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
|
|
||||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
@@ -74,8 +65,6 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
|
||||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
|
||||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
@@ -92,10 +81,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
|
||||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
|
||||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
|
||||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
|
||||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -119,10 +104,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
@@ -143,10 +124,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
|
||||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
|
||||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -131,52 +131,45 @@ func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
|
|
||||||
type customIconField struct {
|
type customIconField struct {
|
||||||
URL template.URL
|
URL template.URL
|
||||||
IsFlatIcon bool
|
AutoInvert 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 newCustomIconField(value string) customIconField {
|
func newCustomIconField(value string) customIconField {
|
||||||
const autoInvertPrefix = "auto-invert "
|
const autoInvertPrefix = "auto-invert "
|
||||||
field := customIconField{}
|
field := customIconField{}
|
||||||
|
|
||||||
|
if strings.HasPrefix(value, autoInvertPrefix) {
|
||||||
|
field.AutoInvert = true
|
||||||
|
value = strings.TrimPrefix(value, autoInvertPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
prefix, icon, found := strings.Cut(value, ":")
|
prefix, icon, found := strings.Cut(value, ":")
|
||||||
if !found {
|
if !found {
|
||||||
if strings.HasPrefix(value, autoInvertPrefix) {
|
|
||||||
field.IsFlatIcon = true
|
|
||||||
value = strings.TrimPrefix(value, autoInvertPrefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
field.URL = template.URL(value)
|
field.URL = template.URL(value)
|
||||||
return field
|
return field
|
||||||
}
|
}
|
||||||
|
|
||||||
|
basename, ext, found := strings.Cut(icon, ".")
|
||||||
|
if !found {
|
||||||
|
ext = "svg"
|
||||||
|
basename = icon
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext != "svg" && ext != "png" {
|
||||||
|
ext = "svg"
|
||||||
|
}
|
||||||
|
|
||||||
switch prefix {
|
switch prefix {
|
||||||
case "si":
|
case "si":
|
||||||
field.URL = template.URL("https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg")
|
field.AutoInvert = true
|
||||||
field.IsFlatIcon = true
|
field.URL = template.URL("https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + basename + ".svg")
|
||||||
case "di", "sh":
|
case "di":
|
||||||
// syntax: di:<icon_name>[.svg|.png]
|
field.URL = template.URL("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext)
|
||||||
// syntax: sh:<icon_name>[.svg|.png]
|
case "mdi":
|
||||||
// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
|
field.AutoInvert = true
|
||||||
// otherwise, specify the extension of either .svg or .png to use either of the CDN offerings
|
field.URL = template.URL("https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/" + basename + ".svg")
|
||||||
// any other extension will be interpreted as .svg
|
case "sh":
|
||||||
basename, ext, found := strings.Cut(icon, ".")
|
field.URL = template.URL("https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext)
|
||||||
if !found {
|
|
||||||
ext = "svg"
|
|
||||||
basename = icon
|
|
||||||
}
|
|
||||||
|
|
||||||
if ext != "svg" && ext != "png" {
|
|
||||||
ext = "svg"
|
|
||||||
}
|
|
||||||
|
|
||||||
if prefix == "di" {
|
|
||||||
field.URL = template.URL("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext)
|
|
||||||
} else {
|
|
||||||
field.URL = template.URL("https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext)
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
field.URL = template.URL(value)
|
field.URL = template.URL(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ type config struct {
|
|||||||
|
|
||||||
Theme struct {
|
Theme struct {
|
||||||
themeProperties `yaml:",inline"`
|
themeProperties `yaml:",inline"`
|
||||||
CustomCSSFile string `yaml:"custom-css-file"`
|
CustomCSSFile string `yaml:"custom-css-file"`
|
||||||
Presets orderedYAMLMap[string, *themeProperties] `yaml:"presets"`
|
|
||||||
|
DisablePicker bool `yaml:"disable-picker"`
|
||||||
|
Presets orderedYAMLMap[string, *themeProperties] `yaml:"presets"`
|
||||||
} `yaml:"theme"`
|
} `yaml:"theme"`
|
||||||
|
|
||||||
Branding struct {
|
Branding struct {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const httpTestRequestTimeout = 10 * time.Second
|
const httpTestRequestTimeout = 15 * time.Second
|
||||||
|
|
||||||
var diagnosticSteps = []diagnosticStep{
|
var diagnosticSteps = []diagnosticStep{
|
||||||
{
|
{
|
||||||
@@ -75,7 +75,9 @@ var diagnosticSteps = []diagnosticStep{
|
|||||||
{
|
{
|
||||||
name: "fetch data from Reddit API",
|
name: "fetch data from Reddit API",
|
||||||
fn: func() (string, error) {
|
fn: func() (string, error) {
|
||||||
return testHttpRequest("GET", "https://www.reddit.com/search.json", 200)
|
return testHttpRequestWithHeaders("GET", "https://www.reddit.com/search.json", map[string]string{
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
|
||||||
|
}, 200)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -165,7 +167,7 @@ func testHttpRequestWithHeaders(method, url string, headers map[string]string, e
|
|||||||
request.Header.Add(key, value)
|
request.Header.Add(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := http.DefaultClient.Do(request)
|
response, err := defaultHTTPClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-32
@@ -101,35 +101,37 @@ func newApplication(c *config) (*application, error) {
|
|||||||
// Init themes
|
// Init themes
|
||||||
//
|
//
|
||||||
|
|
||||||
themeKeys := make([]string, 0, 2)
|
if !config.Theme.DisablePicker {
|
||||||
themeProps := make([]*themeProperties, 0, 2)
|
themeKeys := make([]string, 0, 2)
|
||||||
|
themeProps := make([]*themeProperties, 0, 2)
|
||||||
|
|
||||||
defaultDarkTheme, ok := config.Theme.Presets.Get("default-dark")
|
defaultDarkTheme, ok := config.Theme.Presets.Get("default-dark")
|
||||||
if ok && !config.Theme.SameAs(defaultDarkTheme) || !config.Theme.SameAs(&themeProperties{}) {
|
if ok && !config.Theme.SameAs(defaultDarkTheme) || !config.Theme.SameAs(&themeProperties{}) {
|
||||||
themeKeys = append(themeKeys, "default-dark")
|
themeKeys = append(themeKeys, "default-dark")
|
||||||
themeProps = append(themeProps, &themeProperties{})
|
themeProps = append(themeProps, &themeProperties{})
|
||||||
}
|
}
|
||||||
|
|
||||||
themeKeys = append(themeKeys, "default-light")
|
themeKeys = append(themeKeys, "default-light")
|
||||||
themeProps = append(themeProps, &themeProperties{
|
themeProps = append(themeProps, &themeProperties{
|
||||||
Light: true,
|
Light: true,
|
||||||
BackgroundColor: &hslColorField{240, 13, 95},
|
BackgroundColor: &hslColorField{240, 13, 95},
|
||||||
PrimaryColor: &hslColorField{230, 100, 30},
|
PrimaryColor: &hslColorField{230, 100, 30},
|
||||||
NegativeColor: &hslColorField{0, 70, 50},
|
NegativeColor: &hslColorField{0, 70, 50},
|
||||||
ContrastMultiplier: 1.3,
|
ContrastMultiplier: 1.3,
|
||||||
TextSaturationMultiplier: 0.5,
|
TextSaturationMultiplier: 0.5,
|
||||||
})
|
})
|
||||||
|
|
||||||
themePresets, err := newOrderedYAMLMap(themeKeys, themeProps)
|
themePresets, err := newOrderedYAMLMap(themeKeys, themeProps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("creating theme presets: %v", err)
|
return nil, fmt.Errorf("creating theme presets: %v", err)
|
||||||
}
|
}
|
||||||
config.Theme.Presets = *themePresets.Merge(&config.Theme.Presets)
|
config.Theme.Presets = *themePresets.Merge(&config.Theme.Presets)
|
||||||
|
|
||||||
for key, properties := range config.Theme.Presets.Items() {
|
for key, properties := range config.Theme.Presets.Items() {
|
||||||
properties.Key = key
|
properties.Key = key
|
||||||
if err := properties.init(); err != nil {
|
if err := properties.init(); err != nil {
|
||||||
return nil, fmt.Errorf("initializing preset theme %s: %v", key, err)
|
return nil, fmt.Errorf("initializing preset theme %s: %v", key, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +172,12 @@ func newApplication(c *config) (*application, error) {
|
|||||||
page.DesktopNavigationWidth = page.Width
|
page.DesktopNavigationWidth = page.Width
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i := range page.HeadWidgets {
|
||||||
|
widget := page.HeadWidgets[i]
|
||||||
|
app.widgetByID[widget.GetID()] = widget
|
||||||
|
widget.setProviders(providers)
|
||||||
|
}
|
||||||
|
|
||||||
for c := range page.Columns {
|
for c := range page.Columns {
|
||||||
column := &page.Columns[c]
|
column := &page.Columns[c]
|
||||||
|
|
||||||
@@ -180,7 +188,6 @@ func newApplication(c *config) (*application, error) {
|
|||||||
for w := range column.Widgets {
|
for w := range column.Widgets {
|
||||||
widget := column.Widgets[w]
|
widget := column.Widgets[w]
|
||||||
app.widgetByID[widget.GetID()] = widget
|
app.widgetByID[widget.GetID()] = widget
|
||||||
|
|
||||||
widget.setProviders(providers)
|
widget.setProviders(providers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,11 +290,13 @@ type templateData struct {
|
|||||||
func (a *application) populateTemplateRequestData(data *templateRequestData, r *http.Request) {
|
func (a *application) populateTemplateRequestData(data *templateRequestData, r *http.Request) {
|
||||||
theme := &a.Config.Theme.themeProperties
|
theme := &a.Config.Theme.themeProperties
|
||||||
|
|
||||||
selectedTheme, err := r.Cookie("theme")
|
if !a.Config.Theme.DisablePicker {
|
||||||
if err == nil {
|
selectedTheme, err := r.Cookie("theme")
|
||||||
preset, exists := a.Config.Theme.Presets.Get(selectedTheme.Value)
|
if err == nil {
|
||||||
if exists {
|
preset, exists := a.Config.Theme.Presets.Get(selectedTheme.Value)
|
||||||
theme = preset
|
if exists {
|
||||||
|
theme = preset
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +440,11 @@ func (a *application) server() (func() error, func() error) {
|
|||||||
mux.HandleFunc("GET /{page}", a.handlePageRequest)
|
mux.HandleFunc("GET /{page}", a.handlePageRequest)
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
|
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
|
||||||
mux.HandleFunc("POST /api/set-theme/{key}", a.handleThemeChangeRequest)
|
|
||||||
|
if !a.Config.Theme.DisablePicker {
|
||||||
|
mux.HandleFunc("POST /api/set-theme/{key}", a.handleThemeChangeRequest)
|
||||||
|
}
|
||||||
|
|
||||||
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
|
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
|
||||||
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type SingleflightCall[T any] struct {
|
||||||
|
done chan struct{}
|
||||||
|
val T
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Singleflight[T any] struct {
|
||||||
|
fn func() (T, error)
|
||||||
|
mu sync.Mutex
|
||||||
|
current *SingleflightCall[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Singleflight[T]) Do() (T, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.current != nil {
|
||||||
|
// Wait for the current call to finish and return its result
|
||||||
|
c := s.current
|
||||||
|
s.mu.Unlock()
|
||||||
|
<-c.done
|
||||||
|
return c.val, c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initiate a new call
|
||||||
|
c := &SingleflightCall[T]{done: make(chan struct{})}
|
||||||
|
s.current = c
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
c.val, c.err = s.fn()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.current = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
close(c.done)
|
||||||
|
return c.val, c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSingleflight[T any](fn func() (T, error)) func() (T, error) {
|
||||||
|
sf := &Singleflight[T]{fn: fn}
|
||||||
|
return sf.Do
|
||||||
|
}
|
||||||
@@ -194,7 +194,7 @@ function setupSearchBoxes() {
|
|||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
||||||
if (event.key != "s") return;
|
if (event.code != "KeyS") return;
|
||||||
|
|
||||||
inputElement.focus();
|
inputElement.focus();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -643,13 +643,14 @@ async function setupCalendars() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setupTodos() {
|
async function setupTodos() {
|
||||||
const elems = document.getElementsByClassName("todo");
|
const elems = Array.from(document.getElementsByClassName("todo"));
|
||||||
if (elems.length == 0) return;
|
if (elems.length == 0) return;
|
||||||
|
|
||||||
const todo = await import ('./todo.js');
|
const todo = await import ('./todo.js');
|
||||||
|
|
||||||
for (let i = 0; i < elems.length; i++)
|
for (let i = 0; i < elems.length; i++){
|
||||||
todo.default(elems[i]);
|
todo.default(elems[i]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupTruncatedElementTitles() {
|
function setupTruncatedElementTitles() {
|
||||||
@@ -661,7 +662,8 @@ function setupTruncatedElementTitles() {
|
|||||||
|
|
||||||
for (let i = 0; i < elements.length; i++) {
|
for (let i = 0; i < elements.length; i++) {
|
||||||
const element = elements[i];
|
const element = elements[i];
|
||||||
if (element.getAttribute("title") === null) element.title = element.textContent;
|
if (element.getAttribute("title") === null)
|
||||||
|
element.title = element.innerText.trim().replace(/\s+/g, " ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,15 +685,23 @@ async function changeTheme(key, onChanged) {
|
|||||||
.appendTo(document.head);
|
.appendTo(document.head);
|
||||||
|
|
||||||
themeStyleElem.html(newThemeStyle);
|
themeStyleElem.html(newThemeStyle);
|
||||||
|
document.documentElement.setAttribute("data-theme", key);
|
||||||
document.documentElement.setAttribute("data-scheme", response.headers.get("X-Scheme"));
|
document.documentElement.setAttribute("data-scheme", response.headers.get("X-Scheme"));
|
||||||
typeof onChanged == "function" && onChanged();
|
typeof onChanged == "function" && onChanged();
|
||||||
setTimeout(() => { tempStyle.remove(); }, 10);
|
setTimeout(() => { tempStyle.remove(); }, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initThemeSwitcher() {
|
function initThemePicker() {
|
||||||
find(".mobile-navigation .theme-choices").replaceWith(
|
const themeChoicesInMobileNav = find(".mobile-navigation .theme-choices");
|
||||||
find(".header-container .theme-choices").cloneNode(true)
|
if (!themeChoicesInMobileNav) return;
|
||||||
);
|
|
||||||
|
const themeChoicesInHeader = find(".header-container .theme-choices");
|
||||||
|
|
||||||
|
if (themeChoicesInHeader) {
|
||||||
|
themeChoicesInHeader.replaceWith(
|
||||||
|
themeChoicesInMobileNav.cloneNode(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const presetElems = findAll(".theme-choices .theme-preset");
|
const presetElems = findAll(".theme-choices .theme-preset");
|
||||||
let themePreviewElems = document.getElementsByClassName("current-theme-preview");
|
let themePreviewElems = document.getElementsByClassName("current-theme-preview");
|
||||||
@@ -734,7 +744,7 @@ function initThemeSwitcher() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setupPage() {
|
async function setupPage() {
|
||||||
initThemeSwitcher();
|
initThemePicker();
|
||||||
|
|
||||||
const pageElement = document.getElementById("page");
|
const pageElement = document.getElementById("page");
|
||||||
const pageContentElement = document.getElementById("page-content");
|
const pageContentElement = document.getElementById("page-content");
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function repositionContainer() {
|
|||||||
} else if (left + containerBounds.width > window.innerWidth) {
|
} else if (left + containerBounds.width > window.innerWidth) {
|
||||||
containerElement.style.removeProperty("left");
|
containerElement.style.removeProperty("left");
|
||||||
containerElement.style.right = 0;
|
containerElement.style.right = 0;
|
||||||
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px");
|
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (document.documentElement.clientWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px");
|
||||||
} else {
|
} else {
|
||||||
containerElement.style.removeProperty("right");
|
containerElement.style.removeProperty("right");
|
||||||
containerElement.style.left = left + "px";
|
containerElement.style.left = left + "px";
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
"golang.org/x/text/message"
|
"golang.org/x/text/message"
|
||||||
@@ -22,6 +21,9 @@ var globalTemplateFunctions = template.FuncMap{
|
|||||||
"safeURL": func(str string) template.URL {
|
"safeURL": func(str string) template.URL {
|
||||||
return template.URL(str)
|
return template.URL(str)
|
||||||
},
|
},
|
||||||
|
"safeHTML": func(str string) template.HTML {
|
||||||
|
return template.HTML(str)
|
||||||
|
},
|
||||||
"absInt": func(i int) int {
|
"absInt": func(i int) int {
|
||||||
return int(math.Abs(float64(i)))
|
return int(math.Abs(float64(i)))
|
||||||
},
|
},
|
||||||
@@ -54,7 +56,6 @@ var globalTemplateFunctions = template.FuncMap{
|
|||||||
|
|
||||||
return template.HTML(value + ` <span class="color-base size-h5">` + label + `</span>`)
|
return template.HTML(value + ` <span class="color-base size-h5">` + label + `</span>`)
|
||||||
},
|
},
|
||||||
"hasPrefix": strings.HasPrefix,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustParseTemplate(primary string, dependencies ...string) *template.Template {
|
func mustParseTemplate(primary string, dependencies ...string) *template.Template {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="flex items-center gap-10">
|
<div class="flex items-center gap-10">
|
||||||
{{- if ne "" .Icon.URL }}
|
{{- if ne "" .Icon.URL }}
|
||||||
<div class="bookmarks-icon-container">
|
<div class="bookmarks-icon-container">
|
||||||
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
<img class="bookmarks-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||||
</div>
|
</div>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{- range .Containers }}
|
{{- range .Containers }}
|
||||||
<li class="docker-container flex items-center gap-15">
|
<li class="docker-container flex items-center gap-15">
|
||||||
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem" data-popover-max-width="400px" aria-hidden="true">
|
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem" data-popover-max-width="400px" aria-hidden="true">
|
||||||
<img class="docker-container-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
<img class="docker-container-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||||
<div data-popover-html>
|
<div data-popover-html>
|
||||||
<div class="color-highlight text-truncate block">{{ .Image }}</div>
|
<div class="color-highlight text-truncate block">{{ .Image }}</div>
|
||||||
<div>{{ .StateText }}</div>
|
<div>{{ .StateText }}</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" id="top" data-scheme="{{ if .Request.Theme.Light }}light{{ else }}dark{{ end }}">
|
<html lang="en" id="top" data-theme="{{ .Request.Theme.Key }}" data-scheme="{{ if .Request.Theme.Light }}light{{ else }}dark{{ end }}">
|
||||||
<head>
|
<head>
|
||||||
{{ block "document-head-before" . }}{{ end }}
|
{{ block "document-head-before" . }}{{ end }}
|
||||||
<script>
|
<script>
|
||||||
@@ -21,7 +21,6 @@
|
|||||||
<meta name="theme-color" content="{{ .Request.Theme.BackgroundColorAsHex }}">
|
<meta name="theme-color" content="{{ .Request.Theme.BackgroundColorAsHex }}">
|
||||||
<link rel="apple-touch-icon" sizes="512x512" href='{{ .App.Config.Branding.AppIconURL }}'>
|
<link rel="apple-touch-icon" sizes="512x512" href='{{ .App.Config.Branding.AppIconURL }}'>
|
||||||
<link rel="manifest" href='{{ .App.VersionedAssetPath "manifest.json" }}'>
|
<link rel="manifest" href='{{ .App.VersionedAssetPath "manifest.json" }}'>
|
||||||
<link rel="icon" type="image/png" href='{{ .App.StaticAssetPath "favicon.png" }}' />
|
|
||||||
<link rel="icon" type="{{ .App.Config.Branding.FaviconType }}" href="{{ .App.Config.Branding.FaviconURL }}" />
|
<link rel="icon" type="{{ .App.Config.Branding.FaviconType }}" href="{{ .App.Config.Branding.FaviconURL }}" />
|
||||||
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
|
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
|
||||||
<style id="theme-style">{{ .Request.Theme.CSS }}</style>
|
<style id="theme-style">{{ .Request.Theme.CSS }}</style>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
{{ define "site" }}
|
{{ define "site" }}
|
||||||
{{ if .Icon.URL }}
|
{{ if .Icon.URL }}
|
||||||
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
<img class="monitor-site-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="grow min-width-0">
|
<div class="grow min-width-0">
|
||||||
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||||
|
|||||||
@@ -33,19 +33,16 @@
|
|||||||
<nav class="nav flex grow hide-scrollbars">
|
<nav class="nav flex grow hide-scrollbars">
|
||||||
{{ template "navigation-links" . }}
|
{{ template "navigation-links" . }}
|
||||||
</nav>
|
</nav>
|
||||||
|
{{ if not .App.Config.Theme.DisablePicker }}
|
||||||
<div class="theme-picker self-center" data-popover-type="html" data-popover-position="below" data-popover-show-delay="0">
|
<div class="theme-picker self-center" data-popover-type="html" data-popover-position="below" data-popover-show-delay="0">
|
||||||
<div class="current-theme-preview">
|
<div class="current-theme-preview">
|
||||||
{{ .Request.Theme.PreviewHTML }}
|
{{ .Request.Theme.PreviewHTML }}
|
||||||
</div>
|
</div>
|
||||||
<div data-popover-html>
|
<div data-popover-html>
|
||||||
<div class="theme-choices">
|
<div class="theme-choices"></div>
|
||||||
{{ .App.Config.Theme.PreviewHTML }}
|
|
||||||
{{ range $_, $preset := .App.Config.Theme.Presets.Items }}
|
|
||||||
{{ $preset.PreviewHTML }}
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{ end }}
|
||||||
{{- if .App.RequiresAuth }}
|
{{- if .App.RequiresAuth }}
|
||||||
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/logout" title="Logout">
|
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/logout" title="Logout">
|
||||||
<svg class="logout-button" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
<svg class="logout-button" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
@@ -71,9 +68,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mobile-navigation-actions flex flex-column margin-block-10">
|
<div class="mobile-navigation-actions flex flex-column margin-block-10">
|
||||||
|
{{ if not .App.Config.Theme.DisablePicker }}
|
||||||
<div class="theme-picker flex justify-between items-center" data-popover-type="html" data-popover-position="above" data-popover-show-delay="0" data-popover-hide-delay="100" data-popover-anchor=".current-theme-preview" data-popover-trigger="click">
|
<div class="theme-picker flex justify-between items-center" data-popover-type="html" data-popover-position="above" data-popover-show-delay="0" data-popover-hide-delay="100" data-popover-anchor=".current-theme-preview" data-popover-trigger="click">
|
||||||
<div data-popover-html>
|
<div data-popover-html>
|
||||||
<div class="theme-choices"></div>
|
<div class="theme-choices">
|
||||||
|
{{ .App.Config.Theme.PreviewHTML }}
|
||||||
|
{{ range $_, $preset := .App.Config.Theme.Presets.Items }}
|
||||||
|
{{ $preset.PreviewHTML }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="size-h3 pointer-events-none select-none">Change theme</div>
|
<div class="size-h3 pointer-events-none select-none">Change theme</div>
|
||||||
@@ -87,6 +90,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
{{ if .App.RequiresAuth }}
|
{{ if .App.RequiresAuth }}
|
||||||
<a href="{{ .App.Config.Server.BaseURL }}/logout" class="flex justify-between items-center">
|
<a href="{{ .App.Config.Server.BaseURL }}/logout" class="flex justify-between items-center">
|
||||||
@@ -100,7 +104,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-bounds grow{{ if .Page.Width }} content-bounds-{{ .Page.Width }}{{ end }}">
|
<div class="content-bounds grow{{ if .Page.Width }} content-bounds-{{ .Page.Width }}{{ end }}">
|
||||||
<main class="page{{ if .Page.CenterVertically }} page-center-vertically{{ end }}" id="page" aria-live="polite" aria-busy="true">
|
<main class="page{{ if .Page.CenterVertically }} center-vertically{{ end }}" id="page" aria-live="polite" aria-busy="true">
|
||||||
<h1 class="visually-hidden">{{ .Page.Title }}</h1>
|
<h1 class="visually-hidden">{{ .Page.Title }}</h1>
|
||||||
<div class="page-content" id="page-content"></div>
|
<div class="page-content" id="page-content"></div>
|
||||||
<div class="page-loading-container">
|
<div class="page-loading-container">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<li class="flex thumbnail-parent gap-10 items-center">
|
<li class="flex thumbnail-parent gap-10 items-center">
|
||||||
<img class="video-horizontal-list-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
<img class="video-horizontal-list-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||||
<div class="min-width-0">
|
<div class="min-width-0">
|
||||||
<a class="block text-truncate color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a class="block text-truncate color-primary-if-not-visited" href="{{ .Url | safeURL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap">
|
<ul class="list-horizontal-text flex-nowrap">
|
||||||
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li class="min-width-0">
|
<li class="min-width-0">
|
||||||
|
|||||||
@@ -108,6 +108,20 @@ func (o *customAPIOptions) BoolOr(key string, defaultValue bool) bool {
|
|||||||
return customAPIGetOptionOrDefault(*o, key, defaultValue)
|
return customAPIGetOptionOrDefault(*o, key, defaultValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *customAPIOptions) JSON(key string) string {
|
||||||
|
value, exists := (*o)[key]
|
||||||
|
if !exists {
|
||||||
|
panic(fmt.Sprintf("key %q does not exist in options", key))
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("marshaling %s: %v", key, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
func customAPIGetOptionOrDefault[T any](o customAPIOptions, key string, defaultValue T) T {
|
func customAPIGetOptionOrDefault[T any](o customAPIOptions, key string, defaultValue T) T {
|
||||||
if value, exists := o[key]; exists {
|
if value, exists := o[key]; exists {
|
||||||
if typedValue, ok := value.(T); ok {
|
if typedValue, ok := value.(T); ok {
|
||||||
@@ -479,6 +493,12 @@ var customAPITemplateFuncs = func() template.FuncMap {
|
|||||||
"div": func(a, b any) any {
|
"div": func(a, b any) any {
|
||||||
return doMathOpWithAny(a, b, "div")
|
return doMathOpWithAny(a, b, "div")
|
||||||
},
|
},
|
||||||
|
"mod": func(a, b int) int {
|
||||||
|
if b == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return a % b
|
||||||
|
},
|
||||||
"now": func() time.Time {
|
"now": func() time.Time {
|
||||||
return time.Now()
|
return time.Now()
|
||||||
},
|
},
|
||||||
@@ -509,6 +529,12 @@ var customAPITemplateFuncs = func() template.FuncMap {
|
|||||||
// Shorthand to do both of the above with a single function call
|
// Shorthand to do both of the above with a single function call
|
||||||
return dynamicRelativeTimeAttrs(customAPIFuncParseTimeInLocation(layout, value, time.UTC))
|
return dynamicRelativeTimeAttrs(customAPIFuncParseTimeInLocation(layout, value, time.UTC))
|
||||||
},
|
},
|
||||||
|
"startOfDay": func(t time.Time) time.Time {
|
||||||
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||||
|
},
|
||||||
|
"endOfDay": func(t time.Time) time.Time {
|
||||||
|
return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location())
|
||||||
|
},
|
||||||
// The reason we flip the parameter order is so that you can chain multiple calls together like this:
|
// The reason we flip the parameter order is so that you can chain multiple calls together like this:
|
||||||
// {{ .JSON.String "foo" | trimPrefix "bar" | doSomethingElse }}
|
// {{ .JSON.String "foo" | trimPrefix "bar" | doSomethingElse }}
|
||||||
// instead of doing this:
|
// instead of doing this:
|
||||||
|
|||||||
@@ -2,15 +2,22 @@ package glance
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
utls "github.com/refraction-networking/utls"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -158,7 +165,7 @@ func (widget *redditWidget) parseCustomCommentsURL(subreddit, postId, postPath s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) {
|
func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) {
|
||||||
var client requestDoer = defaultHTTPClient
|
var client requestDoer = redditHTTPClient
|
||||||
var baseURL string
|
var baseURL string
|
||||||
var requestURL string
|
var requestURL string
|
||||||
var headers http.Header
|
var headers http.Header
|
||||||
@@ -212,6 +219,13 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) {
|
|||||||
}
|
}
|
||||||
request.Header = headers
|
request.Header = headers
|
||||||
|
|
||||||
|
loid, err := getRedditLoidCookie()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error fetching reddit loid cookie: %v\n", err)
|
||||||
|
return nil, errors.New("could not solve reddit challenge")
|
||||||
|
}
|
||||||
|
request.AddCookie(&http.Cookie{Name: "loid", Value: loid})
|
||||||
|
|
||||||
responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request)
|
responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -308,3 +322,135 @@ func (widget *redditWidget) fetchNewAppAccessToken() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On Windows the default HTTP client works fine, but on Linux it seems to
|
||||||
|
// get detected and blocked by reddit (or cloudflare) presumably because of TLS fingerprinting,
|
||||||
|
// so we use uTLS to mimic a real browser's TLS fingerprint, which seems to work around the issue
|
||||||
|
var redditHTTPClient = &http.Client{Transport: &http2.Transport{
|
||||||
|
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tcpConn, err := (&net.Dialer{}).DialContext(ctx, network, addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
uconn := utls.UClient(tcpConn, &utls.Config{
|
||||||
|
ServerName: host,
|
||||||
|
}, utls.HelloFirefox_Auto)
|
||||||
|
|
||||||
|
if err := uconn.HandshakeContext(ctx); err != nil {
|
||||||
|
tcpConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return uconn, nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
var (
|
||||||
|
redditChallengePattern = regexp.MustCompile(`await\(async \w+\s*=>\s*\w+\s*\+\s*\w+\)\("([^"]+)"\)`)
|
||||||
|
redditTokenPattern = regexp.MustCompile(`name="token"\s+value="([^"]+)"`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Allows all widget instances to share a single loid cookie, since we don't want to draw
|
||||||
|
// too much attention by making a lot of requests to the flow that allows us to obtain
|
||||||
|
// the cookie required to access the .json endpoints
|
||||||
|
var getRedditLoidCookie = func() func() (string, error) {
|
||||||
|
var lastUpdate time.Time
|
||||||
|
var cachedLoid string
|
||||||
|
|
||||||
|
return NewSingleflight(func() (string, error) {
|
||||||
|
// Caching for 6 hours is a bit arbitrary, presumably the cookie is valid for 24 hours,
|
||||||
|
// but we want to keep the cache time short in the event that a cookie becomes invalid
|
||||||
|
// for whatever reason, since we don't have a way to force refresh it
|
||||||
|
if time.Since(lastUpdate) < 6*time.Hour && cachedLoid != "" {
|
||||||
|
return cachedLoid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
loid, err := fetchRedditLoidCookie()
|
||||||
|
if err != nil {
|
||||||
|
if cachedLoid != "" {
|
||||||
|
fmt.Printf("Error fetching new reddit loid cookie, using cached value: %v\n", err)
|
||||||
|
return cachedLoid, nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdate = time.Now()
|
||||||
|
cachedLoid = loid
|
||||||
|
return loid, nil
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
func fetchRedditLoidCookie() (string, error) {
|
||||||
|
request, err := http.NewRequest("GET", "https://www.reddit.com/", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
setBrowserUserAgentHeader(request)
|
||||||
|
|
||||||
|
response, err := redditHTTPClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("unexpected status code %d when requesting challenge page", response.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
challengeBody, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
challengeMatches := redditChallengePattern.FindSubmatch(challengeBody)
|
||||||
|
tokenMatches := redditTokenPattern.FindSubmatch(challengeBody)
|
||||||
|
|
||||||
|
if challengeMatches == nil {
|
||||||
|
return "", fmt.Errorf("no JS challenge found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenMatches == nil {
|
||||||
|
return "", fmt.Errorf("no token found in challenge page")
|
||||||
|
}
|
||||||
|
|
||||||
|
challengeStr := string(challengeMatches[1])
|
||||||
|
token := string(tokenMatches[1])
|
||||||
|
solution := challengeStr + challengeStr // the JS does: e + e
|
||||||
|
|
||||||
|
params := url.Values{
|
||||||
|
"solution": {solution},
|
||||||
|
"js_challenge": {"1"},
|
||||||
|
"token": {token},
|
||||||
|
}
|
||||||
|
request, err = http.NewRequest("GET", "https://www.reddit.com/?"+params.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
setBrowserUserAgentHeader(request)
|
||||||
|
|
||||||
|
response, err = redditHTTPClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("unexpected status code %d when submitting challenge solution", response.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cookie := range response.Cookies() {
|
||||||
|
if cookie.Name == "loid" {
|
||||||
|
return cookie.Value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no loid cookie found")
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const defaultClientTimeout = 5 * time.Second
|
|||||||
var defaultHTTPClient = &http.Client{
|
var defaultHTTPClient = &http.Client{
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
MaxIdleConnsPerHost: 10,
|
MaxIdleConnsPerHost: 10,
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
},
|
},
|
||||||
Timeout: defaultClientTimeout,
|
Timeout: defaultClientTimeout,
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@ var defaultInsecureHTTPClient = &http.Client{
|
|||||||
Timeout: defaultClientTimeout,
|
Timeout: defaultClientTimeout,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ func getBrowserUserAgentHeader() string {
|
|||||||
userAgentPersistentVersion.Store(rand.Int32N(5))
|
userAgentPersistentVersion.Store(rand.Int32N(5))
|
||||||
}
|
}
|
||||||
|
|
||||||
version := strconv.Itoa(130 + int(userAgentPersistentVersion.Load()))
|
version := strconv.Itoa(148 + int(userAgentPersistentVersion.Load()))
|
||||||
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:" + version + ".0) Gecko/20100101 Firefox/" + version + ".0"
|
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:" + version + ".0) Gecko/20100101 Firefox/" + version + ".0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user