Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9de6843a2e | ||
|
|
bd5cacd14a | ||
|
|
96007e98bc | ||
|
|
ae409768ac | ||
|
|
baaf306ebf | ||
|
|
7bbf103e01 | ||
|
|
49c07f397e | ||
|
|
dd91a506fa | ||
|
|
4ed8bef562 | ||
|
|
8db6544400 | ||
|
|
ca9e4d273b | ||
|
|
9324da6a2f | ||
|
|
ce3a7ee29c | ||
|
|
c9e6b774f3 | ||
|
|
9ffb2d9939 | ||
|
|
c5d4cf8f68 | ||
|
|
6b7d68d960 | ||
|
|
0cb8a810e6 | ||
|
|
40ae263248 | ||
|
|
bf97829814 | ||
|
|
b075607bac | ||
|
|
56bdd2c9a3 | ||
|
|
0f6e51ee5c | ||
|
|
1c5701fde5 | ||
|
|
6c1c909c9d | ||
|
|
26ab9c7b05 | ||
|
|
176f14df70 | ||
|
|
2d1e317c1f | ||
|
|
fcdea66a0f | ||
|
|
0e91958de5 | ||
|
|
6624b635df | ||
|
|
0612dc6f89 | ||
|
|
0769f33653 | ||
|
|
c2286f9a22 | ||
|
|
a2d8410fec | ||
|
|
a68805b55d | ||
|
|
8ca6879a1c | ||
|
|
1661f14adb | ||
|
|
7d08eb312f | ||
|
|
47340ed82a | ||
|
|
129441713b | ||
|
|
a3e9510c1d | ||
|
|
328ba9a239 | ||
|
|
3a3ff080ac | ||
|
|
e2d3e1c04f | ||
|
|
b3658bea09 | ||
|
|
1ad71f6974 | ||
|
|
9dde306c0c | ||
|
|
d7a17aab01 | ||
|
|
65adf9b9c3 | ||
|
|
aceb832645 | ||
|
|
ec2f549295 | ||
|
|
6e06c9c489 | ||
|
|
1862858c07 | ||
|
|
306c28f203 | ||
|
|
bba2c5b20c | ||
|
|
18436e91e0 | ||
|
|
77fb199cb3 | ||
|
|
272bf1d2ad | ||
|
|
ceebf34874 | ||
|
|
efd39e1f80 | ||
|
|
93c72f561b | ||
|
|
689e8f216c | ||
|
|
4e4c3cfe64 | ||
|
|
e2112e0d83 | ||
|
|
44fca91089 | ||
|
|
4ae40cbd1e | ||
|
|
ea04aec0b4 | ||
|
|
b8e2717faf | ||
|
|
333d40ed4f | ||
|
|
cb3555f5ed | ||
|
|
7a8f70db02 | ||
|
|
1cf4f520f8 | ||
|
|
e11ce5d3d0 | ||
|
|
502950709c | ||
|
|
2c3e14d84c | ||
|
|
8e71afe158 | ||
|
|
44ea98fc41 | ||
|
|
5214fb9145 | ||
|
|
41e74ba9b5 | ||
|
|
03b616622e | ||
|
|
50e1da01fe | ||
|
|
0daa620b11 | ||
|
|
f8f50b26d8 | ||
|
|
a3bc133bcb | ||
|
|
50dae22ff4 | ||
|
|
5a91154eab | ||
|
|
d5b89d512a | ||
|
|
c4e4c62072 | ||
|
|
33b54ded4f | ||
|
|
0d2f5a818b | ||
|
|
8f217e6f01 | ||
|
|
835d7767cb | ||
|
|
8892a39160 | ||
|
|
db97871945 | ||
|
|
90254b92d2 | ||
|
|
eeda2104a6 | ||
|
|
97f43f88eb | ||
|
|
793da48f5c | ||
|
|
3177af9524 | ||
|
|
f99d22738c | ||
|
|
ea7124db52 | ||
|
|
b1247e5de6 | ||
|
|
5ab962634e | ||
|
|
d99944dcff | ||
|
|
84f7286460 | ||
|
|
920bcb06dc | ||
|
|
3679cc21ab | ||
|
|
07e5626469 | ||
|
|
6bb6f87800 | ||
|
|
cb6b52ff4c | ||
|
|
03c377dff3 | ||
|
|
d9c934d3eb | ||
|
|
0bc31b0f31 | ||
|
|
5a38c8dd2f | ||
|
|
0e3f172032 | ||
|
|
fd704419d9 | ||
|
|
32ac72f592 | ||
|
|
15f20ffaeb | ||
|
|
9c77b87435 | ||
|
|
d4d9e94f44 | ||
|
|
47cef2abf0 | ||
|
|
0a5cdf380f | ||
|
|
7f0e9b3289 | ||
|
|
9da7967158 | ||
|
|
964744a9ae | ||
|
|
779304d035 | ||
|
|
f15d7445bd | ||
|
|
26d68ba3fc | ||
|
|
9effff25df | ||
|
|
958805a1fd | ||
|
|
dd74c173a5 | ||
|
|
bd020c93f5 | ||
|
|
75555f0426 | ||
|
|
4e6b14a467 | ||
|
|
233de7fc37 | ||
|
|
c6e0230e5d | ||
|
|
ca2732668e | ||
|
|
456b76ac96 | ||
|
|
a2c58bea82 | ||
|
|
7276b3d5ef | ||
|
|
1ef8315462 | ||
|
|
2f88ac419b | ||
|
|
cee7c85780 | ||
|
|
b2c5205035 | ||
|
|
2be59c3eb6 | ||
|
|
176fc896ae | ||
|
|
a943555d86 | ||
|
|
58bbfbbc42 | ||
|
|
026b644630 | ||
|
|
1483b355af | ||
|
|
6a5bb635bb | ||
|
|
18241136e3 | ||
|
|
6fb6467b07 | ||
|
|
83f0d16904 | ||
|
|
0e0aca3844 | ||
|
|
97b52792ef | ||
|
|
0680c5ff9c | ||
|
|
f1f0158238 | ||
|
|
bca3617116 | ||
|
|
55ae674e0b | ||
|
|
46eb610d26 | ||
|
|
bfc2e9c7fb | ||
|
|
ffedd9eaf9 | ||
|
|
1512718d41 | ||
|
|
e566dcd0d9 | ||
|
|
43b8f8f31b | ||
|
|
768f700719 | ||
|
|
ab1faedadf | ||
|
|
fa325bd519 | ||
|
|
568876fc4f | ||
|
|
d506604918 | ||
|
|
7c9f79f243 | ||
|
|
a77d0ce5c0 | ||
|
|
55685e6854 | ||
|
|
4731b90899 | ||
|
|
f36527995e | ||
|
|
fd5cf98072 | ||
|
|
844e32a11e | ||
|
|
82cb0143f2 | ||
|
|
51e70347e4 | ||
|
|
075bdfdc23 | ||
|
|
e84edb3e30 | ||
|
|
2bde4656ed | ||
|
|
d22ac6a7a4 | ||
|
|
2f50f5ef34 | ||
|
|
14db59318c | ||
|
|
f9b3deaff2 | ||
|
|
c265e42220 | ||
|
|
c0bdf1551d | ||
|
|
e373eeeed3 | ||
|
|
6c8859863a | ||
|
|
31ecd91f7c | ||
|
|
474255c985 | ||
|
|
652f9ceb5c | ||
|
|
49668d4ba9 | ||
|
|
acddaf07db | ||
|
|
8da26ab409 | ||
|
|
948289a038 | ||
|
|
ce293ed891 | ||
|
|
2738613344 | ||
|
|
5d12d934b8 | ||
|
|
19a89645a1 | ||
|
|
9df9673e84 | ||
|
|
9c98c6d0c4 | ||
|
|
4d6600b0a3 | ||
|
|
dac0d15e78 | ||
|
|
5b45751c67 | ||
|
|
c00d937f4c | ||
|
|
c33fe45d4c | ||
|
|
e355c643f4 | ||
|
|
fcccb7eb38 | ||
|
|
f9209406fb | ||
|
|
facbf6f529 | ||
|
|
94806ed45d | ||
|
|
baee94ed1d | ||
|
|
0c8358beaa | ||
|
|
62e9c32082 |
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.23.6-alpine3.21 AS builder
|
FROM golang:1.24.2-alpine3.21 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . /app
|
COPY . /app
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<p align="center"><em>What if you could see everything at a...</em></p>
|
<p align="center"><em>What if you could see everything at a...</em></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="docs/preconfigured-pages.md">Preconfigured pages</a> • <a href="docs/themes.md">Themes</a> • <a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a></p>
|
<p align="center"><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>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -16,7 +17,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
|
||||||
@@ -45,8 +46,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>
|
||||||
@@ -194,6 +194,7 @@ services:
|
|||||||
glance:
|
glance:
|
||||||
container_name: glance
|
container_name: glance
|
||||||
image: glanceapp/glance
|
image: glanceapp/glance
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
ports:
|
ports:
|
||||||
@@ -264,59 +265,31 @@ Glance can also be installed through the following 3rd party channels:
|
|||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
## Building from source
|
## Common issues
|
||||||
|
|
||||||
Choose one of the following methods:
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Build binary with Go</strong></summary>
|
<summary><strong>Requests timing out</strong></summary>
|
||||||
<br>
|
|
||||||
|
|
||||||
Requirements: [Go](https://go.dev/dl/) >= v1.23
|
The most common cause of this is when using Pi-Hole, AdGuard Home or other ad-blocking DNS services, which by default have a fairly low rate limit. Depending on the number of widgets you have in a single page, this limit can very easily be exceeded. To fix this, increase the rate limit in the settings of your DNS service.
|
||||||
|
|
||||||
To build the project for your current OS and architecture, run:
|
If using Podman, in some rare cases the timeout can be caused by an unknown issue, in which case it may be resolved by adding the following to the bottom of your `docker-compose.yml` file:
|
||||||
|
```yaml
|
||||||
```bash
|
networks:
|
||||||
go build -o build/glance .
|
podman:
|
||||||
|
external: true
|
||||||
```
|
```
|
||||||
|
|
||||||
To build for a specific OS and architecture, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o build/glance .
|
|
||||||
```
|
|
||||||
|
|
||||||
[*click here for a full list of GOOS and GOARCH combinations*](https://go.dev/doc/install/source#:~:text=$GOOS%20and%20$GOARCH)
|
|
||||||
|
|
||||||
Alternatively, if you just want to run the app without creating a binary, like when you're testing out changes, you can run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go run .
|
|
||||||
```
|
|
||||||
<hr>
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Build project and Docker image with Docker</strong></summary>
|
<summary><strong>Broken layout for markets, bookmarks or other widgets</strong></summary>
|
||||||
<br>
|
|
||||||
|
|
||||||
Requirements: [Docker](https://docs.docker.com/engine/install/)
|
This is almost always caused by the browser extension Dark Reader. To fix this, disable dark mode for the domain where Glance is hosted.
|
||||||
|
</details>
|
||||||
|
|
||||||
To build the project and image using just Docker, run:
|
<details>
|
||||||
|
<summary><strong>cannot unmarshal !!map into []glance.page</strong></summary>
|
||||||
|
|
||||||
*(replace `owner` with your name or organization)*
|
The most common cause of this is having a `pages` key in your `glance.yml` and then also having a `pages` key inside one of your included pages. To fix this, remove the `pages` key from the top of your included pages.
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t owner/glance:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
If you wish to push the image to a registry (by default Docker Hub), run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker push owner/glance:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -375,6 +348,63 @@ Feature requests are tagged with one of the following:
|
|||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
## Building from source
|
||||||
|
|
||||||
|
Choose one of the following methods:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Build binary with Go</strong></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Requirements: [Go](https://go.dev/dl/) >= v1.23
|
||||||
|
|
||||||
|
To build the project for your current OS and architecture, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o build/glance .
|
||||||
|
```
|
||||||
|
|
||||||
|
To build for a specific OS and architecture, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o build/glance .
|
||||||
|
```
|
||||||
|
|
||||||
|
[*click here for a full list of GOOS and GOARCH combinations*](https://go.dev/doc/install/source#:~:text=$GOOS%20and%20$GOARCH)
|
||||||
|
|
||||||
|
Alternatively, if you just want to run the app without creating a binary, like when you're testing out changes, you can run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
<hr>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Build project and Docker image with Docker</strong></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Requirements: [Docker](https://docs.docker.com/engine/install/)
|
||||||
|
|
||||||
|
To build the project and image using just Docker, run:
|
||||||
|
|
||||||
|
*(replace `owner` with your name or organization)*
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t owner/glance:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
If you wish to push the image to a registry (by default Docker Hub), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker push owner/glance:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
## Contributing guidelines
|
## Contributing guidelines
|
||||||
|
|
||||||
* Before working on a new feature it's preferable to submit a feature request first and state that you'd like to implement it yourself
|
* Before working on a new feature it's preferable to submit a feature request first and state that you'd like to implement it yourself
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
- [The config file](#the-config-file)
|
- [The config file](#the-config-file)
|
||||||
- [Auto reload](#auto-reload)
|
- [Auto reload](#auto-reload)
|
||||||
- [Environment variables](#environment-variables)
|
- [Environment variables](#environment-variables)
|
||||||
|
- [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)
|
||||||
|
- [Config schema](#config-schema)
|
||||||
|
- [Authentication](#authentication)
|
||||||
- [Server](#server)
|
- [Server](#server)
|
||||||
- [Document](#document)
|
- [Document](#document)
|
||||||
- [Branding](#branding)
|
- [Branding](#branding)
|
||||||
- [Theme](#theme)
|
- [Theme](#theme)
|
||||||
- [Themes](#themes)
|
- [Available themes](#available-themes)
|
||||||
- [Pages & Columns](#pages--columns)
|
- [Pages & Columns](#pages--columns)
|
||||||
- [Widgets](#widgets)
|
- [Widgets](#widgets)
|
||||||
- [RSS](#rss)
|
- [RSS](#rss)
|
||||||
@@ -23,6 +26,7 @@
|
|||||||
- [Custom API](#custom-api)
|
- [Custom API](#custom-api)
|
||||||
- [Extension](#extension)
|
- [Extension](#extension)
|
||||||
- [Weather](#weather)
|
- [Weather](#weather)
|
||||||
|
- [Todo](#todo)
|
||||||
- [Monitor](#monitor)
|
- [Monitor](#monitor)
|
||||||
- [Releases](#releases)
|
- [Releases](#releases)
|
||||||
- [Docker Containers](#docker-containers)
|
- [Docker Containers](#docker-containers)
|
||||||
@@ -92,14 +96,46 @@ If you need to use the syntax `${NAME}` in your config without it being interpre
|
|||||||
something: \${NOT_AN_ENV_VAR}
|
something: \${NOT_AN_ENV_VAR}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Other ways of providing tokens/passwords/secrets
|
||||||
|
|
||||||
|
You can use [Docker secrets](https://docs.docker.com/compose/how-tos/use-secrets/) with the following syntax:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# This will be replaced with the contents of the file /run/secrets/github_token
|
||||||
|
# so long as the secret `github_token` is provided to the container
|
||||||
|
token: ${secret:github_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, you can load the contents of a file who's path is provided by an environment variable:
|
||||||
|
|
||||||
|
`docker-compose.yml`
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
glance:
|
||||||
|
image: glanceapp/glance
|
||||||
|
environment:
|
||||||
|
- TOKEN_FILE=/home/user/token
|
||||||
|
volumes:
|
||||||
|
- /home/user/token:/home/user/token
|
||||||
|
```
|
||||||
|
|
||||||
|
`glance.yml`
|
||||||
|
```yaml
|
||||||
|
token: ${readFileFromEnv:TOKEN_FILE}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
>
|
||||||
|
> The contents of the file will be stripped of any leading/trailing whitespace before being used.
|
||||||
|
|
||||||
### Including other config files
|
### Including other config files
|
||||||
Including config files from within your main config file is supported. This is done via the `!include` directive along with a relative or absolute path to the file you want to include. If the path is relative, it will be relative to the main config file. Additionally, environment variables can be used within included files, and changes to the included files will trigger an automatic reload. Example:
|
Including config files from within your main config file is supported. This is done via the `$include` directive along with a relative or absolute path to the file you want to include. If the path is relative, it will be relative to the main config file. Additionally, environment variables can be used within included files, and changes to the included files will trigger an automatic reload. Example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
pages:
|
pages:
|
||||||
!include: home.yml
|
- $include: home.yml
|
||||||
!include: videos.yml
|
- $include: videos.yml
|
||||||
!include: homelab.yml
|
- $include: homelab.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
The file you are including should not have any additional indentation, its values should be at the top level and the appropriate amount of indentation will be added automatically depending on where the file is included. Example:
|
The file you are including should not have any additional indentation, its values should be at the top level and the appropriate amount of indentation will be added automatically depending on where the file is included. Example:
|
||||||
@@ -112,14 +148,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
|
||||||
```
|
```
|
||||||
@@ -133,9 +169,9 @@ pages:
|
|||||||
- url: ${RSS_URL}
|
- url: ${RSS_URL}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `!include` directive can be used anywhere in the config file, not just in the `pages` property, however it must be on its own line and have the appropriate indentation.
|
The `$include` directive can be used anywhere in the config file, not just in the `pages` property, however it must be on its own line and have the appropriate indentation.
|
||||||
|
|
||||||
If you encounter YAML parsing errors when using the `!include` directive, the reported line numbers will likely be incorrect. This is because the inclusion of files is done before the YAML is parsed, as YAML itself does not support file inclusion. To help with debugging in cases like this, you can use the `config:print` command and pipe it into `less -N` to see the full config file with includes resolved and line numbers added:
|
If you encounter YAML parsing errors when using the `$include` directive, the reported line numbers will likely be incorrect. This is because the inclusion of files is done before the YAML is parsed, as YAML itself does not support file inclusion. To help with debugging in cases like this, you can use the `config:print` command and pipe it into `less -N` to see the full config file with includes resolved and line numbers added:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
glance --config /path/to/glance.yml config:print | less -N
|
glance --config /path/to/glance.yml config:print | less -N
|
||||||
@@ -149,6 +185,71 @@ 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`.
|
||||||
|
|
||||||
|
## 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!
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
To make sure that only you and the people you want to share your dashboard with have access to it, you can set up authentication via username and password. This is done through a top level `auth` property. Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
secret-key: # this must be set to a random value generated using the secret:make CLI command
|
||||||
|
users:
|
||||||
|
admin:
|
||||||
|
password: 123456
|
||||||
|
svilen:
|
||||||
|
password: 123456
|
||||||
|
```
|
||||||
|
|
||||||
|
To generate a secret key, run the following command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./glance secret:make
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Docker:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm glanceapp/glance secret:make
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using hashed passwords
|
||||||
|
|
||||||
|
If you do not want to store plain passwords in your config file or in environment variables, you can hash your password and provide its hash instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./glance password:hash mysecretpassword
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Docker:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm glanceapp/glance password:hash mysecretpassword
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, in your config file use the `password-hash` property instead of `password`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
secret-key: # this must be set to a random value generated using the secret:make CLI command
|
||||||
|
users:
|
||||||
|
admin:
|
||||||
|
password-hash: $2a$10$o6SXqiccI3DDP2dN4ADumuOeIHET6Q4bUMYZD6rT2Aqt6XQ3DyO.6
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preventing brute-force attacks
|
||||||
|
|
||||||
|
Glance will automatically block IP addresses of users who fail to authenticate 5 times in a row in the span of 5 minutes. In order for this feature to work correctly, Glance must know the real IP address of requests. If you're using a reverse proxy such as nginx, Traefik, NPM, etc, you must set the `proxied` property in the `server` configuration to `true`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
proxied: true
|
||||||
|
```
|
||||||
|
|
||||||
|
When set to `true`, Glance will use the `X-Forwarded-For` header to determine the original IP address of the request, so make sure that your reverse proxy is correctly configured to send that header.
|
||||||
|
|
||||||
## Server
|
## Server
|
||||||
Server configuration is done through a top level `server` property. Example:
|
Server configuration is done through a top level `server` property. Example:
|
||||||
|
|
||||||
@@ -164,6 +265,7 @@ server:
|
|||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| host | string | no | |
|
| host | string | no | |
|
||||||
| port | number | no | 8080 |
|
| port | number | no | 8080 |
|
||||||
|
| proxied | boolean | no | false |
|
||||||
| base-url | string | no | |
|
| base-url | string | no | |
|
||||||
| assets-path | string | no | |
|
| assets-path | string | no | |
|
||||||
|
|
||||||
@@ -173,6 +275,9 @@ The address which the server will listen on. Setting it to `localhost` means tha
|
|||||||
#### `port`
|
#### `port`
|
||||||
A number between 1 and 65,535, so long as that port isn't already used by anything else.
|
A number between 1 and 65,535, so long as that port isn't already used by anything else.
|
||||||
|
|
||||||
|
#### `proxied`
|
||||||
|
Set to `true` if you're using a reverse proxy in front of Glance. This will make Glance use the `X-Forwarded-*` headers to determine the original request details.
|
||||||
|
|
||||||
#### `base-url`
|
#### `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.
|
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.
|
||||||
|
|
||||||
@@ -235,6 +340,9 @@ branding:
|
|||||||
<p>Powered by <a href="https://github.com/glanceapp/glance">Glance</a></p>
|
<p>Powered by <a href="https://github.com/glanceapp/glance">Glance</a></p>
|
||||||
logo-url: /assets/logo.png
|
logo-url: /assets/logo.png
|
||||||
favicon-url: /assets/logo.png
|
favicon-url: /assets/logo.png
|
||||||
|
app-name: "My Dashboard"
|
||||||
|
app-icon-url: "/assets/app-icon.png"
|
||||||
|
app-background-color: "#151519"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Properties
|
### Properties
|
||||||
@@ -246,6 +354,9 @@ branding:
|
|||||||
| logo-text | string | no | G |
|
| logo-text | string | no | G |
|
||||||
| logo-url | string | no | |
|
| logo-url | string | no | |
|
||||||
| favicon-url | string | no | |
|
| favicon-url | string | no | |
|
||||||
|
| app-name | string | no | Glance |
|
||||||
|
| app-icon-url | string | no | Glance's default icon |
|
||||||
|
| app-background-color | string | no | Glance's default background color |
|
||||||
|
|
||||||
#### `hide-footer`
|
#### `hide-footer`
|
||||||
Hides the footer when set to `true`.
|
Hides the footer when set to `true`.
|
||||||
@@ -262,6 +373,15 @@ Specify a URL to a custom image to use instead of the "G" found in the navigatio
|
|||||||
#### `favicon-url`
|
#### `favicon-url`
|
||||||
Specify a URL to a custom image to use for the favicon.
|
Specify a URL to a custom image to use for the favicon.
|
||||||
|
|
||||||
|
#### `app-name`
|
||||||
|
Specify the name of the web app shown in browser tab and PWA.
|
||||||
|
|
||||||
|
#### `app-icon-url`
|
||||||
|
Specify URL for PWA and browser tab icon (512x512 PNG).
|
||||||
|
|
||||||
|
#### `app-background-color`
|
||||||
|
Specify background color for PWA. Must be a valid CSS color.
|
||||||
|
|
||||||
## Theme
|
## Theme
|
||||||
Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers.
|
Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers.
|
||||||
|
|
||||||
@@ -269,12 +389,27 @@ 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:
|
||||||
|
gruvbox-dark:
|
||||||
|
background-color: 0 0 16
|
||||||
|
primary-color: 43 59 81
|
||||||
|
positive-color: 61 66 44
|
||||||
|
negative-color: 6 96 59
|
||||||
|
|
||||||
|
zebra:
|
||||||
|
light: true
|
||||||
|
background-color: 0 0 95
|
||||||
|
primary-color: 0 0 10
|
||||||
|
negative-color: 0 90 50
|
||||||
```
|
```
|
||||||
|
|
||||||
### Themes
|
### Available themes
|
||||||
If you don't want to spend time configuring your own theme, there are [several available themes](themes.md) which you can simply copy the values for.
|
If you don't want to spend time configuring your own theme, there are [several available themes](themes.md) which you can simply copy the values for.
|
||||||
|
|
||||||
### Properties
|
### Properties
|
||||||
@@ -288,6 +423,8 @@ 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 | |
|
||||||
|
|
||||||
#### `light`
|
#### `light`
|
||||||
Whether the scheme is light or dark. This does not change the background color, it inverts the text colors so that they look appropriately on a light background.
|
Whether the scheme is light or dark. This does not change the background color, it inverts the text colors so that they look appropriately on a light background.
|
||||||
@@ -332,6 +469,33 @@ 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`
|
||||||
|
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:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
presets:
|
||||||
|
my-custom-dark-theme:
|
||||||
|
background-color: 229 19 23
|
||||||
|
contrast-multiplier: 1.2
|
||||||
|
primary-color: 222 74 74
|
||||||
|
positive-color: 96 44 68
|
||||||
|
negative-color: 359 68 71
|
||||||
|
my-custom-light-theme:
|
||||||
|
light: true
|
||||||
|
background-color: 220 23 95
|
||||||
|
contrast-multiplier: 1.1
|
||||||
|
primary-color: 220 91 54
|
||||||
|
positive-color: 109 58 40
|
||||||
|
negative-color: 347 87 44
|
||||||
|
```
|
||||||
|
|
||||||
|
To override the default dark and light themes, use the key names `default-dark` and `default-light`.
|
||||||
|
|
||||||
## Pages & Columns
|
## Pages & Columns
|
||||||

|

|
||||||
@@ -359,22 +523,28 @@ pages:
|
|||||||
| name | string | yes | |
|
| name | string | yes | |
|
||||||
| slug | string | no | |
|
| slug | string | no | |
|
||||||
| width | string | no | |
|
| width | string | no | |
|
||||||
|
| desktop-navigation-width | string | no | |
|
||||||
| center-vertically | boolean | no | false |
|
| center-vertically | boolean | no | false |
|
||||||
| hide-desktop-navigation | boolean | no | false |
|
| hide-desktop-navigation | boolean | no | false |
|
||||||
| expand-mobile-page-navigation | boolean | no | false |
|
|
||||||
| show-mobile-header | boolean | no | false |
|
| show-mobile-header | boolean | no | false |
|
||||||
|
| head-widgets | array | no | |
|
||||||
| columns | array | yes | |
|
| columns | array | yes | |
|
||||||
|
|
||||||
#### `title`
|
#### `name`
|
||||||
The name of the page which gets shown in the navigation bar.
|
The name of the page which gets shown in the navigation bar.
|
||||||
|
|
||||||
#### `slug`
|
#### `slug`
|
||||||
The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title.
|
The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title.
|
||||||
|
|
||||||
#### `width`
|
#### `width`
|
||||||
The maximum width of the page on desktop. Possible values are `slim` and `wide`.
|
The maximum width of the page on desktop. Possible values are `default`, `slim` and `wide`.
|
||||||
|
|
||||||
* default: `1600px` (when no value is specified)
|
#### `desktop-navigation-width`
|
||||||
|
The maximum width of the desktop navigation. Useful if you have a few pages that use a different width than the rest and don't want the navigation to jump abruptly when going to and away from those pages. Possible values are `default`, `slim` and `wide`.
|
||||||
|
|
||||||
|
Here are the pixel equivalents for each value:
|
||||||
|
|
||||||
|
* default: `1600px`
|
||||||
* slim: `1100px`
|
* slim: `1100px`
|
||||||
* wide: `1920px`
|
* wide: `1920px`
|
||||||
|
|
||||||
@@ -388,9 +558,6 @@ When set to `true`, vertically centers the content on the page. Has no effect if
|
|||||||
#### `hide-desktop-navigation`
|
#### `hide-desktop-navigation`
|
||||||
Whether to show the navigation links at the top of the page on desktop.
|
Whether to show the navigation links at the top of the page on desktop.
|
||||||
|
|
||||||
#### `expand-mobile-page-navigation`
|
|
||||||
Whether the mobile page navigation should be expanded by default.
|
|
||||||
|
|
||||||
#### `show-mobile-header`
|
#### `show-mobile-header`
|
||||||
Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices.
|
Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices.
|
||||||
|
|
||||||
@@ -398,6 +565,43 @@ Preview:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
#### `head-widgets`
|
||||||
|
|
||||||
|
Head widgets will be shown at the top of the page, above the columns, and take up the combined width of all columns. You can specify any widget, though some will look better than others, such as the markets, RSS feed with `horizontal-cards` style, and videos widgets. Example:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```yaml
|
||||||
|
pages:
|
||||||
|
- name: Home
|
||||||
|
head-widgets:
|
||||||
|
- type: markets
|
||||||
|
hide-header: true
|
||||||
|
markets:
|
||||||
|
- symbol: SPY
|
||||||
|
name: S&P 500
|
||||||
|
- symbol: BTC-USD
|
||||||
|
name: Bitcoin
|
||||||
|
- symbol: NVDA
|
||||||
|
name: NVIDIA
|
||||||
|
- symbol: AAPL
|
||||||
|
name: Apple
|
||||||
|
- symbol: MSFT
|
||||||
|
name: Microsoft
|
||||||
|
|
||||||
|
columns:
|
||||||
|
- size: small
|
||||||
|
widgets:
|
||||||
|
- type: calendar
|
||||||
|
- size: full
|
||||||
|
widgets:
|
||||||
|
- type: hacker-news
|
||||||
|
- size: small
|
||||||
|
widgets:
|
||||||
|
- type: weather
|
||||||
|
location: London, United Kingdom
|
||||||
|
```
|
||||||
|
|
||||||
### Columns
|
### Columns
|
||||||
Columns are defined for each page using a `columns` property. There are two types of columns - `full` and `small`, which refers to their width. A small column takes up a fixed amount of width (300px) and a full column takes up the all of the remaining width. You can have up to 3 columns per page and you must have either 1 or 2 full columns. Example:
|
Columns are defined for each page using a `columns` property. There are two types of columns - `full` and `small`, which refers to their width. A small column takes up a fixed amount of width (300px) and a full column takes up the all of the remaining width. You can have up to 3 columns per page and you must have either 1 or 2 full columns. Example:
|
||||||
|
|
||||||
@@ -476,6 +680,7 @@ pages:
|
|||||||
| type | string | yes |
|
| type | string | yes |
|
||||||
| title | string | no |
|
| title | string | no |
|
||||||
| title-url | string | no |
|
| title-url | string | no |
|
||||||
|
| hide-header | boolean | no | false |
|
||||||
| cache | string | no |
|
| cache | string | no |
|
||||||
| css-class | string | no |
|
| css-class | string | no |
|
||||||
|
|
||||||
@@ -488,6 +693,13 @@ The title of the widget. If left blank it will be defined by the widget.
|
|||||||
#### `title-url`
|
#### `title-url`
|
||||||
The URL to go to when clicking on the widget's title. If left blank it will be defined by the widget (if available).
|
The URL to go to when clicking on the widget's title. If left blank it will be defined by the widget (if available).
|
||||||
|
|
||||||
|
#### `hide-header`
|
||||||
|
When set to `true`, the header (title) of the widget will be hidden. You cannot hide the header of the group widget.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
>
|
||||||
|
> If a widget fails to update, a red dot or circle is shown next to the title of that widget indicating that the it is not working. You will not be able to see this if you hide the header.
|
||||||
|
|
||||||
#### `cache`
|
#### `cache`
|
||||||
How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples:
|
How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples:
|
||||||
|
|
||||||
@@ -789,7 +1001,10 @@ Display a list of posts from a specific subreddit.
|
|||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
>
|
>
|
||||||
> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403 response. As a workaround you can route the traffic from Glance through a VPN or your own HTTP proxy using the `request-url-template` property.
|
> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403
|
||||||
|
> response. As a workaround you can either [register an app on Reddit](https://ssl.reddit.com/prefs/apps/) and use the
|
||||||
|
> generated ID and secret in the widget configuration to authenticate your requests (see `app-auth` property), use a proxy
|
||||||
|
> (see `proxy` property) or route the traffic from Glance through a VPN.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -814,6 +1029,7 @@ Example:
|
|||||||
| top-period | string | no | day |
|
| top-period | string | no | day |
|
||||||
| search | string | no | |
|
| search | string | no | |
|
||||||
| extra-sort-by | string | no | |
|
| extra-sort-by | string | no | |
|
||||||
|
| app-auth | object | no | |
|
||||||
|
|
||||||
##### `subreddit`
|
##### `subreddit`
|
||||||
The subreddit for which to fetch the posts from.
|
The subreddit for which to fetch the posts from.
|
||||||
@@ -921,6 +1137,19 @@ Can be used to specify an additional sort which will be applied on top of the al
|
|||||||
|
|
||||||
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
|
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
|
||||||
|
|
||||||
|
##### `app-auth`
|
||||||
|
```yaml
|
||||||
|
widgets:
|
||||||
|
- type: reddit
|
||||||
|
subreddit: technology
|
||||||
|
app-auth:
|
||||||
|
name: ${REDDIT_APP_NAME}
|
||||||
|
id: ${REDDIT_APP_CLIENT_ID}
|
||||||
|
secret: ${REDDIT_APP_SECRET}
|
||||||
|
```
|
||||||
|
|
||||||
|
To register an app on Reddit, go to [this page](https://ssl.reddit.com/prefs/apps/).
|
||||||
|
|
||||||
### Search Widget
|
### Search Widget
|
||||||
Display a search bar that can be used to search for specific terms on various search engines.
|
Display a search bar that can be used to search for specific terms on various search engines.
|
||||||
|
|
||||||
@@ -958,6 +1187,7 @@ Preview:
|
|||||||
| search-engine | string | no | duckduckgo |
|
| search-engine | string | no | duckduckgo |
|
||||||
| new-tab | boolean | no | false |
|
| new-tab | boolean | no | false |
|
||||||
| autofocus | boolean | no | false |
|
| autofocus | boolean | no | false |
|
||||||
|
| target | string | no | _blank |
|
||||||
| placeholder | string | no | Type here to search… |
|
| placeholder | string | no | Type here to search… |
|
||||||
| bangs | array | no | |
|
| bangs | array | no | |
|
||||||
|
|
||||||
@@ -968,6 +1198,10 @@ Either a value from the table below or a URL to a custom search engine. Use `{QU
|
|||||||
| ---- | --- |
|
| ---- | --- |
|
||||||
| duckduckgo | `https://duckduckgo.com/?q={QUERY}` |
|
| duckduckgo | `https://duckduckgo.com/?q={QUERY}` |
|
||||||
| google | `https://www.google.com/search?q={QUERY}` |
|
| google | `https://www.google.com/search?q={QUERY}` |
|
||||||
|
| bing | `https://www.bing.com/search?q={QUERY}` |
|
||||||
|
| perplexity | `https://www.perplexity.ai/search?q={QUERY}` |
|
||||||
|
| kagi | `https://kagi.com/search?q={QUERY}` |
|
||||||
|
| startpage | `https://www.startpage.com/search?q={QUERY}` |
|
||||||
|
|
||||||
##### `new-tab`
|
##### `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.
|
When set to `true`, swaps the shortcuts for showing results in the same or new tab, defaulting to showing results in a new tab.
|
||||||
@@ -975,6 +1209,9 @@ When set to `true`, swaps the shortcuts for showing results in the same or new t
|
|||||||
##### `autofocus`
|
##### `autofocus`
|
||||||
When set to `true`, automatically focuses the search input on page load.
|
When set to `true`, automatically focuses the search input on page load.
|
||||||
|
|
||||||
|
##### `target`
|
||||||
|
The target to use when opening the search results in a new tab. Possible values are `_blank`, `_self`, `_parent` and `_top`.
|
||||||
|
|
||||||
##### `placeholder`
|
##### `placeholder`
|
||||||
When set, modifies the text displayed in the input field before typing.
|
When set, modifies the text displayed in the input field before typing.
|
||||||
|
|
||||||
@@ -1290,10 +1527,18 @@ Examples:
|
|||||||
#### Properties
|
#### Properties
|
||||||
| Name | Type | Required | Default |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| url | string | yes | |
|
| url | string | no | |
|
||||||
| headers | key (string) & value (string) | no | |
|
| headers | key (string) & value (string) | no | |
|
||||||
|
| method | string | no | GET |
|
||||||
|
| body-type | string | no | json |
|
||||||
|
| body | any | no | |
|
||||||
| frameless | boolean | no | false |
|
| frameless | boolean | no | false |
|
||||||
|
| allow-insecure | boolean | no | false |
|
||||||
|
| skip-json-validation | boolean | no | false |
|
||||||
| template | string | yes | |
|
| template | string | yes | |
|
||||||
|
| options | map | no | |
|
||||||
|
| parameters | key (string) & value (string|array) | no | |
|
||||||
|
| subrequests | map of requests | no | |
|
||||||
|
|
||||||
##### `url`
|
##### `url`
|
||||||
The URL to fetch the data from. It must be accessible from the server that Glance is running on.
|
The URL to fetch the data from. It must be accessible from the server that Glance is running on.
|
||||||
@@ -1307,12 +1552,181 @@ headers:
|
|||||||
Accept: application/json
|
Accept: application/json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
##### `method`
|
||||||
|
The HTTP method to use when making the request. Possible values are `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` and `HEAD`.
|
||||||
|
|
||||||
|
##### `body-type`
|
||||||
|
The type of the body that will be sent with the request. Possible values are `json`, and `string`.
|
||||||
|
|
||||||
|
##### `body`
|
||||||
|
The body that will be sent with the request. It can be a string or a map. Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
body-type: json
|
||||||
|
body:
|
||||||
|
key1: value1
|
||||||
|
key2: value2
|
||||||
|
multiple-items:
|
||||||
|
- item1
|
||||||
|
- item2
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
body-type: string
|
||||||
|
body: |
|
||||||
|
key1=value1&key2=value2
|
||||||
|
```
|
||||||
|
|
||||||
##### `frameless`
|
##### `frameless`
|
||||||
When set to `true`, removes the border and padding around the widget.
|
When set to `true`, removes the border and padding around the widget.
|
||||||
|
|
||||||
|
##### `allow-insecure`
|
||||||
|
Whether to ignore invalid/self-signed certificates.
|
||||||
|
|
||||||
|
##### `skip-json-validation`
|
||||||
|
When set to `true`, skips the JSON validation step. This is useful when the API returns JSON Lines/newline-delimited JSON, which is a format that consists of several JSON objects separated by newlines.
|
||||||
|
|
||||||
##### `template`
|
##### `template`
|
||||||
The template that will be used to display the data. It relies on Go's `html/template` package so it's recommended to go through [its documentation](https://pkg.go.dev/text/template) to understand how to do basic things such as conditionals, loops, etc. In addition, it also uses [tidwall's gjson](https://github.com/tidwall/gjson) package to parse the JSON data so it's worth going through its documentation if you want to use more advanced JSON selectors. You can view additional examples with explanations and function definitions [here](custom-api.md).
|
The template that will be used to display the data. It relies on Go's `html/template` package so it's recommended to go through [its documentation](https://pkg.go.dev/text/template) to understand how to do basic things such as conditionals, loops, etc. In addition, it also uses [tidwall's gjson](https://github.com/tidwall/gjson) package to parse the JSON data so it's worth going through its documentation if you want to use more advanced JSON selectors. You can view additional examples with explanations and function definitions [here](custom-api.md).
|
||||||
|
|
||||||
|
##### `options`
|
||||||
|
A map of options that will be passed to the template and can be used to modify the behavior of the widget.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>View examples</summary>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Instead of defining options within the template and having to modify the template itself like such:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: custom-api
|
||||||
|
template: |
|
||||||
|
{{ /* User configurable options */ }}
|
||||||
|
{{ $collapseAfter := 5 }}
|
||||||
|
{{ $showThumbnails := true }}
|
||||||
|
{{ $showFlairs := false }}
|
||||||
|
|
||||||
|
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ $collapseAfter }}">
|
||||||
|
{{ if $showThumbnails }}
|
||||||
|
<li>
|
||||||
|
<img src="{{ .JSON.String "thumbnail" }}" alt="thumbnail" />
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
{{ if $showFlairs }}
|
||||||
|
<li>
|
||||||
|
<span class="flair">{{ .JSON.String "flair" }}</span>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
You can use the `options` property to retrieve and define default values for these variables:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: custom-api
|
||||||
|
template: |
|
||||||
|
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .Options.IntOr "collapse-after" 5 }}">
|
||||||
|
{{ if (.Options.BoolOr "show-thumbnails" true) }}
|
||||||
|
<li>
|
||||||
|
<img src="{{ .JSON.String "thumbnail" }}" alt="thumbnail" />
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
{{ if (.Options.BoolOr "show-flairs" false) }}
|
||||||
|
<li>
|
||||||
|
<span class="flair">{{ .JSON.String "flair" }}</span>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
This way, you can optionally specify the `collapse-after`, `show-thumbnails` and `show-flairs` properties in the widget configuration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: custom-api
|
||||||
|
options:
|
||||||
|
collapse-after: 5
|
||||||
|
show-thumbnails: true
|
||||||
|
show-flairs: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Which means you can reuse the same template for multiple widgets with different options:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Note that `custom-widgets` isn't a special property, it's just used to define the reusable "anchor", see https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/
|
||||||
|
custom-widgets:
|
||||||
|
- &example-widget
|
||||||
|
type: custom-api
|
||||||
|
template: |
|
||||||
|
{{ .Options.StringOr "custom-option" "not defined" }}
|
||||||
|
|
||||||
|
pages:
|
||||||
|
- name: Home
|
||||||
|
columns:
|
||||||
|
- size: full
|
||||||
|
widgets:
|
||||||
|
- <<: *example-widget
|
||||||
|
options:
|
||||||
|
custom-option: "Value 1"
|
||||||
|
|
||||||
|
- <<: *example-widget
|
||||||
|
options:
|
||||||
|
custom-option: "Value 2"
|
||||||
|
```
|
||||||
|
|
||||||
|
Currently, the available methods on the `.Options` object are: `StringOr`, `IntOr`, `BoolOr` and `FloatOr`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
##### `parameters`
|
||||||
|
A list of keys and values that will be sent to the custom-api as query paramters.
|
||||||
|
|
||||||
|
##### `subrequests`
|
||||||
|
A map of additional requests that will be executed concurrently and then made available in the template via the `.Subrequest` property. Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: custom-api
|
||||||
|
cache: 2h
|
||||||
|
subrequests:
|
||||||
|
another-one:
|
||||||
|
url: https://uselessfacts.jsph.pl/api/v2/facts/random
|
||||||
|
title: Random Fact
|
||||||
|
url: https://uselessfacts.jsph.pl/api/v2/facts/random
|
||||||
|
template: |
|
||||||
|
<p class="size-h4 color-paragraph">{{ .JSON.String "text" }}</p>
|
||||||
|
<p class="size-h4 color-paragraph margin-top-15">{{ (.Subrequest "another-one").JSON.String "text" }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
The subrequests support all the same properties as the main request, except for `subrequests` itself, so you can use `headers`, `parameters`, etc.
|
||||||
|
|
||||||
|
`(.Subrequest "key")` can be a little cumbersome to write, so you can define a variable to make it easier:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
template: |
|
||||||
|
{{ $anotherOne := .Subrequest "another-one" }}
|
||||||
|
<p>{{ $anotherOne.JSON.String "text" }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also access the `.Response` property of a subrequest as you would with the main request:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
template: |
|
||||||
|
{{ $anotherOne := .Subrequest "another-one" }}
|
||||||
|
<p>{{ $anotherOne.Response.StatusCode }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
>
|
||||||
|
> Setting this property will override any query parameters that are already in the URL.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
parameters:
|
||||||
|
param1: value1
|
||||||
|
param2:
|
||||||
|
- item1
|
||||||
|
- item2
|
||||||
|
```
|
||||||
|
|
||||||
### Extension
|
### 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).
|
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).
|
||||||
|
|
||||||
@@ -1330,6 +1744,7 @@ Display a widget provided by an external source (3rd party). If you want to lear
|
|||||||
| url | string | yes | |
|
| url | string | yes | |
|
||||||
| fallback-content-type | string | no | |
|
| fallback-content-type | string | no | |
|
||||||
| allow-potentially-dangerous-html | boolean | no | false |
|
| allow-potentially-dangerous-html | boolean | no | false |
|
||||||
|
| headers | key & value | no | |
|
||||||
| parameters | key & value | no | |
|
| parameters | key & value | no | |
|
||||||
|
|
||||||
##### `url`
|
##### `url`
|
||||||
@@ -1338,6 +1753,14 @@ The URL of the extension. **Note that the query gets stripped from this URL and
|
|||||||
##### `fallback-content-type`
|
##### `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`.
|
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`.
|
||||||
|
|
||||||
|
##### `headers`
|
||||||
|
Optionally specify the headers that will be sent with the request. Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
headers:
|
||||||
|
x-api-key: ${SECRET_KEY}
|
||||||
|
```
|
||||||
|
|
||||||
##### `allow-potentially-dangerous-html`
|
##### `allow-potentially-dangerous-html`
|
||||||
Whether to allow the extension to display HTML.
|
Whether to allow the extension to display HTML.
|
||||||
|
|
||||||
@@ -1410,6 +1833,44 @@ Otherwise, if set to `false` (which is the default) it'll be displayed as:
|
|||||||
Greenville, United States
|
Greenville, United States
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
A simple to-do list that allows you to add, edit and delete tasks. The tasks are stored in the browser's local storage.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: to-do
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
To reorder tasks, drag and drop them by grabbing the top side of the task:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
To delete a task, hover over it and click on the trash icon.
|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
|
||||||
|
| Name | Type | Required | Default |
|
||||||
|
| ---- | ---- | -------- | ------- |
|
||||||
|
| id | string | no | |
|
||||||
|
|
||||||
|
##### `id`
|
||||||
|
|
||||||
|
The ID of the todo list. If you want to have multiple todo lists, you must specify a different ID for each one. The ID is used to store the tasks in the browser's local storage. This means that if you have multiple todo lists with the same ID, they will share the same tasks.
|
||||||
|
|
||||||
|
#### Keyboard shortcuts
|
||||||
|
| Keys | Action | Condition |
|
||||||
|
| ---- | ------ | --------- |
|
||||||
|
| <kbd>Enter</kbd> | Add a task to the bottom of the list | When the "Add a task" field is focused |
|
||||||
|
| <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Add a task to the top of the list | When the "Add a task" field is focused |
|
||||||
|
| <kbd>Down Arrow</kbd> | Focus the last task that was added | When the "Add a task" field is focused |
|
||||||
|
| <kbd>Escape</kbd> | Focus the "Add a task" field | When a task is focused |
|
||||||
|
|
||||||
### Monitor
|
### Monitor
|
||||||
Display a list of sites and whether they are reachable (online) or not. This is determined by sending a GET request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds.
|
Display a list of sites and whether they are reachable (online) or not. This is determined by sending a GET request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds.
|
||||||
|
|
||||||
@@ -1435,7 +1896,6 @@ Example:
|
|||||||
- title: Vaultwarden
|
- title: Vaultwarden
|
||||||
url: https://vault.yourdomain.com
|
url: https://vault.yourdomain.com
|
||||||
icon: /assets/vaultwarden-logo.png
|
icon: /assets/vaultwarden-logo.png
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Preview:
|
Preview:
|
||||||
@@ -1473,9 +1933,11 @@ Properties for each site:
|
|||||||
| check-url | string | no | |
|
| check-url | string | no | |
|
||||||
| error-url | string | no | |
|
| error-url | string | no | |
|
||||||
| icon | string | no | |
|
| icon | string | no | |
|
||||||
|
| timeout | string | no | 3s |
|
||||||
| allow-insecure | boolean | no | false |
|
| allow-insecure | boolean | no | false |
|
||||||
| same-tab | boolean | no | false |
|
| same-tab | boolean | no | false |
|
||||||
| alt-status-codes | array | no | |
|
| alt-status-codes | array | no | |
|
||||||
|
| basic-auth | object | no | |
|
||||||
|
|
||||||
`title`
|
`title`
|
||||||
|
|
||||||
@@ -1483,7 +1945,7 @@ The title used to indicate the site.
|
|||||||
|
|
||||||
`url`
|
`url`
|
||||||
|
|
||||||
The public facing URL of a monitored service, the user will be redirected here. If `check-url` is not specified, this is used as the status check.
|
The URL of the monitored service, which must be reachable by Glance, and will be used as the link to go to when clicking on the title. If `check-url` is not specified, this is used as the status check.
|
||||||
|
|
||||||
`check-url`
|
`check-url`
|
||||||
|
|
||||||
@@ -1507,6 +1969,10 @@ icon: si:adguard
|
|||||||
>
|
>
|
||||||
> 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.
|
> 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`
|
||||||
|
|
||||||
|
How long to wait for a response from the server before considering it unreachable. The value is a string and must be a number followed by one of s, m, h, d. Example: `5s` for 5 seconds, `1m` for 1 minute, etc.
|
||||||
|
|
||||||
`allow-insecure`
|
`allow-insecure`
|
||||||
|
|
||||||
Whether to ignore invalid/self-signed certificates.
|
Whether to ignore invalid/self-signed certificates.
|
||||||
@@ -1524,6 +1990,16 @@ alt-status-codes:
|
|||||||
- 403
|
- 403
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`basic-auth`
|
||||||
|
|
||||||
|
HTTP Basic Authentication credentials for protected sites.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
basic-auth:
|
||||||
|
usename: your-username
|
||||||
|
password: your-password
|
||||||
|
```
|
||||||
|
|
||||||
### Releases
|
### Releases
|
||||||
Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
|
Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
|
||||||
|
|
||||||
@@ -1665,6 +2141,19 @@ Configuration of the containers is done via labels applied to each container:
|
|||||||
glance.description: Movies & shows
|
glance.description: Movies & shows
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Alternatively, you can also define the values within your `glance.yml` via the `containers` property, where the key is the container name and each value is the same as the labels but without the "glance." prefix:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: docker-containers
|
||||||
|
containers:
|
||||||
|
container_name_1:
|
||||||
|
name: Container Name
|
||||||
|
description: Description of the container
|
||||||
|
url: https://container.domain.com
|
||||||
|
icon: si:container-icon
|
||||||
|
hide: false
|
||||||
|
```
|
||||||
|
|
||||||
For services with multiple containers you can specify a `glance.id` on the "main" container and `glance.parent` on each "child" container:
|
For services with multiple containers you can specify a `glance.id` on the "main" container and `glance.parent` on each "child" container:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -1716,13 +2205,71 @@ If any of the child containers are down, their status will propagate up to the p
|
|||||||
| Name | Type | Required | Default |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| hide-by-default | boolean | no | false |
|
| hide-by-default | boolean | no | false |
|
||||||
|
| format-container-names | boolean | no | false |
|
||||||
| sock-path | string | no | /var/run/docker.sock |
|
| sock-path | string | no | /var/run/docker.sock |
|
||||||
|
| category | string | no | |
|
||||||
|
| running-only | boolean | no | false |
|
||||||
|
|
||||||
##### `hide-by-default`
|
##### `hide-by-default`
|
||||||
Whether to hide the containers by default. If set to `true` you'll have to manually add a `glance.hide: false` label to each container you want to display. By default all containers will be shown and if you want to hide a specific container you can add a `glance.hide: true` label.
|
Whether to hide the containers by default. If set to `true` you'll have to manually add a `glance.hide: false` label to each container you want to display. By default all containers will be shown and if you want to hide a specific container you can add a `glance.hide: true` label.
|
||||||
|
|
||||||
|
##### `format-container-names`
|
||||||
|
When set to `true`, automatically converts container names such as `container_name_1` into `Container Name 1`.
|
||||||
|
|
||||||
##### `sock-path`
|
##### `sock-path`
|
||||||
The path to the Docker socket.
|
The path to the Docker socket. This can also be a [remote socket](https://docs.docker.com/engine/daemon/remote-access/) or proxied socket using something like [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy).
|
||||||
|
|
||||||
|
###### `category`
|
||||||
|
Filter to only the containers which have this category specified via the `glance.category` label. Useful if you want to have multiple containers widgets, each showing a different set of containers.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>View example</summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
jellyfin:
|
||||||
|
image: jellyfin/jellyfin:latest
|
||||||
|
labels:
|
||||||
|
glance.name: Jellyfin
|
||||||
|
glance.icon: si:jellyfin
|
||||||
|
glance.url: https://jellyfin.domain.com
|
||||||
|
glance.category: media
|
||||||
|
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:latest
|
||||||
|
labels:
|
||||||
|
glance.name: Gitea
|
||||||
|
glance.icon: si:gitea
|
||||||
|
glance.url: https://gitea.domain.com
|
||||||
|
glance.category: dev-tools
|
||||||
|
|
||||||
|
vaultwarden:
|
||||||
|
image: vaultwarden/server:latest
|
||||||
|
labels:
|
||||||
|
glance.name: Vaultwarden
|
||||||
|
glance.icon: si:vaultwarden
|
||||||
|
glance.url: https://vaultwarden.domain.com
|
||||||
|
glance.category: dev-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
Then you can use the `category` property to filter the containers:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: docker-containers
|
||||||
|
title: Dev tool containers
|
||||||
|
category: dev-tools
|
||||||
|
|
||||||
|
- type: docker-containers
|
||||||
|
title: Media containers
|
||||||
|
category: media
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
##### `running-only`
|
||||||
|
Whether to only show running containers. If set to `true` only containers that are currently running will be displayed. If set to `false` all containers will be displayed regardless of their state.
|
||||||
|
|
||||||
#### Labels
|
#### Labels
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
@@ -1735,9 +2282,10 @@ The path to the Docker socket.
|
|||||||
| glance.hide | Whether to hide the container. If set to `true` the container will not be displayed. Defaults to `false`. |
|
| glance.hide | Whether to hide the container. If set to `true` the container will not be displayed. Defaults to `false`. |
|
||||||
| glance.id | The custom ID of the container. Used to group containers under a single parent. |
|
| glance.id | The custom ID of the container. Used to group containers under a single parent. |
|
||||||
| glance.parent | The ID of the parent container. Used to group containers under a single parent. |
|
| glance.parent | The ID of the parent container. Used to group containers under a single parent. |
|
||||||
|
| glance.category | The category of the container. Used to filter containers by category. |
|
||||||
|
|
||||||
### DNS Stats
|
### DNS Stats
|
||||||
Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home or Pi-hole.
|
Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home, Pi-hole, or Technitium.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -1755,7 +2303,7 @@ Preview:
|
|||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
>
|
>
|
||||||
> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole it will be the total number of blocked domains from all adlists.
|
> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole or Technitium it will be the total number of blocked domains from all adlists.
|
||||||
|
|
||||||
#### Properties
|
#### Properties
|
||||||
|
|
||||||
@@ -1772,7 +2320,7 @@ Preview:
|
|||||||
| hour-format | string | no | 12h |
|
| hour-format | string | no | 12h |
|
||||||
|
|
||||||
##### `service`
|
##### `service`
|
||||||
Either `adguard`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above).
|
Either `adguard`, `technitium`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above).
|
||||||
|
|
||||||
##### `allow-insecure`
|
##### `allow-insecure`
|
||||||
Whether to allow invalid/self-signed certificates when making the request to the service.
|
Whether to allow invalid/self-signed certificates when making the request to the service.
|
||||||
@@ -1786,10 +2334,12 @@ Only required when using AdGuard Home. The username used to log into the admin d
|
|||||||
##### `password`
|
##### `password`
|
||||||
Required when using AdGuard Home, where the password is the one used to log into the admin dashboard.
|
Required when using AdGuard Home, where the password is the one used to log into the admin dashboard.
|
||||||
|
|
||||||
Also requried when using Pi-hole major version 6 and above, where the password is the one used to log into the admin dashboard or the application password, which can be found in `Settings -> Web Interface / API -> Configure app password`.
|
Also required when using Pi-hole major version 6 and above, where the password is the one used to log into the admin dashboard or the application password, which can be found in `Settings -> Web Interface / API -> Configure app password`.
|
||||||
|
|
||||||
##### `token`
|
##### `token`
|
||||||
Only required when using Pi-hole major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API token`.
|
Required when using Pi-hole major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API token`.
|
||||||
|
|
||||||
|
Also required when using Technitium, an API token can be generated at `Administration -> Sessions -> Create Token`.
|
||||||
|
|
||||||
##### `hide-graph`
|
##### `hide-graph`
|
||||||
Whether to hide the graph showing the number of queries over time.
|
Whether to hide the graph showing the number of queries over time.
|
||||||
@@ -1854,7 +2404,7 @@ Whether to hide the swap usage.
|
|||||||
| Name | Type | Required | Default |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| cpu-temp-sensor | string | no | |
|
| cpu-temp-sensor | string | no | |
|
||||||
| hide-mointpoints-by-default | boolean | no | false |
|
| hide-mountpoints-by-default | boolean | no | false |
|
||||||
| mountpoints | map\[string\]object | no | |
|
| mountpoints | map\[string\]object | no | |
|
||||||
|
|
||||||
###### `cpu-temp-sensor`
|
###### `cpu-temp-sensor`
|
||||||
@@ -2032,6 +2582,7 @@ An array of groups which can optionally have a title and a custom color.
|
|||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| title | string | yes | |
|
| title | string | yes | |
|
||||||
| url | string | yes | |
|
| url | string | yes | |
|
||||||
|
| description | string | no | |
|
||||||
| icon | string | no | |
|
| icon | string | no | |
|
||||||
| same-tab | boolean | no | false |
|
| same-tab | boolean | no | false |
|
||||||
| hide-arrow | boolean | no | false |
|
| hide-arrow | boolean | no | false |
|
||||||
|
|||||||
@@ -226,10 +226,10 @@ JSON response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Calculations can be performed, however all numbers must be converted to floats first if they are not already:
|
Calculations can be performed on either ints or floats. If both numbers are ints, an int will be returned, otherwise a float will be returned. If you try to divide by zero, 0 will be returned. If you provide non-numeric values, `NaN` will be returned.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<div>{{ sub (.JSON.Int "price" | toFloat) (.JSON.Int "discount" | toFloat) }}</div>
|
<div>{{ sub (.JSON.Int "price") (.JSON.Int "discount") }}</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
Output:
|
Output:
|
||||||
@@ -238,7 +238,58 @@ Output:
|
|||||||
<div>90</div>
|
<div>90</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
Other operations include `add`, `mul`, and `div`.
|
Other operations include `add`, `mul`, `div` and `mod`.
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
JSON response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"title": "Exploring the Depths of Quantum Computing",
|
||||||
|
"date": "2023-10-27T10:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "A Beginner's Guide to Sustainable Living",
|
||||||
|
"date": "2023-11-15T14:30:00+01:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "The Art of Baking Sourdough Bread",
|
||||||
|
"date": "2023-12-03T08:45:22-08:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To parse the date and display the relative time (e.g. 2h, 1d, etc), you would use the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{ range .JSON.Array "posts" }}
|
||||||
|
<div>{{ .String "title" }}</div>
|
||||||
|
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `parseTime` function takes two arguments: the layout of the date string and the date string itself. The layout can be one of the following: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly" or a custom layout in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div>Exploring the Depths of Quantum Computing</div>
|
||||||
|
<div data-dynamic-relative-time="1698400800"></div>
|
||||||
|
|
||||||
|
<div>A Beginner's Guide to Sustainable Living</div>
|
||||||
|
<div data-dynamic-relative-time="1700055000"></div>
|
||||||
|
|
||||||
|
<div>The Art of Baking Sourdough Bread</div>
|
||||||
|
<div data-dynamic-relative-time="1701621922"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
You don't have to worry about the internal implementation, this will then be dynamically populated by Glance on the client side to show the correct relative time.
|
||||||
|
|
||||||
|
The important thing to notice here is that the return value of `toRelativeTime` must be used as an attribute in an HTML tag, be it a `div`, `li`, `span`, etc.
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
@@ -258,6 +309,101 @@ You can also access the response headers:
|
|||||||
<div>{{ .Response.Header.Get "Content-Type" }}</div>
|
<div>{{ .Response.Header.Get "Content-Type" }}</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
JSON response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name": "Steve", "age": 30}
|
||||||
|
{"name": "Alex", "age": 25}
|
||||||
|
{"name": "John", "age": 35}
|
||||||
|
```
|
||||||
|
|
||||||
|
The above format is "[ndjson](https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson)" or "[JSON Lines](https://jsonlines.org/)", where each line is a separate JSON object. To parse this format, you must first disable the JSON validation check in your config, since by default the response is expected to be a single valid JSON object:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: custom-api
|
||||||
|
skip-json-validation: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, to iterate over each object you can use `.JSONLines`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{{ range .JSONLines }}
|
||||||
|
<p>{{ .String "name" }} is {{ .Int "age" }} years old</p>
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p>Steve is 30 years old</p>
|
||||||
|
<p>Alex is 25 years old</p>
|
||||||
|
<p>John is 35 years old</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
For other ways of selecting data from a JSON Lines response, have a look at the docs for [tidwall/gjson](https://github.com/tidwall/gjson/tree/master?tab=readme-ov-file#json-lines). For example, to get an array of all names, you can use the following:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{{ range .JSON.Array "..#.name" }}
|
||||||
|
<p>{{ .String "" }}</p>
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p>Steve</p>
|
||||||
|
<p>Alex</p>
|
||||||
|
<p>John</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
In some instances, you may need to make two consecutive API calls, where you use the result of the first call in the second call. To achieve this, you can make additional HTTP requests from within the template itself using the following syntax:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: custom-api
|
||||||
|
url: https://api.example.com/get-id-of-something
|
||||||
|
template: |
|
||||||
|
{{ $theID := .JSON.String "id" }}
|
||||||
|
|
||||||
|
{{
|
||||||
|
$something := newRequest (concat "https://api.example.com/something/" $theID)
|
||||||
|
| withParameter "key" "value"
|
||||||
|
| withHeader "Authorization" "Bearer token"
|
||||||
|
| getResponse
|
||||||
|
}}
|
||||||
|
|
||||||
|
{{ $something.JSON.String "title" }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Here, `$theID` gets retrieved from the result of the first API call and used in the second API call. The `newRequest` function creates a new request, and the `getResponse` function executes it. You can also use `withParameter` and `withHeader` to optionally add parameters and headers to the request.
|
||||||
|
|
||||||
|
If you need to make a request to a URL that requires dynamic parameters, you can omit the `url` property in the YAML and run the request entirely from within the template itself:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: custom-api
|
||||||
|
title: Events from the last 24h
|
||||||
|
template: |
|
||||||
|
{{
|
||||||
|
$events := newRequest "https://api.example.com/events"
|
||||||
|
| withParameter "after" (offsetNow "-24h" | formatTime "rfc3339")
|
||||||
|
| getResponse
|
||||||
|
}}
|
||||||
|
|
||||||
|
{{ if eq $events.Response.StatusCode 200 }}
|
||||||
|
{{ range $events.JSON.Array "events" }}
|
||||||
|
<div>{{ .String "title" }}</div>
|
||||||
|
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
|
||||||
|
{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
<p>Failed to fetch data: {{ $events.Response.Status }}</p>
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
*Note that you need to manually check for the correct status code.*
|
||||||
|
|
||||||
## Functions
|
## Functions
|
||||||
|
|
||||||
The following functions are available on the `JSON` object:
|
The following functions are available on the `JSON` object:
|
||||||
@@ -269,16 +415,49 @@ 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.
|
||||||
- `toInt(f float) int`: Converts a float to an integer.
|
- `toInt(f float) int`: Converts a float to an integer.
|
||||||
|
- `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. `<span {{ toRelativeTime .Time }}></span>`.
|
||||||
|
- `now() time.Time`: Returns the current time.
|
||||||
|
- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s".
|
||||||
|
- `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`.
|
||||||
|
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly".
|
||||||
|
- `formatTime(layout string, s string) time.Time`: Formats a `time.Time` into a string. The layout uses the same format as `parseTime`.
|
||||||
|
- `parseLocalTime(layout string, s string) time.Time`: Same as the above, except in the absence of a timezone, it will use the local timezone instead of UTC.
|
||||||
|
- `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`.
|
||||||
- `add(a, b float) float`: Adds two numbers.
|
- `add(a, b float) float`: Adds two numbers.
|
||||||
- `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.
|
||||||
|
- `trimSuffix(suffix string, str string) string`: Trims the suffix from a string.
|
||||||
|
- `trimSpace(str string) string`: Trims whitespace from a string on both ends.
|
||||||
|
- `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string.
|
||||||
|
- `replaceMatches(pattern string, replacement string, str string) string`: Replaces all occurrences of a regular expression in a string.
|
||||||
|
- `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string.
|
||||||
|
- `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string.
|
||||||
|
- `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order.
|
||||||
|
- `sortByInt(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by an integer key in either ascending or descending order.
|
||||||
|
- `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order.
|
||||||
|
- `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
||||||
|
- `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.
|
||||||
|
- `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:
|
||||||
|
|
||||||
@@ -288,8 +467,8 @@ The following helper functions provided by Go's `text/template` are available:
|
|||||||
- `lte(a, b any) bool`: Compares two values for less than or equal to.
|
- `lte(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.
|
- `gte(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.
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ If you know how to setup an HTTP server and a bit of HTML and CSS you're ready t
|
|||||||
### `Widget-Title`
|
### `Widget-Title`
|
||||||
Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
|
Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
|
||||||
|
|
||||||
|
### `Widget-Title-URL`
|
||||||
|
Used to specify the URL that will be opened when the widget's title is clicked. If the user has specified a `title-url` in their config, it will take precedence over this header.
|
||||||
|
|
||||||
### `Widget-Content-Type`
|
### `Widget-Content-Type`
|
||||||
Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text.
|
Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text.
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 792 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -82,6 +82,17 @@ theme:
|
|||||||
negative-color: 209 88 54
|
negative-color: 209 88 54
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Dracula
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 231 15 21
|
||||||
|
primary-color: 265 89 79
|
||||||
|
contrast-multiplier: 1.2
|
||||||
|
positive-color: 135 94 66
|
||||||
|
negative-color: 0 100 67
|
||||||
|
```
|
||||||
|
|
||||||
## Light
|
## Light
|
||||||
|
|
||||||
### Catppuccin Latte
|
### Catppuccin Latte
|
||||||
|
|||||||
@@ -1,32 +1,33 @@
|
|||||||
module github.com/glanceapp/glance
|
module github.com/glanceapp/glance
|
||||||
|
|
||||||
go 1.23.6
|
go 1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.8.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.1
|
github.com/shirou/gopsutil/v4 v4.25.4
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/crypto v0.38.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.1 // indirect
|
github.com/PuerkitoBio/goquery v1.10.3 // 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.2 // 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/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // 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
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.9.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.34.0 // indirect
|
golang.org/x/net v0.40.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
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.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/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||||
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=
|
||||||
@@ -9,16 +13,20 @@ github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z
|
|||||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
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.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/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=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|
||||||
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/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/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
||||||
|
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/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||||
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||||
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
||||||
@@ -34,6 +42,10 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
|
|||||||
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/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
|
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
|
||||||
|
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
|
||||||
|
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
||||||
|
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=
|
||||||
@@ -47,8 +59,12 @@ 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 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
||||||
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
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/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
|
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.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
|
||||||
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
|
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=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
@@ -58,6 +74,10 @@ 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/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=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
@@ -74,6 +94,10 @@ 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 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
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/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=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/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=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -97,6 +121,10 @@ 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 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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/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=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -117,6 +145,10 @@ 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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
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/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=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
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=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
mathrand "math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const AUTH_SESSION_COOKIE_NAME = "session_token"
|
||||||
|
const AUTH_RATE_LIMIT_WINDOW = 5 * time.Minute
|
||||||
|
const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||||
|
|
||||||
|
const AUTH_TOKEN_SECRET_LENGTH = 32
|
||||||
|
const AUTH_USERNAME_HASH_LENGTH = 32
|
||||||
|
const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH
|
||||||
|
const AUTH_TIMESTAMP_LENGTH = 4 // uint32
|
||||||
|
const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH
|
||||||
|
|
||||||
|
// How long the token will be valid for
|
||||||
|
const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days
|
||||||
|
// How long the token has left before it should be regenerated
|
||||||
|
const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days
|
||||||
|
|
||||||
|
var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html")
|
||||||
|
|
||||||
|
type doWhenUnauthorized int
|
||||||
|
|
||||||
|
const (
|
||||||
|
redirectToLogin doWhenUnauthorized = iota
|
||||||
|
showUnauthorizedJSON
|
||||||
|
)
|
||||||
|
|
||||||
|
type failedAuthAttempt struct {
|
||||||
|
attempts int
|
||||||
|
first time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSessionToken(username string, secret []byte, now time.Time) (string, error) {
|
||||||
|
if len(secret) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameHash, err := computeUsernameHash(username, secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]byte, AUTH_TOKEN_DATA_LENGTH)
|
||||||
|
copy(data, usernameHash)
|
||||||
|
expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix()
|
||||||
|
binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires))
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH])
|
||||||
|
h.Write(data)
|
||||||
|
|
||||||
|
signature := h.Sum(nil)
|
||||||
|
encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...))
|
||||||
|
// encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64
|
||||||
|
|
||||||
|
return encodedToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeUsernameHash(username string, secret []byte) ([]byte, error) {
|
||||||
|
if len(secret) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:])
|
||||||
|
h.Write([]byte(username))
|
||||||
|
|
||||||
|
return h.Sum(nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte, bool, error) {
|
||||||
|
tokenBytes, err := base64.StdEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tokenBytes) != AUTH_TOKEN_DATA_LENGTH+32 {
|
||||||
|
return nil, false, fmt.Errorf("token length is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
return nil, false, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameHashBytes := tokenBytes[0:AUTH_USERNAME_HASH_LENGTH]
|
||||||
|
timestampBytes := tokenBytes[AUTH_USERNAME_HASH_LENGTH : AUTH_USERNAME_HASH_LENGTH+AUTH_TIMESTAMP_LENGTH]
|
||||||
|
providedSignatureBytes := tokenBytes[AUTH_TOKEN_DATA_LENGTH:]
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, secretBytes[0:32])
|
||||||
|
h.Write(tokenBytes[0:AUTH_TOKEN_DATA_LENGTH])
|
||||||
|
expectedSignatureBytes := h.Sum(nil)
|
||||||
|
|
||||||
|
if !hmac.Equal(expectedSignatureBytes, providedSignatureBytes) {
|
||||||
|
return nil, false, fmt.Errorf("signature does not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes))
|
||||||
|
if now.Unix() > expiresTimestamp {
|
||||||
|
return nil, false, fmt.Errorf("token has expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
return usernameHashBytes,
|
||||||
|
// True if the token should be regenerated
|
||||||
|
time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now),
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAuthSecretKey(length int) (string, error) {
|
||||||
|
key := make([]byte, length)
|
||||||
|
_, err := rand.Read(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Content-Type") != "application/json" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waitOnFailure := 1*time.Second - time.Duration(mathrand.IntN(500))*time.Millisecond
|
||||||
|
|
||||||
|
ip := a.addressOfRequest(r)
|
||||||
|
|
||||||
|
a.authAttemptsMu.Lock()
|
||||||
|
exceededRateLimit, retryAfter := func() (bool, int) {
|
||||||
|
attempt, exists := a.failedAuthAttempts[ip]
|
||||||
|
if !exists {
|
||||||
|
a.failedAuthAttempts[ip] = &failedAuthAttempt{
|
||||||
|
attempts: 1,
|
||||||
|
first: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(attempt.first)
|
||||||
|
if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS {
|
||||||
|
return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt.attempts++
|
||||||
|
return false, 0
|
||||||
|
}()
|
||||||
|
|
||||||
|
if exceededRateLimit {
|
||||||
|
a.authAttemptsMu.Unlock()
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Clean up old failed attempts
|
||||||
|
for ipOfAttempt := range a.failedAuthAttempts {
|
||||||
|
if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW {
|
||||||
|
delete(a.failedAuthAttempts, ipOfAttempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.authAttemptsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var creds struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, &creds)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logAuthFailure := func() {
|
||||||
|
log.Printf(
|
||||||
|
"Failed login attempt for user '%s' from %s",
|
||||||
|
creds.Username, ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(creds.Username) == 0 || len(creds.Password) == 0 {
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(creds.Username) > 50 || len(creds.Password) > 100 {
|
||||||
|
logAuthFailure()
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, exists := a.Config.Auth.Users[creds.Username]
|
||||||
|
if !exists {
|
||||||
|
logAuthFailure()
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(creds.Password)); err != nil {
|
||||||
|
logAuthFailure()
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Could not compute session token during login attempt: %v", err)
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
|
||||||
|
|
||||||
|
a.authAttemptsMu.Lock()
|
||||||
|
delete(a.failedAuthAttempts, ip)
|
||||||
|
a.authAttemptsMu.Unlock()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if !a.RequiresAuth {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME)
|
||||||
|
if err != nil || token.Value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameHash, shouldRegenerate, err := verifySessionToken(token.Value, a.authSecretKey, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
username, exists := a.usernameHashToUsername[string(usernameHash)]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_, exists = a.Config.Auth.Users[username]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldRegenerate {
|
||||||
|
newToken, err := generateSessionToken(username, a.authSecretKey, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Could not compute session token during regeneration: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
a.setAuthSessionCookie(w, r, newToken, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized
|
||||||
|
func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool {
|
||||||
|
if a.isAuthorized(w, r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch fallback {
|
||||||
|
case redirectToLogin:
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
|
||||||
|
case showUnauthorizedJSON:
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte(`{"error": "Unauthorized"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maybe this should be a POST request instead?
|
||||||
|
func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour))
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) setAuthSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: AUTH_SESSION_COOKIE_NAME,
|
||||||
|
Value: token,
|
||||||
|
Expires: expires,
|
||||||
|
Secure: strings.ToLower(r.Header.Get("X-Forwarded-Proto")) == "https",
|
||||||
|
Path: a.Config.Server.BaseURL + "/",
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
HttpOnly: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleLoginPageRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.isAuthorized(w, r) {
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &templateData{
|
||||||
|
App: a,
|
||||||
|
}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
|
||||||
|
var responseBytes bytes.Buffer
|
||||||
|
err := loginPageTemplate.Execute(&responseBytes, data)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(responseBytes.Bytes())
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthTokenGenerationAndVerification(t *testing.T) {
|
||||||
|
secret, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate secret key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretBytes, err := base64.StdEncoding.DecodeString(secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to decode secret key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
t.Fatalf("Secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
username := "admin"
|
||||||
|
|
||||||
|
token, err := generateSessionToken(username, secretBytes, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate session token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameHashBytes, shouldRegen, err := verifySessionToken(token, secretBytes, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to verify session token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldRegen {
|
||||||
|
t.Fatal("Token should not need to be regenerated immediately after generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
computedUsernameHash, err := computeUsernameHash(username, secretBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to compute username hash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(usernameHashBytes, computedUsernameHash) {
|
||||||
|
t.Fatal("Username hash does not match the expected value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test token regeneration
|
||||||
|
timeRightAfterRegenPeriod := now.Add(AUTH_TOKEN_VALID_PERIOD - AUTH_TOKEN_REGEN_BEFORE + 2*time.Second)
|
||||||
|
_, shouldRegen, err = verifySessionToken(token, secretBytes, timeRightAfterRegenPeriod)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Token verification should not fail during regeneration period, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shouldRegen {
|
||||||
|
t.Fatal("Token should have been marked for regeneration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test token expiration
|
||||||
|
_, _, err = verifySessionToken(token, secretBytes, now.Add(AUTH_TOKEN_VALID_PERIOD+2*time.Second))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected token verification to fail after token expiration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test tampered token
|
||||||
|
decodedToken, err := base64.StdEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to decode token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any of the bytes are off by 1, the token should be considered invalid
|
||||||
|
for i := range len(decodedToken) {
|
||||||
|
tampered := make([]byte, len(decodedToken))
|
||||||
|
copy(tampered, decodedToken)
|
||||||
|
tampered[i] += 1
|
||||||
|
|
||||||
|
_, _, err = verifySessionToken(base64.StdEncoding.EncodeToString(tampered), secretBytes, now)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Expected token verification to fail for tampered token at index %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,23 +5,41 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type cliIntent uint8
|
type cliIntent uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
cliIntentServe cliIntent = iota
|
cliIntentVersionPrint cliIntent = iota
|
||||||
cliIntentConfigValidate = iota
|
cliIntentServe
|
||||||
cliIntentConfigPrint = iota
|
cliIntentConfigValidate
|
||||||
cliIntentDiagnose = iota
|
cliIntentConfigPrint
|
||||||
|
cliIntentDiagnose
|
||||||
|
cliIntentSensorsPrint
|
||||||
|
cliIntentMountpointInfo
|
||||||
|
cliIntentSecretMake
|
||||||
|
cliIntentPasswordHash
|
||||||
)
|
)
|
||||||
|
|
||||||
type cliOptions struct {
|
type cliOptions struct {
|
||||||
intent cliIntent
|
intent cliIntent
|
||||||
configPath string
|
configPath string
|
||||||
|
args []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseCliOptions() (*cliOptions, error) {
|
func parseCliOptions() (*cliOptions, error) {
|
||||||
|
var args []string
|
||||||
|
|
||||||
|
args = os.Args[1:]
|
||||||
|
if len(args) == 1 && (args[0] == "--version" || args[0] == "-v" || args[0] == "version") {
|
||||||
|
return &cliOptions{
|
||||||
|
intent: cliIntentVersionPrint,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
flags := flag.NewFlagSet("", flag.ExitOnError)
|
flags := flag.NewFlagSet("", flag.ExitOnError)
|
||||||
flags.Usage = func() {
|
flags.Usage = func() {
|
||||||
fmt.Println("Usage: glance [options] command")
|
fmt.Println("Usage: glance [options] command")
|
||||||
@@ -30,10 +48,15 @@ func parseCliOptions() (*cliOptions, error) {
|
|||||||
flags.PrintDefaults()
|
flags.PrintDefaults()
|
||||||
|
|
||||||
fmt.Println("\nCommands:")
|
fmt.Println("\nCommands:")
|
||||||
fmt.Println(" config:validate Validate the config file")
|
fmt.Println(" config:validate Validate the config file")
|
||||||
fmt.Println(" config:print Print the parsed config file with embedded includes")
|
fmt.Println(" config:print Print the parsed config file with embedded includes")
|
||||||
fmt.Println(" diagnose Run diagnostic checks")
|
fmt.Println(" password:hash <pwd> Hash a password")
|
||||||
|
fmt.Println(" secret:make Generate a random secret key")
|
||||||
|
fmt.Println(" sensors:print List all sensors")
|
||||||
|
fmt.Println(" mountpoint:info Print information about a given mountpoint path")
|
||||||
|
fmt.Println(" diagnose Run diagnostic checks")
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := flags.String("config", "glance.yml", "Set config path")
|
configPath := flags.String("config", "glance.yml", "Set config path")
|
||||||
err := flags.Parse(os.Args[1:])
|
err := flags.Parse(os.Args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -41,7 +64,7 @@ func parseCliOptions() (*cliOptions, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var intent cliIntent
|
var intent cliIntent
|
||||||
var args = flags.Args()
|
args = flags.Args()
|
||||||
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
|
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
|
||||||
|
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
@@ -51,8 +74,24 @@ func parseCliOptions() (*cliOptions, error) {
|
|||||||
intent = cliIntentConfigValidate
|
intent = cliIntentConfigValidate
|
||||||
} else if args[0] == "config:print" {
|
} else if args[0] == "config:print" {
|
||||||
intent = cliIntentConfigPrint
|
intent = cliIntentConfigPrint
|
||||||
|
} else if args[0] == "sensors:print" {
|
||||||
|
intent = cliIntentSensorsPrint
|
||||||
} else if args[0] == "diagnose" {
|
} else if args[0] == "diagnose" {
|
||||||
intent = cliIntentDiagnose
|
intent = cliIntentDiagnose
|
||||||
|
} else if args[0] == "secret:make" {
|
||||||
|
intent = cliIntentSecretMake
|
||||||
|
} else {
|
||||||
|
return nil, unknownCommandErr
|
||||||
|
}
|
||||||
|
} else if len(args) == 2 {
|
||||||
|
if args[0] == "password:hash" {
|
||||||
|
intent = cliIntentPasswordHash
|
||||||
|
} else {
|
||||||
|
return nil, unknownCommandErr
|
||||||
|
}
|
||||||
|
} else if len(args) == 2 {
|
||||||
|
if args[0] == "mountpoint:info" {
|
||||||
|
intent = cliIntentMountpointInfo
|
||||||
} else {
|
} else {
|
||||||
return nil, unknownCommandErr
|
return nil, unknownCommandErr
|
||||||
}
|
}
|
||||||
@@ -63,5 +102,54 @@ func parseCliOptions() (*cliOptions, error) {
|
|||||||
return &cliOptions{
|
return &cliOptions{
|
||||||
intent: intent,
|
intent: intent,
|
||||||
configPath: *configPath,
|
configPath: *configPath,
|
||||||
|
args: args,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cliSensorsPrint() int {
|
||||||
|
tempSensors, err := sensors.SensorsTemperatures()
|
||||||
|
if err != nil {
|
||||||
|
if warns, ok := err.(*sensors.Warnings); ok {
|
||||||
|
fmt.Printf("Could not retrieve information for some sensors (%v):\n", err)
|
||||||
|
for _, w := range warns.List {
|
||||||
|
fmt.Printf(" - %v\n", w)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Failed to retrieve sensor information: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tempSensors) == 0 {
|
||||||
|
fmt.Println("No sensors found")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Sensors found:")
|
||||||
|
for _, sensor := range tempSensors {
|
||||||
|
fmt.Printf(" %s: %.1f°C\n", sensor.SensorKey, sensor.Temperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func cliMountpointInfo(requestedPath string) int {
|
||||||
|
usage, err := disk.Usage(requestedPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to retrieve info for path %s: %v\n", requestedPath, err)
|
||||||
|
if warns, ok := err.(*disk.Warnings); ok {
|
||||||
|
for _, w := range warns.List {
|
||||||
|
fmt.Printf(" - %v\n", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Path:", usage.Path)
|
||||||
|
fmt.Println("FS type:", ternary(usage.Fstype == "", "unknown", usage.Fstype))
|
||||||
|
fmt.Printf("Used percent: %.1f%%\n", usage.UsedPercent)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package glance
|
|||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -13,7 +14,7 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
|
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?([\d\.]+)(?: |,)+([\d\.]+)%?(?: |,)+([\d\.]+)%?\)?$`)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
hslHueMax = 360
|
hslHueMax = 360
|
||||||
@@ -22,13 +23,27 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type hslColorField struct {
|
type hslColorField struct {
|
||||||
Hue uint16
|
H float64
|
||||||
Saturation uint8
|
S float64
|
||||||
Lightness uint8
|
L float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *hslColorField) String() string {
|
func (c *hslColorField) String() string {
|
||||||
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
|
return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", c.H, c.S, c.L)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *hslColorField) ToHex() string {
|
||||||
|
return hslToHex(c.H, c.S, c.L)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c1 *hslColorField) SameAs(c2 *hslColorField) bool {
|
||||||
|
if c1 == nil && c2 == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if c1 == nil || c2 == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return c1.H == c2.H && c1.S == c2.S && c1.L == c2.L
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||||
@@ -44,7 +59,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return fmt.Errorf("invalid HSL color format: %s", value)
|
return fmt.Errorf("invalid HSL color format: %s", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
hue, err := strconv.ParseUint(matches[1], 10, 16)
|
hue, err := strconv.ParseFloat(matches[1], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -53,7 +68,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
|
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
saturation, err := strconv.ParseUint(matches[2], 10, 8)
|
saturation, err := strconv.ParseFloat(matches[2], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -62,7 +77,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
|
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
lightness, err := strconv.ParseUint(matches[3], 10, 8)
|
lightness, err := strconv.ParseFloat(matches[3], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -71,9 +86,9 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
|
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Hue = uint16(hue)
|
c.H = hue
|
||||||
c.Saturation = uint8(saturation)
|
c.S = saturation
|
||||||
c.Lightness = uint8(lightness)
|
c.L = lightness
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -115,7 +130,7 @@ func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type customIconField struct {
|
type customIconField struct {
|
||||||
URL string
|
URL template.URL
|
||||||
IsFlatIcon bool
|
IsFlatIcon bool
|
||||||
// TODO: along with whether the icon is flat, we also need to know
|
// 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
|
// whether the icon is black or white by default in order to properly
|
||||||
@@ -123,17 +138,23 @@ type customIconField struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newCustomIconField(value string) customIconField {
|
func newCustomIconField(value string) customIconField {
|
||||||
|
const autoInvertPrefix = "auto-invert "
|
||||||
field := customIconField{}
|
field := customIconField{}
|
||||||
|
|
||||||
prefix, icon, found := strings.Cut(value, ":")
|
prefix, icon, found := strings.Cut(value, ":")
|
||||||
if !found {
|
if !found {
|
||||||
field.URL = value
|
if strings.HasPrefix(value, autoInvertPrefix) {
|
||||||
|
field.IsFlatIcon = true
|
||||||
|
value = strings.TrimPrefix(value, autoInvertPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
field.URL = template.URL(value)
|
||||||
return field
|
return field
|
||||||
}
|
}
|
||||||
|
|
||||||
switch prefix {
|
switch prefix {
|
||||||
case "si":
|
case "si":
|
||||||
field.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
|
field.URL = template.URL("https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg")
|
||||||
field.IsFlatIcon = true
|
field.IsFlatIcon = true
|
||||||
case "di", "sh":
|
case "di", "sh":
|
||||||
// syntax: di:<icon_name>[.svg|.png]
|
// syntax: di:<icon_name>[.svg|.png]
|
||||||
@@ -152,12 +173,12 @@ func newCustomIconField(value string) customIconField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if prefix == "di" {
|
if prefix == "di" {
|
||||||
field.URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext
|
field.URL = template.URL("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext)
|
||||||
} else {
|
} else {
|
||||||
field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext
|
field.URL = template.URL("https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
field.URL = value
|
field.URL = template.URL(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return field
|
return field
|
||||||
@@ -219,3 +240,58 @@ func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type queryParametersField map[string][]string
|
||||||
|
|
||||||
|
func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
var decoded map[string]any
|
||||||
|
|
||||||
|
if err := node.Decode(&decoded); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*q = make(queryParametersField)
|
||||||
|
|
||||||
|
// TODO: refactor the duplication in the switch cases if any more types get added
|
||||||
|
for key, value := range decoded {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
(*q)[key] = []string{v}
|
||||||
|
case int, int8, int16, int32, int64, float32, float64:
|
||||||
|
(*q)[key] = []string{fmt.Sprintf("%v", v)}
|
||||||
|
case bool:
|
||||||
|
(*q)[key] = []string{fmt.Sprintf("%t", v)}
|
||||||
|
case []string:
|
||||||
|
(*q)[key] = append((*q)[key], v...)
|
||||||
|
case []any:
|
||||||
|
for _, item := range v {
|
||||||
|
switch item := item.(type) {
|
||||||
|
case string:
|
||||||
|
(*q)[key] = append((*q)[key], item)
|
||||||
|
case int, int8, int16, int32, int64, float32, float64:
|
||||||
|
(*q)[key] = append((*q)[key], fmt.Sprintf("%v", item))
|
||||||
|
case bool:
|
||||||
|
(*q)[key] = append((*q)[key], fmt.Sprintf("%t", item))
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid query parameter value type: %T", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid query parameter value type: %T", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *queryParametersField) toQueryString() string {
|
||||||
|
query := url.Values{}
|
||||||
|
|
||||||
|
for key, values := range *q {
|
||||||
|
for _, value := range values {
|
||||||
|
query.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package glance
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"iter"
|
||||||
"log"
|
"log"
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
@@ -17,50 +19,71 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT = 20
|
||||||
|
|
||||||
|
const (
|
||||||
|
configVarTypeEnv = "env"
|
||||||
|
configVarTypeSecret = "secret"
|
||||||
|
configVarTypeFileFromEnv = "readFileFromEnv"
|
||||||
|
)
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
Server struct {
|
Server struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port uint16 `yaml:"port"`
|
Port uint16 `yaml:"port"`
|
||||||
AssetsPath string `yaml:"assets-path"`
|
Proxied bool `yaml:"proxied"`
|
||||||
BaseURL string `yaml:"base-url"`
|
AssetsPath string `yaml:"assets-path"`
|
||||||
StartedAt time.Time `yaml:"-"` // used in custom css file
|
BaseURL string `yaml:"base-url"`
|
||||||
} `yaml:"server"`
|
} `yaml:"server"`
|
||||||
|
|
||||||
|
Auth struct {
|
||||||
|
SecretKey string `yaml:"secret-key"`
|
||||||
|
Users map[string]*user `yaml:"users"`
|
||||||
|
} `yaml:"auth"`
|
||||||
|
|
||||||
Document struct {
|
Document struct {
|
||||||
Head template.HTML `yaml:"head"`
|
Head template.HTML `yaml:"head"`
|
||||||
} `yaml:"document"`
|
} `yaml:"document"`
|
||||||
|
|
||||||
Theme struct {
|
Theme struct {
|
||||||
BackgroundColor *hslColorField `yaml:"background-color"`
|
themeProperties `yaml:",inline"`
|
||||||
PrimaryColor *hslColorField `yaml:"primary-color"`
|
CustomCSSFile string `yaml:"custom-css-file"`
|
||||||
PositiveColor *hslColorField `yaml:"positive-color"`
|
|
||||||
NegativeColor *hslColorField `yaml:"negative-color"`
|
DisablePicker bool `yaml:"disable-picker"`
|
||||||
Light bool `yaml:"light"`
|
Presets orderedYAMLMap[string, *themeProperties] `yaml:"presets"`
|
||||||
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
|
||||||
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
|
||||||
CustomCSSFile string `yaml:"custom-css-file"`
|
|
||||||
} `yaml:"theme"`
|
} `yaml:"theme"`
|
||||||
|
|
||||||
Branding struct {
|
Branding struct {
|
||||||
HideFooter bool `yaml:"hide-footer"`
|
HideFooter bool `yaml:"hide-footer"`
|
||||||
CustomFooter template.HTML `yaml:"custom-footer"`
|
CustomFooter template.HTML `yaml:"custom-footer"`
|
||||||
LogoText string `yaml:"logo-text"`
|
LogoText string `yaml:"logo-text"`
|
||||||
LogoURL string `yaml:"logo-url"`
|
LogoURL string `yaml:"logo-url"`
|
||||||
FaviconURL string `yaml:"favicon-url"`
|
FaviconURL string `yaml:"favicon-url"`
|
||||||
|
FaviconType string `yaml:"-"`
|
||||||
|
AppName string `yaml:"app-name"`
|
||||||
|
AppIconURL string `yaml:"app-icon-url"`
|
||||||
|
AppBackgroundColor string `yaml:"app-background-color"`
|
||||||
} `yaml:"branding"`
|
} `yaml:"branding"`
|
||||||
|
|
||||||
Pages []page `yaml:"pages"`
|
Pages []page `yaml:"pages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type user struct {
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
PasswordHashString string `yaml:"password-hash"`
|
||||||
|
PasswordHash []byte `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
type page struct {
|
type page struct {
|
||||||
Title string `yaml:"name"`
|
Title string `yaml:"name"`
|
||||||
Slug string `yaml:"slug"`
|
Slug string `yaml:"slug"`
|
||||||
Width string `yaml:"width"`
|
Width string `yaml:"width"`
|
||||||
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
DesktopNavigationWidth string `yaml:"desktop-navigation-width"`
|
||||||
ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"`
|
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
||||||
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
||||||
CenterVertically bool `yaml:"center-vertically"`
|
CenterVertically bool `yaml:"center-vertically"`
|
||||||
Columns []struct {
|
HeadWidgets widgets `yaml:"head-widgets"`
|
||||||
|
Columns []struct {
|
||||||
Size string `yaml:"size"`
|
Size string `yaml:"size"`
|
||||||
Widgets widgets `yaml:"widgets"`
|
Widgets widgets `yaml:"widgets"`
|
||||||
} `yaml:"columns"`
|
} `yaml:"columns"`
|
||||||
@@ -69,7 +92,7 @@ type page struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newConfigFromYAML(contents []byte) (*config, error) {
|
func newConfigFromYAML(contents []byte) (*config, error) {
|
||||||
contents, err := parseConfigEnvVariables(contents)
|
contents, err := parseConfigVariables(contents)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -87,6 +110,12 @@ func newConfigFromYAML(contents []byte) (*config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for p := range config.Pages {
|
for p := range config.Pages {
|
||||||
|
for w := range config.Pages[p].HeadWidgets {
|
||||||
|
if err := config.Pages[p].HeadWidgets[w].initialize(); err != nil {
|
||||||
|
return nil, formatWidgetInitError(err, config.Pages[p].HeadWidgets[w])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for c := range config.Pages[p].Columns {
|
for c := range config.Pages[p].Columns {
|
||||||
for w := range config.Pages[p].Columns[c].Widgets {
|
for w := range config.Pages[p].Columns[c].Widgets {
|
||||||
if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
|
if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
|
||||||
@@ -99,23 +128,33 @@ func newConfigFromYAML(contents []byte) (*config, error) {
|
|||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: change the pattern so that it doesn't match commented out lines
|
var envVariableNamePattern = regexp.MustCompile(`^[A-Z0-9_]+$`)
|
||||||
var configEnvVariablePattern = regexp.MustCompile(`(^|.)\$\{([A-Z0-9_]+)\}`)
|
var configVariablePattern = regexp.MustCompile(`(^|.)\$\{(?:([a-zA-Z]+):)?([a-zA-Z0-9_-]+)\}`)
|
||||||
|
|
||||||
func parseConfigEnvVariables(contents []byte) ([]byte, error) {
|
// Parses variables defined in the config such as:
|
||||||
|
// ${API_KEY} - gets replaced with the value of the API_KEY environment variable
|
||||||
|
// \${API_KEY} - escaped, gets used as is without the \ in the config
|
||||||
|
// ${secret:api_key} - value gets loaded from /run/secrets/api_key
|
||||||
|
// ${readFileFromEnv:PATH_TO_SECRET} - value gets loaded from the file path specified in the environment variable PATH_TO_SECRET
|
||||||
|
//
|
||||||
|
// TODO: don't match against commented out sections, not sure exactly how since
|
||||||
|
// variables can be placed anywhere and used to modify the YAML structure itself
|
||||||
|
func parseConfigVariables(contents []byte) ([]byte, error) {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
replaced := configEnvVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
|
replaced := configVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := configEnvVariablePattern.FindSubmatch(match)
|
groups := configVariablePattern.FindSubmatch(match)
|
||||||
if len(groups) != 3 {
|
if len(groups) != 4 {
|
||||||
|
// we can't handle this match, this shouldn't happen unless the number of groups
|
||||||
|
// in the regex has been changed without updating the below code
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix, key := string(groups[1]), string(groups[2])
|
prefix := string(groups[1])
|
||||||
if prefix == `\` {
|
if prefix == `\` {
|
||||||
if len(match) >= 2 {
|
if len(match) >= 2 {
|
||||||
return match[1:]
|
return match[1:]
|
||||||
@@ -124,13 +163,20 @@ func parseConfigEnvVariables(contents []byte) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
value, found := os.LookupEnv(key)
|
typeAsString, variableName := string(groups[2]), string(groups[3])
|
||||||
if !found {
|
variableType := ternary(typeAsString == "", configVarTypeEnv, typeAsString)
|
||||||
err = fmt.Errorf("environment variable %s not found", key)
|
|
||||||
|
parsedValue, returnOriginal, localErr := parseConfigVariableOfType(variableType, variableName)
|
||||||
|
if localErr != nil {
|
||||||
|
err = fmt.Errorf("parsing variable: %v", localErr)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return []byte(prefix + value)
|
if returnOriginal {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(prefix + parsedValue)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,33 +186,90 @@ func parseConfigEnvVariables(contents []byte) ([]byte, error) {
|
|||||||
return replaced, nil
|
return replaced, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the bool return value is true, it indicates that the caller should use the original value
|
||||||
|
func parseConfigVariableOfType(variableType, variableName string) (string, bool, error) {
|
||||||
|
switch variableType {
|
||||||
|
case configVarTypeEnv:
|
||||||
|
if !envVariableNamePattern.MatchString(variableName) {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
v, found := os.LookupEnv(variableName)
|
||||||
|
if !found {
|
||||||
|
return "", false, fmt.Errorf("environment variable %s not found", variableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v, false, nil
|
||||||
|
case configVarTypeSecret:
|
||||||
|
secretPath := filepath.Join("/run/secrets", variableName)
|
||||||
|
secret, err := os.ReadFile(secretPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("reading secret file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(secret)), false, nil
|
||||||
|
case configVarTypeFileFromEnv:
|
||||||
|
if !envVariableNamePattern.MatchString(variableName) {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath, found := os.LookupEnv(variableName)
|
||||||
|
if !found {
|
||||||
|
return "", false, fmt.Errorf("readFileFromEnv: environment variable %s not found", variableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filepath.IsAbs(filePath) {
|
||||||
|
return "", false, fmt.Errorf("readFileFromEnv: file path %s is not absolute", filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileContents, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("readFileFromEnv: reading file from %s: %v", variableName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(fileContents)), false, nil
|
||||||
|
default:
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func formatWidgetInitError(err error, w widget) error {
|
func formatWidgetInitError(err error, w widget) error {
|
||||||
return fmt.Errorf("%s widget: %v", w.GetType(), err)
|
return fmt.Errorf("%s widget: %v", w.GetType(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
|
var configIncludePattern = regexp.MustCompile(`(?m)^([ \t]*)(?:-[ \t]*)?(?:!|\$)include:[ \t]*(.+)$`)
|
||||||
|
|
||||||
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
|
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
|
||||||
|
return recursiveParseYAMLIncludes(mainFilePath, nil, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recursiveParseYAMLIncludes(mainFilePath string, includes map[string]struct{}, depth int) ([]byte, map[string]struct{}, error) {
|
||||||
|
if depth > CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT {
|
||||||
|
return nil, nil, fmt.Errorf("recursion depth limit of %d reached", CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
mainFileContents, err := os.ReadFile(mainFilePath)
|
mainFileContents, err := os.ReadFile(mainFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("reading main YAML file: %w", err)
|
return nil, nil, fmt.Errorf("reading %s: %w", mainFilePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err)
|
return nil, nil, fmt.Errorf("getting absolute path of %s: %w", mainFilePath, err)
|
||||||
}
|
}
|
||||||
mainFileDir := filepath.Dir(mainFileAbsPath)
|
mainFileDir := filepath.Dir(mainFileAbsPath)
|
||||||
|
|
||||||
includes := make(map[string]struct{})
|
if includes == nil {
|
||||||
|
includes = make(map[string]struct{})
|
||||||
|
}
|
||||||
var includesLastErr error
|
var includesLastErr error
|
||||||
|
|
||||||
mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
mainFileContents = configIncludePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
||||||
if includesLastErr != nil {
|
if includesLastErr != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := includePattern.FindSubmatch(match)
|
matches := configIncludePattern.FindSubmatch(match)
|
||||||
if len(matches) != 3 {
|
if len(matches) != 3 {
|
||||||
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
|
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
|
||||||
return nil
|
return nil
|
||||||
@@ -181,13 +284,14 @@ func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error)
|
|||||||
var fileContents []byte
|
var fileContents []byte
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
fileContents, err = os.ReadFile(includeFilePath)
|
includes[includeFilePath] = struct{}{}
|
||||||
|
|
||||||
|
fileContents, includes, err = recursiveParseYAMLIncludes(includeFilePath, includes, depth+1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err)
|
includesLastErr = err
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
includes[includeFilePath] = struct{}{}
|
|
||||||
return []byte(prefixStringLines(indent, string(fileContents)))
|
return []byte(prefixStringLines(indent, string(fileContents)))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -308,7 +412,7 @@ func configFilesWatcher(
|
|||||||
|
|
||||||
// wait for file to maybe get created again
|
// wait for file to maybe get created again
|
||||||
// see https://github.com/glanceapp/glance/pull/358
|
// see https://github.com/glanceapp/glance/pull/358
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
if _, err := os.Stat(event.Name); err == nil {
|
if _, err := os.Stat(event.Name); err == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -340,11 +444,39 @@ func configFilesWatcher(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Refactor, we currently validate in two different places, this being
|
||||||
|
// one of them, which doesn't modify the data and only checks for logical errors
|
||||||
|
// and then again when creating the application which does modify the data and do
|
||||||
|
// further validation. Would be better if validation was done in a single place.
|
||||||
func isConfigStateValid(config *config) error {
|
func isConfigStateValid(config *config) error {
|
||||||
if len(config.Pages) == 0 {
|
if len(config.Pages) == 0 {
|
||||||
return fmt.Errorf("no pages configured")
|
return fmt.Errorf("no pages configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(config.Auth.Users) > 0 && config.Auth.SecretKey == "" {
|
||||||
|
return fmt.Errorf("secret-key must be set when users are configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
for username := range config.Auth.Users {
|
||||||
|
if username == "" {
|
||||||
|
return fmt.Errorf("user has no name")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(username) < 3 {
|
||||||
|
return errors.New("usernames must be at least 3 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := config.Auth.Users[username]
|
||||||
|
|
||||||
|
if user.Password == "" {
|
||||||
|
if user.PasswordHashString == "" {
|
||||||
|
return fmt.Errorf("user %s must have a password or a password-hash set", username)
|
||||||
|
}
|
||||||
|
} else if len(user.Password) < 6 {
|
||||||
|
return fmt.Errorf("the password for %s must be at least 6 characters", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if config.Server.AssetsPath != "" {
|
if config.Server.AssetsPath != "" {
|
||||||
if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
|
if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
|
||||||
return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
|
return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
|
||||||
@@ -352,36 +484,46 @@ func isConfigStateValid(config *config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i := range config.Pages {
|
for i := range config.Pages {
|
||||||
if config.Pages[i].Title == "" {
|
page := &config.Pages[i]
|
||||||
|
|
||||||
|
if page.Title == "" {
|
||||||
return fmt.Errorf("page %d has no name", i+1)
|
return fmt.Errorf("page %d has no name", i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
|
if page.Width != "" && (page.Width != "wide" && page.Width != "slim" && page.Width != "default") {
|
||||||
return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
|
return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(config.Pages[i].Columns) == 0 {
|
if page.DesktopNavigationWidth != "" {
|
||||||
|
if page.DesktopNavigationWidth != "wide" && page.DesktopNavigationWidth != "slim" && page.DesktopNavigationWidth != "default" {
|
||||||
|
return fmt.Errorf("page %d: desktop-navigation-width can only be either wide or slim", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(page.Columns) == 0 {
|
||||||
return fmt.Errorf("page %d has no columns", i+1)
|
return fmt.Errorf("page %d has no columns", i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Pages[i].Width == "slim" {
|
if page.Width == "slim" {
|
||||||
if len(config.Pages[i].Columns) > 2 {
|
if len(page.Columns) > 2 {
|
||||||
return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
|
return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if len(config.Pages[i].Columns) > 3 {
|
if len(page.Columns) > 3 {
|
||||||
return fmt.Errorf("page %d has more than 3 columns", i+1)
|
return fmt.Errorf("page %d has more than 3 columns", i+1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
columnSizesCount := make(map[string]int)
|
columnSizesCount := make(map[string]int)
|
||||||
|
|
||||||
for j := range config.Pages[i].Columns {
|
for j := range page.Columns {
|
||||||
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" {
|
column := &page.Columns[j]
|
||||||
|
|
||||||
|
if column.Size != "small" && column.Size != "full" {
|
||||||
return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
|
return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
columnSizesCount[config.Pages[i].Columns[j].Size]++
|
columnSizesCount[page.Columns[j].Size]++
|
||||||
}
|
}
|
||||||
|
|
||||||
full := columnSizesCount["full"]
|
full := columnSizesCount["full"]
|
||||||
@@ -393,3 +535,103 @@ func isConfigStateValid(config *config) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read-only way to store ordered maps from a YAML structure
|
||||||
|
type orderedYAMLMap[K comparable, V any] struct {
|
||||||
|
keys []K
|
||||||
|
data map[K]V
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOrderedYAMLMap[K comparable, V any](keys []K, values []V) (*orderedYAMLMap[K, V], error) {
|
||||||
|
if len(keys) != len(values) {
|
||||||
|
return nil, fmt.Errorf("keys and values must have the same length")
|
||||||
|
}
|
||||||
|
|
||||||
|
om := &orderedYAMLMap[K, V]{
|
||||||
|
keys: make([]K, len(keys)),
|
||||||
|
data: make(map[K]V, len(keys)),
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(om.keys, keys)
|
||||||
|
|
||||||
|
for i := range keys {
|
||||||
|
om.data[keys[i]] = values[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return om, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (om *orderedYAMLMap[K, V]) Items() iter.Seq2[K, V] {
|
||||||
|
return func(yield func(K, V) bool) {
|
||||||
|
for _, key := range om.keys {
|
||||||
|
value, ok := om.data[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !yield(key, value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (om *orderedYAMLMap[K, V]) Get(key K) (V, bool) {
|
||||||
|
value, ok := om.data[key]
|
||||||
|
return value, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *orderedYAMLMap[K, V]) Merge(other *orderedYAMLMap[K, V]) *orderedYAMLMap[K, V] {
|
||||||
|
merged := &orderedYAMLMap[K, V]{
|
||||||
|
keys: make([]K, 0, len(self.keys)+len(other.keys)),
|
||||||
|
data: make(map[K]V, len(self.data)+len(other.data)),
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.keys = append(merged.keys, self.keys...)
|
||||||
|
maps.Copy(merged.data, self.data)
|
||||||
|
|
||||||
|
for _, key := range other.keys {
|
||||||
|
if _, exists := self.data[key]; !exists {
|
||||||
|
merged.keys = append(merged.keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maps.Copy(merged.data, other.data)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func (om *orderedYAMLMap[K, V]) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
if node.Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("orderedMap: expected mapping node, got %d", node.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(node.Content)%2 != 0 {
|
||||||
|
return fmt.Errorf("orderedMap: expected even number of content items, got %d", len(node.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
om.keys = make([]K, len(node.Content)/2)
|
||||||
|
om.data = make(map[K]V, len(node.Content)/2)
|
||||||
|
|
||||||
|
for i := 0; i < len(node.Content); i += 2 {
|
||||||
|
keyNode := node.Content[i]
|
||||||
|
valueNode := node.Content[i+1]
|
||||||
|
|
||||||
|
var key K
|
||||||
|
if err := keyNode.Decode(&key); err != nil {
|
||||||
|
return fmt.Errorf("orderedMap: decoding key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := om.data[key]; ok {
|
||||||
|
return fmt.Errorf("orderedMap: duplicate key %v", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
var value V
|
||||||
|
if err := valueNode.Decode(&value); err != nil {
|
||||||
|
return fmt.Errorf("orderedMap: decoding value: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
(*om).keys[i/2] = key
|
||||||
|
(*om).data[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const httpTestRequestTimeout = 10 * time.Second
|
const httpTestRequestTimeout = 15 * time.Second
|
||||||
|
|
||||||
var diagnosticSteps = []diagnosticStep{
|
var diagnosticSteps = []diagnosticStep{
|
||||||
{
|
{
|
||||||
@@ -75,13 +75,17 @@ 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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "fetch data from Yahoo finance API",
|
name: "fetch data from Yahoo finance API",
|
||||||
fn: func() (string, error) {
|
fn: func() (string, error) {
|
||||||
return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200)
|
return testHttpRequestWithHeaders("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", map[string]string{
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
|
||||||
|
}, 200)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -103,7 +107,7 @@ func runDiagnostic() {
|
|||||||
fmt.Println("Glance version: " + buildVersion)
|
fmt.Println("Glance version: " + buildVersion)
|
||||||
fmt.Println("Go version: " + runtime.Version())
|
fmt.Println("Go version: " + runtime.Version())
|
||||||
fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
|
fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
|
||||||
fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no"))
|
fmt.Println("In Docker container: " + ternary(isRunningInsideDockerContainer(), "yes", "no"))
|
||||||
|
|
||||||
fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
|
fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
|
||||||
|
|
||||||
@@ -129,7 +133,7 @@ func runDiagnostic() {
|
|||||||
|
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
"%s %s %s| %dms\n",
|
"%s %s %s| %dms\n",
|
||||||
boolToString(step.err == nil, "✓ Can", "✗ Can't"),
|
ternary(step.err == nil, "✓ Can", "✗ Can't"),
|
||||||
step.name,
|
step.name,
|
||||||
extraInfo,
|
extraInfo,
|
||||||
step.elapsed.Milliseconds(),
|
step.elapsed.Milliseconds(),
|
||||||
@@ -163,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package glance
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +26,19 @@ var _templateFS embed.FS
|
|||||||
var staticFS, _ = fs.Sub(_staticFS, "static")
|
var staticFS, _ = fs.Sub(_staticFS, "static")
|
||||||
var templateFS, _ = fs.Sub(_templateFS, "templates")
|
var templateFS, _ = fs.Sub(_templateFS, "templates")
|
||||||
|
|
||||||
|
func readAllFromStaticFS(path string) ([]byte, error) {
|
||||||
|
// For some reason fs.FS only works with forward slashes, so in case we're
|
||||||
|
// running on Windows or pass paths with backslashes we need to replace them.
|
||||||
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
|
|
||||||
|
file, err := staticFS.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return io.ReadAll(file)
|
||||||
|
}
|
||||||
|
|
||||||
var staticFSHash = func() string {
|
var staticFSHash = func() string {
|
||||||
hash, err := computeFSHash(staticFS)
|
hash, err := computeFSHash(staticFS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -60,3 +79,73 @@ func computeFSHash(files fs.FS) (string, error) {
|
|||||||
|
|
||||||
return hex.EncodeToString(hash.Sum(nil))[:10], nil
|
return hex.EncodeToString(hash.Sum(nil))[:10], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`)
|
||||||
|
var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`)
|
||||||
|
|
||||||
|
// Yes, we bundle at runtime, give comptime pls
|
||||||
|
var bundledCSSContents = func() []byte {
|
||||||
|
const mainFilePath = "css/main.css"
|
||||||
|
|
||||||
|
var recursiveParseImports func(path string, depth int) ([]byte, error)
|
||||||
|
recursiveParseImports = func(path string, depth int) ([]byte, error) {
|
||||||
|
if depth > 20 {
|
||||||
|
return nil, errors.New("maximum import depth reached, is one of your imports circular?")
|
||||||
|
}
|
||||||
|
|
||||||
|
mainFileContents, err := readAllFromStaticFS(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize line endings, otherwise the \r's make the regex not match
|
||||||
|
mainFileContents = bytes.ReplaceAll(mainFileContents, []byte("\r\n"), []byte("\n"))
|
||||||
|
|
||||||
|
mainFileDir := filepath.Dir(path)
|
||||||
|
var importLastErr error
|
||||||
|
|
||||||
|
parsed := cssImportPattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
||||||
|
if importLastErr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := cssImportPattern.FindSubmatch(match)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
importLastErr = fmt.Errorf(
|
||||||
|
"import didn't return expected number of capture groups: %s, expected 2, got %d",
|
||||||
|
match, len(matches),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
importFilePath := filepath.Join(mainFileDir, string(matches[1]))
|
||||||
|
importContents, err := recursiveParseImports(importFilePath, depth+1)
|
||||||
|
if err != nil {
|
||||||
|
importLastErr = err
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return importContents
|
||||||
|
})
|
||||||
|
|
||||||
|
if importLastErr != nil {
|
||||||
|
return nil, importLastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
contents, err := recursiveParseImports(mainFilePath, 0)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("building CSS bundle: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// We could strip a bunch more unnecessary characters, but the biggest
|
||||||
|
// win comes from removing the whitespace at the beginning of lines
|
||||||
|
// since that's at least 4 bytes per property, which yielded a ~20% reduction.
|
||||||
|
contents = cssSingleLineCommentPattern.ReplaceAll(contents, nil)
|
||||||
|
contents = whitespaceAtBeginningOfLinePattern.ReplaceAll(contents, nil)
|
||||||
|
contents = bytes.ReplaceAll(contents, []byte("\n"), []byte(""))
|
||||||
|
|
||||||
|
return contents
|
||||||
|
}()
|
||||||
|
|||||||
@@ -3,50 +3,151 @@ package glance
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
pageTemplate = mustParseTemplate("page.html", "document.html")
|
pageTemplate = mustParseTemplate("page.html", "document.html", "footer.html")
|
||||||
pageContentTemplate = mustParseTemplate("page-content.html")
|
pageContentTemplate = mustParseTemplate("page-content.html")
|
||||||
pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
|
manifestTemplate = mustParseTemplate("manifest.json")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour
|
||||||
|
|
||||||
|
var reservedPageSlugs = []string{"login", "logout"}
|
||||||
|
|
||||||
type application struct {
|
type application struct {
|
||||||
Version string
|
Version string
|
||||||
Config config
|
CreatedAt time.Time
|
||||||
ParsedThemeStyle template.HTML
|
Config config
|
||||||
|
|
||||||
|
parsedManifest []byte
|
||||||
|
|
||||||
slugToPage map[string]*page
|
slugToPage map[string]*page
|
||||||
widgetByID map[uint64]widget
|
widgetByID map[uint64]widget
|
||||||
|
|
||||||
|
RequiresAuth bool
|
||||||
|
authSecretKey []byte
|
||||||
|
usernameHashToUsername map[string]string
|
||||||
|
authAttemptsMu sync.Mutex
|
||||||
|
failedAuthAttempts map[string]*failedAuthAttempt
|
||||||
}
|
}
|
||||||
|
|
||||||
func newApplication(config *config) (*application, error) {
|
func newApplication(c *config) (*application, error) {
|
||||||
app := &application{
|
app := &application{
|
||||||
Version: buildVersion,
|
Version: buildVersion,
|
||||||
Config: *config,
|
CreatedAt: time.Now(),
|
||||||
|
Config: *c,
|
||||||
slugToPage: make(map[string]*page),
|
slugToPage: make(map[string]*page),
|
||||||
widgetByID: make(map[uint64]widget),
|
widgetByID: make(map[uint64]widget),
|
||||||
}
|
}
|
||||||
|
config := &app.Config
|
||||||
|
|
||||||
|
//
|
||||||
|
// Init auth
|
||||||
|
//
|
||||||
|
|
||||||
|
if len(config.Auth.Users) > 0 {
|
||||||
|
secretBytes, err := base64.StdEncoding.DecodeString(config.Auth.SecretKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decoding secret-key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
return nil, fmt.Errorf("secret-key must be exactly %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.usernameHashToUsername = make(map[string]string)
|
||||||
|
app.failedAuthAttempts = make(map[string]*failedAuthAttempt)
|
||||||
|
app.RequiresAuth = true
|
||||||
|
|
||||||
|
for username := range config.Auth.Users {
|
||||||
|
user := config.Auth.Users[username]
|
||||||
|
usernameHash, err := computeUsernameHash(username, secretBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("computing username hash for user %s: %v", username, err)
|
||||||
|
}
|
||||||
|
app.usernameHashToUsername[string(usernameHash)] = username
|
||||||
|
|
||||||
|
if user.PasswordHashString != "" {
|
||||||
|
user.PasswordHash = []byte(user.PasswordHashString)
|
||||||
|
user.PasswordHashString = ""
|
||||||
|
} else {
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hashing password for user %s: %v", username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = ""
|
||||||
|
user.PasswordHash = hashedPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.authSecretKey = secretBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Init themes
|
||||||
|
//
|
||||||
|
|
||||||
|
if !config.Theme.DisablePicker {
|
||||||
|
themeKeys := make([]string, 0, 2)
|
||||||
|
themeProps := make([]*themeProperties, 0, 2)
|
||||||
|
|
||||||
|
defaultDarkTheme, ok := config.Theme.Presets.Get("default-dark")
|
||||||
|
if ok && !config.Theme.SameAs(defaultDarkTheme) || !config.Theme.SameAs(&themeProperties{}) {
|
||||||
|
themeKeys = append(themeKeys, "default-dark")
|
||||||
|
themeProps = append(themeProps, &themeProperties{})
|
||||||
|
}
|
||||||
|
|
||||||
|
themeKeys = append(themeKeys, "default-light")
|
||||||
|
themeProps = append(themeProps, &themeProperties{
|
||||||
|
Light: true,
|
||||||
|
BackgroundColor: &hslColorField{240, 13, 95},
|
||||||
|
PrimaryColor: &hslColorField{230, 100, 30},
|
||||||
|
NegativeColor: &hslColorField{0, 70, 50},
|
||||||
|
ContrastMultiplier: 1.3,
|
||||||
|
TextSaturationMultiplier: 0.5,
|
||||||
|
})
|
||||||
|
|
||||||
|
themePresets, err := newOrderedYAMLMap(themeKeys, themeProps)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating theme presets: %v", err)
|
||||||
|
}
|
||||||
|
config.Theme.Presets = *themePresets.Merge(&config.Theme.Presets)
|
||||||
|
|
||||||
|
for key, properties := range config.Theme.Presets.Items() {
|
||||||
|
properties.Key = key
|
||||||
|
if err := properties.init(); err != nil {
|
||||||
|
return nil, fmt.Errorf("initializing preset theme %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Theme.Key = "default"
|
||||||
|
if err := config.Theme.init(); err != nil {
|
||||||
|
return nil, fmt.Errorf("initializing default theme: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Init pages
|
||||||
|
//
|
||||||
|
|
||||||
app.slugToPage[""] = &config.Pages[0]
|
app.slugToPage[""] = &config.Pages[0]
|
||||||
|
|
||||||
providers := &widgetProviders{
|
providers := &widgetProviders{
|
||||||
assetResolver: app.AssetPath,
|
assetResolver: app.StaticAssetPath,
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing theme style: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for p := range config.Pages {
|
for p := range config.Pages {
|
||||||
@@ -57,8 +158,26 @@ func newApplication(config *config) (*application, error) {
|
|||||||
page.Slug = titleToSlug(page.Title)
|
page.Slug = titleToSlug(page.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if slices.Contains(reservedPageSlugs, page.Slug) {
|
||||||
|
return nil, fmt.Errorf("page slug \"%s\" is reserved", page.Slug)
|
||||||
|
}
|
||||||
|
|
||||||
app.slugToPage[page.Slug] = page
|
app.slugToPage[page.Slug] = page
|
||||||
|
|
||||||
|
if page.Width == "default" {
|
||||||
|
page.Width = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if page.DesktopNavigationWidth == "" && page.DesktopNavigationWidth != "default" {
|
||||||
|
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]
|
||||||
|
|
||||||
@@ -69,24 +188,44 @@ func newApplication(config *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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config = &app.Config
|
|
||||||
|
|
||||||
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
||||||
config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
||||||
|
config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL)
|
||||||
|
|
||||||
if config.Branding.FaviconURL == "" {
|
config.Branding.FaviconURL = ternary(
|
||||||
config.Branding.FaviconURL = app.AssetPath("favicon.png")
|
config.Branding.FaviconURL == "",
|
||||||
} else {
|
app.StaticAssetPath("favicon.svg"),
|
||||||
config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL)
|
app.resolveUserDefinedAssetPath(config.Branding.FaviconURL),
|
||||||
|
)
|
||||||
|
|
||||||
|
config.Branding.FaviconType = ternary(
|
||||||
|
strings.HasSuffix(config.Branding.FaviconURL, ".svg"),
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/png",
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.Branding.AppName == "" {
|
||||||
|
config.Branding.AppName = "Glance"
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL)
|
if config.Branding.AppIconURL == "" {
|
||||||
|
config.Branding.AppIconURL = app.StaticAssetPath("app-icon.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Branding.AppBackgroundColor == "" {
|
||||||
|
config.Branding.AppBackgroundColor = config.Theme.BackgroundColorAsHex
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := executeTemplateToString(manifestTemplate, templateData{App: app})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing manifest.json: %v", err)
|
||||||
|
}
|
||||||
|
app.parsedManifest = []byte(manifest)
|
||||||
|
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
@@ -97,6 +236,20 @@ func (p *page) updateOutdatedWidgets() {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
context := context.Background()
|
context := context.Background()
|
||||||
|
|
||||||
|
for w := range p.HeadWidgets {
|
||||||
|
widget := p.HeadWidgets[w]
|
||||||
|
|
||||||
|
if !widget.requiresUpdate(&now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
widget.update(context)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
for c := range p.Columns {
|
for c := range p.Columns {
|
||||||
for w := range p.Columns[c].Widgets {
|
for w := range p.Columns[c].Widgets {
|
||||||
widget := p.Columns[c].Widgets[w]
|
widget := p.Columns[c].Widgets[w]
|
||||||
@@ -116,7 +269,7 @@ func (p *page) updateOutdatedWidgets() {
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) transformUserDefinedAssetPath(path string) string {
|
func (a *application) resolveUserDefinedAssetPath(path string) string {
|
||||||
if strings.HasPrefix(path, "/assets/") {
|
if strings.HasPrefix(path, "/assets/") {
|
||||||
return a.Config.Server.BaseURL + path
|
return a.Config.Server.BaseURL + path
|
||||||
}
|
}
|
||||||
@@ -124,26 +277,51 @@ func (a *application) transformUserDefinedAssetPath(path string) string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
type pageTemplateData struct {
|
type templateRequestData struct {
|
||||||
App *application
|
Theme *themeProperties
|
||||||
Page *page
|
}
|
||||||
|
|
||||||
|
type templateData struct {
|
||||||
|
App *application
|
||||||
|
Page *page
|
||||||
|
Request templateRequestData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) populateTemplateRequestData(data *templateRequestData, r *http.Request) {
|
||||||
|
theme := &a.Config.Theme.themeProperties
|
||||||
|
|
||||||
|
if !a.Config.Theme.DisablePicker {
|
||||||
|
selectedTheme, err := r.Cookie("theme")
|
||||||
|
if err == nil {
|
||||||
|
preset, exists := a.Config.Theme.Presets.Get(selectedTheme.Value)
|
||||||
|
if exists {
|
||||||
|
theme = preset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Theme = theme
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
|
func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
page, exists := a.slugToPage[r.PathValue("page")]
|
page, exists := a.slugToPage[r.PathValue("page")]
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
a.handleNotFound(w, r)
|
a.handleNotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pageData := pageTemplateData{
|
if a.handleUnauthorizedResponse(w, r, redirectToLogin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateData{
|
||||||
Page: page,
|
Page: page,
|
||||||
App: a,
|
App: a,
|
||||||
}
|
}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
|
||||||
var responseBytes bytes.Buffer
|
var responseBytes bytes.Buffer
|
||||||
err := pageTemplate.Execute(&responseBytes, pageData)
|
err := pageTemplate.Execute(&responseBytes, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte(err.Error()))
|
w.Write([]byte(err.Error()))
|
||||||
@@ -155,13 +333,16 @@ func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) {
|
func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
page, exists := a.slugToPage[r.PathValue("page")]
|
page, exists := a.slugToPage[r.PathValue("page")]
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
a.handleNotFound(w, r)
|
a.handleNotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pageData := pageTemplateData{
|
if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pageData := templateData{
|
||||||
Page: page,
|
Page: page,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +366,35 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re
|
|||||||
w.Write(responseBytes.Bytes())
|
w.Write(responseBytes.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *application) addressOfRequest(r *http.Request) string {
|
||||||
|
remoteAddrWithoutPort := func() string {
|
||||||
|
for i := len(r.RemoteAddr) - 1; i >= 0; i-- {
|
||||||
|
if r.RemoteAddr[i] == ':' {
|
||||||
|
return r.RemoteAddr[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.Config.Server.Proxied {
|
||||||
|
return remoteAddrWithoutPort()
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should probably be configurable or look for multiple headers, not just this one
|
||||||
|
forwardedFor := r.Header.Get("X-Forwarded-For")
|
||||||
|
if forwardedFor == "" {
|
||||||
|
return remoteAddrWithoutPort()
|
||||||
|
}
|
||||||
|
|
||||||
|
ips := strings.Split(forwardedFor, ",")
|
||||||
|
if len(ips) == 0 || ips[0] == "" {
|
||||||
|
return remoteAddrWithoutPort()
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips[0]
|
||||||
|
}
|
||||||
|
|
||||||
func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
|
func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
|
||||||
// TODO: add proper not found page
|
// TODO: add proper not found page
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
@@ -192,47 +402,85 @@ func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
|
func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
widgetValue := r.PathValue("widget")
|
// TODO: this requires a rework of the widget update logic so that rather
|
||||||
|
// than locking the entire page we lock individual widgets
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
|
||||||
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
|
// widgetValue := r.PathValue("widget")
|
||||||
if err != nil {
|
|
||||||
a.handleNotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
widget, exists := a.widgetByID[widgetID]
|
// widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
|
||||||
|
// if err != nil {
|
||||||
|
// a.handleNotFound(w, r)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
if !exists {
|
// widget, exists := a.widgetByID[widgetID]
|
||||||
a.handleNotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
widget.handleRequest(w, r)
|
// if !exists {
|
||||||
|
// a.handleNotFound(w, r)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// widget.handleRequest(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) AssetPath(asset string) string {
|
func (a *application) StaticAssetPath(asset string) string {
|
||||||
return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
|
return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *application) VersionedAssetPath(asset string) string {
|
||||||
|
return a.Config.Server.BaseURL + asset +
|
||||||
|
"?v=" + strconv.FormatInt(a.CreatedAt.Unix(), 10)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *application) server() (func() error, func() error) {
|
func (a *application) server() (func() error, func() error) {
|
||||||
// TODO: add gzip support, static files must have their gzipped contents cached
|
|
||||||
// TODO: add HTTPS support
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("GET /{$}", a.handlePageRequest)
|
mux.HandleFunc("GET /{$}", a.handlePageRequest)
|
||||||
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)
|
||||||
|
|
||||||
|
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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if a.RequiresAuth {
|
||||||
|
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
||||||
|
mux.HandleFunc("GET /logout", a.handleLogoutRequest)
|
||||||
|
mux.HandleFunc("POST /api/authenticate", a.handleAuthenticationAttempt)
|
||||||
|
}
|
||||||
|
|
||||||
mux.Handle(
|
mux.Handle(
|
||||||
fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
|
fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
|
||||||
http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)),
|
http.StripPrefix(
|
||||||
|
"/static/"+staticFSHash,
|
||||||
|
fileServerWithCache(http.FS(staticFS), STATIC_ASSETS_CACHE_DURATION),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assetCacheControlValue := fmt.Sprintf(
|
||||||
|
"public, max-age=%d",
|
||||||
|
int(STATIC_ASSETS_CACHE_DURATION.Seconds()),
|
||||||
|
)
|
||||||
|
|
||||||
|
mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", staticFSHash), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Add("Cache-Control", assetCacheControlValue)
|
||||||
|
w.Header().Add("Content-Type", "text/css; charset=utf-8")
|
||||||
|
w.Write(bundledCSSContents)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Add("Cache-Control", assetCacheControlValue)
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
w.Write(a.parsedManifest)
|
||||||
|
})
|
||||||
|
|
||||||
var absAssetsPath string
|
var absAssetsPath string
|
||||||
if a.Config.Server.AssetsPath != "" {
|
if a.Config.Server.AssetsPath != "" {
|
||||||
absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
|
absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
|
||||||
@@ -246,7 +494,6 @@ func (a *application) server() (func() error, func() error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start := func() error {
|
start := func() error {
|
||||||
a.Config.Server.StartedAt = time.Now()
|
|
||||||
log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
|
log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
|
||||||
a.Config.Server.Host,
|
a.Config.Server.Host,
|
||||||
a.Config.Server.Port,
|
a.Config.Server.Port,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
var buildVersion = "dev"
|
var buildVersion = "dev"
|
||||||
@@ -18,6 +20,8 @@ func Main() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch options.intent {
|
switch options.intent {
|
||||||
|
case cliIntentVersionPrint:
|
||||||
|
fmt.Println(buildVersion)
|
||||||
case cliIntentServe:
|
case cliIntentServe:
|
||||||
// remove in v0.10.0
|
// remove in v0.10.0
|
||||||
if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
|
if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
|
||||||
@@ -47,14 +51,49 @@ func Main() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(string(contents))
|
fmt.Println(string(contents))
|
||||||
|
case cliIntentSensorsPrint:
|
||||||
|
return cliSensorsPrint()
|
||||||
|
case cliIntentMountpointInfo:
|
||||||
|
return cliMountpointInfo(options.args[1])
|
||||||
case cliIntentDiagnose:
|
case cliIntentDiagnose:
|
||||||
runDiagnostic()
|
runDiagnostic()
|
||||||
|
case cliIntentSecretMake:
|
||||||
|
key, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to make secret key: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(key)
|
||||||
|
case cliIntentPasswordHash:
|
||||||
|
password := options.args[1]
|
||||||
|
|
||||||
|
if password == "" {
|
||||||
|
fmt.Println("Password cannot be empty")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(password) < 6 {
|
||||||
|
fmt.Println("Password must be at least 6 characters long")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to hash password: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(string(hashedPassword))
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveApp(configPath string) error {
|
func serveApp(configPath string) error {
|
||||||
|
// TODO: refactor if this gets any more complex, the current implementation is
|
||||||
|
// difficult to reason about due to all of the callbacks and simultaneous operations,
|
||||||
|
// use a single goroutine and a channel to initiate synchronous changes to the server
|
||||||
exitChannel := make(chan struct{})
|
exitChannel := make(chan struct{})
|
||||||
hadValidConfigOnStartup := false
|
hadValidConfigOnStartup := false
|
||||||
var stopServer func() error
|
var stopServer func() error
|
||||||
@@ -73,16 +112,23 @@ func serveApp(configPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
} else if !hadValidConfigOnStartup {
|
|
||||||
hadValidConfigOnStartup = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := newApplication(config)
|
app, err := newApplication(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create application: %v", err)
|
log.Printf("Failed to create application: %v", err)
|
||||||
|
|
||||||
|
if !hadValidConfigOnStartup {
|
||||||
|
close(exitChannel)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !hadValidConfigOnStartup {
|
||||||
|
hadValidConfigOnStartup = true
|
||||||
|
}
|
||||||
|
|
||||||
if stopServer != nil {
|
if stopServer != nil {
|
||||||
if err := stopServer(); err != nil {
|
if err := stopServer(); err != nil {
|
||||||
log.Printf("Error while trying to stop server: %v", err)
|
log.Printf("Error while trying to stop server: %v", err)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
.forum-post-list-thumbnail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 6rem;
|
||||||
|
height: 4.1rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--color-separator);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forum-post-tags-container {
|
||||||
|
transform: translateY(-0.15rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container widget (max-width: 550px) {
|
||||||
|
.forum-post-autohide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
.login-bounds {
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
transition: border-color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input input {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 5.2rem;
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
margin-top: -0.1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input input[type="password"] {
|
||||||
|
letter-spacing: 0.3rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input input[type="password"]::placeholder {
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:hover {
|
||||||
|
border-color: var(--color-progress-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
transition-duration: .7s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
padding: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-text-subdue);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text-paragraph);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all .3s, margin-top 0s;
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:not(:disabled) {
|
||||||
|
box-shadow: 0 0 10px 1px var(--color-separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error-message:not(:empty) + .login-button {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:focus, .login-button:hover {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:disabled {
|
||||||
|
border-color: var(--color-separator);
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button svg {
|
||||||
|
width: 1.7rem;
|
||||||
|
height: 1.7rem;
|
||||||
|
transition: transform .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:not(:disabled):hover svg, .login-button:not(:disabled):focus svg {
|
||||||
|
transform: translateX(.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-entrance {
|
||||||
|
animation: fieldReveal 0.7s backwards;
|
||||||
|
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-entrance:nth-child(1) { animation-delay: .1s; }
|
||||||
|
.animate-entrance:nth-child(2) { animation-delay: .2s; }
|
||||||
|
.animate-entrance:nth-child(4) { animation-delay: .3s; }
|
||||||
|
|
||||||
|
@keyframes fieldReveal {
|
||||||
|
from {
|
||||||
|
opacity: 0.0001;
|
||||||
|
transform: translateY(4rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error-message {
|
||||||
|
color: var(--color-negative);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
padding: 1.3rem calc(var(--widget-content-horizontal-padding) + 1px);
|
||||||
|
position: relative;
|
||||||
|
margin-top: 2rem;
|
||||||
|
animation: errorMessageEntrance 0.4s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes errorMessageEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error-message:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error-message::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: var(--color-negative);
|
||||||
|
opacity: 0.05;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
animation-delay: .4s;
|
||||||
|
animation-duration: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-password-visibility {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-size: 10px;
|
||||||
|
|
||||||
|
--scheme: ;
|
||||||
|
--bgh: 240;
|
||||||
|
--bgs: 8%;
|
||||||
|
--bgl: 9%;
|
||||||
|
--bghs: var(--bgh), var(--bgs);
|
||||||
|
--cm: 1;
|
||||||
|
--tsm: 1;
|
||||||
|
|
||||||
|
--widget-gap: 23px;
|
||||||
|
--widget-content-vertical-padding: 15px;
|
||||||
|
--widget-content-horizontal-padding: 17px;
|
||||||
|
--widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
|
||||||
|
--content-bounds-padding: 15px;
|
||||||
|
--border-radius: 5px;
|
||||||
|
--mobile-navigation-height: 50px;
|
||||||
|
|
||||||
|
--color-primary: hsl(43, 50%, 70%);
|
||||||
|
--color-positive: var(--color-primary);
|
||||||
|
--color-negative: hsl(0, 70%, 70%);
|
||||||
|
--color-background: hsl(var(--bghs), var(--bgl));
|
||||||
|
--color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%);
|
||||||
|
--color-widget-background: hsl(var(--color-widget-background-hsl-values));
|
||||||
|
--color-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm))));
|
||||||
|
--color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
|
||||||
|
--color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
|
||||||
|
--color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
|
||||||
|
--color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
|
||||||
|
--color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
|
||||||
|
--color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 26% * var(--cm))));
|
||||||
|
--color-vertical-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 28% * var(--cm))));
|
||||||
|
--color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
|
||||||
|
|
||||||
|
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
|
||||||
|
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
|
||||||
|
--color-text-paragraph: hsl(var(--ths), calc(var(--scheme) var(--cm) * 73%));
|
||||||
|
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
|
||||||
|
--color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
|
||||||
|
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
|
||||||
|
|
||||||
|
--font-size-h1: 1.7rem;
|
||||||
|
--font-size-h2: 1.6rem;
|
||||||
|
--font-size-h3: 1.5rem;
|
||||||
|
--font-size-h4: 1.4rem;
|
||||||
|
--font-size-base: 1.3rem;
|
||||||
|
--font-size-h5: 1.2rem;
|
||||||
|
--font-size-h6: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Do not change the order of the below imports unless you know what you're doing */
|
||||||
|
|
||||||
|
@import "site.css";
|
||||||
|
@import "widgets.css";
|
||||||
|
@import "popover.css";
|
||||||
|
@import "utils.css";
|
||||||
|
@import "mobile.css";
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
@media (max-width: 1190px) {
|
||||||
|
.header-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column-small .size-title-dynamic {
|
||||||
|
font-size: var(--font-size-h3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column-small {
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column {
|
||||||
|
display: none;
|
||||||
|
animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-columns-transitioned .page-column {
|
||||||
|
animation-duration: .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes columnEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleX(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-offset {
|
||||||
|
height: var(--mobile-navigation-height);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
transform: translateY(calc(100% - var(--mobile-navigation-height)));
|
||||||
|
left: var(--content-bounds-padding);
|
||||||
|
right: var(--content-bounds-padding);
|
||||||
|
z-index: 11;
|
||||||
|
background-color: var(--color-widget-background);
|
||||||
|
border: 1px solid var(--color-widget-content-border);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
transition: transform .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-actions > * {
|
||||||
|
padding-block: 1.1rem;
|
||||||
|
padding-inline: var(--content-bounds-padding);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 50ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-actions > *:active {
|
||||||
|
background-color: var(--color-widget-background-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) .hamburger-icon {
|
||||||
|
--spacing: 7px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-page-links {
|
||||||
|
border-top: 1px solid var(--color-widget-content-border);
|
||||||
|
border-bottom: 1px solid var(--color-widget-content-border);
|
||||||
|
padding: 20px var(--content-bounds-padding);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-icons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(.mobile-navigation-input[value="0"]:checked) .page-columns > :nth-child(1),
|
||||||
|
body:has(.mobile-navigation-input[value="1"]:checked) .page-columns > :nth-child(2),
|
||||||
|
body:has(.mobile-navigation-input[value="2"]:checked) .page-columns > :nth-child(3) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-label {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 50px;
|
||||||
|
height: var(--mobile-navigation-height);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: var(--mobile-navigation-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-pill {
|
||||||
|
display: block;
|
||||||
|
background: var(--color-text-base);
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: width .3s, background-color .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-label:hover > .mobile-navigation-pill {
|
||||||
|
background-color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-label:hover {
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-input:checked + .mobile-navigation-pill {
|
||||||
|
background: var(--color-primary);
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-input, .mobile-navigation-page-links-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-icon {
|
||||||
|
--spacing: 4px;
|
||||||
|
width: 1em;
|
||||||
|
height: 1px;
|
||||||
|
background-color: currentColor;
|
||||||
|
transition: color .3s, box-shadow .3s;
|
||||||
|
box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button.container-expanded {
|
||||||
|
bottom: var(--mobile-navigation-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid + .expand-toggle-button.container-expanded {
|
||||||
|
/* hides content that peeks through the rounded borders of the mobile navigation */
|
||||||
|
box-shadow: 0 var(--border-radius) 0 0 var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-rain::before {
|
||||||
|
background-size: 7px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios .search-input {
|
||||||
|
/* so that iOS Safari does not zoom the page when the input is focused */
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1190px) and (display-mode: standalone) {
|
||||||
|
:root {
|
||||||
|
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios .body-content {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button.container-expanded {
|
||||||
|
bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation {
|
||||||
|
transform: translateY(calc(100% - var(--mobile-navigation-height) - var(--safe-area-inset-bottom)));
|
||||||
|
padding-bottom: var(--safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-icons {
|
||||||
|
padding-bottom: var(--safe-area-inset-bottom);
|
||||||
|
transition: padding-bottom .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-offset {
|
||||||
|
height: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
body {
|
||||||
|
padding-top: env(safe-area-inset-top, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 550px) {
|
||||||
|
:root {
|
||||||
|
font-size: 9.4px;
|
||||||
|
--widget-gap: 15px;
|
||||||
|
--widget-content-vertical-padding: 10px;
|
||||||
|
--widget-content-horizontal-padding: 10px;
|
||||||
|
--content-bounds-padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
|
||||||
|
.row-reverse-on-mobile {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-reachability-header {
|
||||||
|
display: block;
|
||||||
|
font-size: 3rem;
|
||||||
|
padding: 10vh 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-detailed-thumbnail > * {
|
||||||
|
height: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-detailed-description {
|
||||||
|
line-clamp: 3;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.popover-container, [data-popover-html] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-container {
|
||||||
|
--triangle-size: 10px;
|
||||||
|
--triangle-offset: 50%;
|
||||||
|
--triangle-margin: calc(var(--triangle-size) + 3px);
|
||||||
|
--entrance-y-offset: 8px;
|
||||||
|
--entrance-direction: calc(var(--entrance-y-offset) * -1);
|
||||||
|
|
||||||
|
z-index: 20;
|
||||||
|
position: absolute;
|
||||||
|
padding-top: var(--triangle-margin);
|
||||||
|
padding-inline: var(--content-bounds-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-container.position-above {
|
||||||
|
--entrance-direction: var(--entrance-y-offset);
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: var(--triangle-margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-frame {
|
||||||
|
--shadow-properties: 0 15px 20px -10px;
|
||||||
|
--shadow-color: hsla(var(--bghs), calc(var(--bgl) * 0.2), 0.5);
|
||||||
|
position: relative;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--color-popover-background);
|
||||||
|
border: 1px solid var(--color-popover-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
animation: popoverFrameEntrance 0.3s backwards cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
box-shadow: var(--shadow-properties) var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-frame::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: var(--triangle-size);
|
||||||
|
height: var(--triangle-size);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
background-color: var(--color-popover-background);
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-left: 1px solid var(--color-popover-border);
|
||||||
|
border-top: 1px solid var(--color-popover-border);
|
||||||
|
left: calc(var(--triangle-offset) - (var(--triangle-size) / 2));
|
||||||
|
top: calc(var(--triangle-size) / 2 * -1 - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-container.position-above .popover-frame::before {
|
||||||
|
transform: rotate(-135deg);
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(var(--triangle-size) / 2 * -1 - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-container.position-above .popover-frame {
|
||||||
|
--shadow-properties: 0 10px 20px -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popoverFrameEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(var(--entrance-direction));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
:root[data-scheme=light] {
|
||||||
|
--scheme: 100% -;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
height: 100%;
|
||||||
|
padding-block: var(--widget-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content, .page.content-ready .page-loading-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page.content-ready > .page-content {
|
||||||
|
display: block;
|
||||||
|
animation: pageContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column-small .size-title-dynamic {
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column-full .size-title-dynamic {
|
||||||
|
font-size: var(--font-size-h3);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-text-subdue);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
background: var(--color-background);
|
||||||
|
height: 5px;
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 0.1rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--color-separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
img, svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
img[loading=lazy].loaded:not(.finished-transition) {
|
||||||
|
transition: opacity .4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
img[loading=lazy].cached:not(.finished-transition) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img[loading=lazy]:not(.loaded, .cached) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-color: var(--color-text-subdue) transparent;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, .body-content {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5 {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column-small {
|
||||||
|
width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column-full {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-columns {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--widget-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pageContentEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-loading-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: loadingContainerEntrance 200ms backwards;
|
||||||
|
animation-delay: 150ms;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-loading-container > .loading-icon {
|
||||||
|
translate: 0 -250%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loadingContainerEntrance {
|
||||||
|
from {
|
||||||
|
/* Using 0.001 instead of 0 fixes a random 1s freeze on Chrome on page load when all */
|
||||||
|
/* elements have opacity 0 and are animated in. I don't want to be a web dev anymore. */
|
||||||
|
opacity: 0.001;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-icon {
|
||||||
|
min-width: 1.5em;
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
border: 0.25em solid hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 12%)));
|
||||||
|
border-top-color: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%)));
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: loadingIconSpin 800ms infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loadingIconSpin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-icon {
|
||||||
|
width: 0.7rem;
|
||||||
|
height: 0.7rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-icon-major {
|
||||||
|
background: var(--color-negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-icon-minor {
|
||||||
|
border: 1px solid var(--color-negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.1rem 0.8rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 2px solid var(--color-widget-background-highlight);
|
||||||
|
box-shadow: 0 2px 0 var(--color-widget-background-highlight);
|
||||||
|
user-select: none;
|
||||||
|
transition: transform .1s, box-shadow .1s;
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd:active {
|
||||||
|
transform: translateY(2px);
|
||||||
|
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-bounds {
|
||||||
|
max-width: 1600px;
|
||||||
|
width: 100%;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 0 var(--content-bounds-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-bounds-wide {
|
||||||
|
max-width: 1920px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-bounds-slim {
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page.center-vertically {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-container {
|
||||||
|
margin-top: calc(var(--widget-gap) / 2);
|
||||||
|
--header-height: 45px;
|
||||||
|
--header-items-gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
height: var(--header-height);
|
||||||
|
gap: var(--header-items-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: var(--header-height);
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
border-right: 1px solid var(--color-widget-content-border);
|
||||||
|
padding-right: var(--widget-content-horizontal-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo:has(img, svg) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
max-height: 2.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
overflow-x: auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
gap: var(--header-items-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav .nav-item {
|
||||||
|
line-height: var(--header-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding-bottom: calc(var(--widget-gap) * 1.5);
|
||||||
|
padding-top: calc(var(--widget-gap) / 2);
|
||||||
|
animation: loadingContainerEntrance 200ms backwards;
|
||||||
|
animation-delay: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation, .mobile-reachability-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
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 {
|
||||||
|
border-bottom-color: var(--color-text-subdue);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.nav-item-current {
|
||||||
|
border-bottom-color: var(--color-primary);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
stroke: var(--color-text-subdue);
|
||||||
|
transition: stroke .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button:hover, .logout-button:focus {
|
||||||
|
stroke: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-choices {
|
||||||
|
--presets-per-row: 2;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--presets-per-row), 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-choices:has(> :nth-child(3)) {
|
||||||
|
--presets-per-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preset {
|
||||||
|
background-color: var(--color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-choices .theme-preset::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -.4rem;
|
||||||
|
border-radius: .7rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-choices .theme-preset:hover::before {
|
||||||
|
border-color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-choices .theme-preset.current::before {
|
||||||
|
border-color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preset-light {
|
||||||
|
gap: 0.3rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-color {
|
||||||
|
background-color: var(--color);
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preset-light .theme-color {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-theme-preview {
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: opacity .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-picker.popover-active .current-theme-preview, .theme-picker:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
.masonry {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--widget-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.masonry-column {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-small-content-bounds {
|
||||||
|
max-width: 350px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
clip-path: inset(50%);
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-horizontal-text {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-horizontal-text > *:not(:last-child)::after {
|
||||||
|
content: '•' / "";
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
margin: 0 0.4rem;
|
||||||
|
position: relative;
|
||||||
|
top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
word-spacing: -0.18em;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details[open] .summary {
|
||||||
|
margin-bottom: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -.3rem -.8rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: var(--color-widget-background-highlight);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details[open] .summary::before, .summary:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details:not([open]) .list-with-transition {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary::after {
|
||||||
|
content: "◀" / "";
|
||||||
|
font-size: 1.2em;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
line-height: 1.3em;
|
||||||
|
right: 0;
|
||||||
|
transition: rotate .5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] .summary::after {
|
||||||
|
rotate: -90deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: refactor, otherwise I hope I never have to change dynamic columns again */
|
||||||
|
.dynamic-columns {
|
||||||
|
--list-half-gap: 0.5rem;
|
||||||
|
gap: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--columns-per-row), 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-columns > * {
|
||||||
|
padding-left: var(--widget-content-horizontal-padding);
|
||||||
|
border-left: 1px solid var(--color-separator);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-columns > *:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
|
||||||
|
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
|
||||||
|
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
|
||||||
|
.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
|
||||||
|
|
||||||
|
@container widget (max-width: 599px) {
|
||||||
|
.dynamic-columns { gap: 0; }
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
.dynamic-columns > * {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.dynamic-columns > *:not(:first-child) {
|
||||||
|
margin-top: calc(var(--list-half-gap) * 2);
|
||||||
|
}
|
||||||
|
.dynamic-columns.list-with-separator > *:not(:first-child) {
|
||||||
|
margin-top: var(--list-half-gap);
|
||||||
|
border-top: 1px solid var(--color-separator);
|
||||||
|
padding-top: var(--list-half-gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@container widget (min-width: 600px) and (max-width: 849px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
|
||||||
|
.dynamic-columns > :nth-child(2n-1) {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@container widget (min-width: 850px) and (max-width: 1249px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
|
||||||
|
.dynamic-columns > :nth-child(3n+1) {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@container widget (min-width: 1250px) and (max-width: 1499px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
|
||||||
|
.dynamic-columns > :nth-child(4n+1) {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@container widget (min-width: 1500px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
|
||||||
|
.dynamic-columns > :nth-child(5n+1) {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-horizontal {
|
||||||
|
--cards-per-row: 6.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-horizontal, .cards-vertical {
|
||||||
|
--cards-gap: calc(var(--widget-content-vertical-padding) * 0.7);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--cards-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-horizontal .card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: calc(100% / var(--cards-per-row) - var(--cards-gap) * (var(--cards-per-row) - 1) / var(--cards-per-row));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid .card {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-horizontal {
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid {
|
||||||
|
--cards-per-row: 6;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cards-per-row), 1fr);
|
||||||
|
gap: calc(var(--widget-content-vertical-padding) * 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container widget (max-width: 1300px) { .cards-horizontal { --cards-per-row: 5.5; } }
|
||||||
|
@container widget (max-width: 1100px) { .cards-horizontal { --cards-per-row: 4.5; } }
|
||||||
|
@container widget (max-width: 850px) { .cards-horizontal { --cards-per-row: 3.5; } }
|
||||||
|
@container widget (max-width: 750px) { .cards-horizontal { --cards-per-row: 3.5; } }
|
||||||
|
@container widget (max-width: 650px) { .cards-horizontal { --cards-per-row: 2.5; } }
|
||||||
|
@container widget (max-width: 450px) { .cards-horizontal { --cards-per-row: 2.3; } }
|
||||||
|
|
||||||
|
@container widget (max-width: 1300px) { .cards-grid { --cards-per-row: 5; } }
|
||||||
|
@container widget (max-width: 1100px) { .cards-grid { --cards-per-row: 4; } }
|
||||||
|
@container widget (max-width: 850px) { .cards-grid { --cards-per-row: 3; } }
|
||||||
|
@container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
|
||||||
|
@container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
|
||||||
|
|
||||||
|
.text-truncate,
|
||||||
|
.single-line-titles .title
|
||||||
|
{
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-line-titles .title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-truncate-2-lines, .text-truncate-3-lines {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-truncate-3-lines { line-clamp: 3; -webkit-line-clamp: 3; }
|
||||||
|
.text-truncate-2-lines { line-clamp: 2; -webkit-line-clamp: 2; }
|
||||||
|
|
||||||
|
.visited-indicator:not(.text-truncate)::after,
|
||||||
|
.visited-indicator.text-truncate::before {
|
||||||
|
content: '↗' / "";
|
||||||
|
margin-left: 0.5em;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
top: 0.15em;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visited-indicator.text-truncate {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visited-indicator:not(:visited)::before, .visited-indicator:not(:visited)::after {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-columns-transitioned .list-with-transition > * { animation: collapsibleItemReveal .25s backwards; }
|
||||||
|
.list-with-transition > *:nth-child(2) { animation-delay: 30ms; }
|
||||||
|
.list-with-transition > *:nth-child(3) { animation-delay: 60ms; }
|
||||||
|
.list-with-transition > *:nth-child(4) { animation-delay: 90ms; }
|
||||||
|
.list-with-transition > *:nth-child(5) { animation-delay: 120ms; }
|
||||||
|
.list-with-transition > *:nth-child(6) { animation-delay: 150ms; }
|
||||||
|
.list-with-transition > *:nth-child(7) { animation-delay: 180ms; }
|
||||||
|
.list-with-transition > *:nth-child(8) { animation-delay: 210ms; }
|
||||||
|
|
||||||
|
.list > *:not(:first-child) {
|
||||||
|
margin-top: calc(var(--list-half-gap) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list.list-with-separator > *:not(:first-child) {
|
||||||
|
margin-top: var(--list-half-gap);
|
||||||
|
border-top: 1px solid var(--color-separator);
|
||||||
|
padding-top: var(--list-half-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-container:not(.container-expanded) > .collapsible-item {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-item {
|
||||||
|
animation: collapsibleItemReveal .25s backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collapsibleItemReveal {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button {
|
||||||
|
font: inherit;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
padding: var(--widget-content-vertical-padding) 0;
|
||||||
|
background: var(--color-widget-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button.container-expanded {
|
||||||
|
position: sticky;
|
||||||
|
/* -1px to hide 1px gap on chrome */
|
||||||
|
bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button-icon {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 1rem;
|
||||||
|
position: relative;
|
||||||
|
top: -.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button-icon::before {
|
||||||
|
content: '' / "";
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button.container-expanded .expand-toggle-button-icon::before {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid.collapsible-container + .expand-toggle-button {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-content:has(.expand-toggle-button:last-child) {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container::before, .carousel-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 2rem;
|
||||||
|
top: 0;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 10;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container::before {
|
||||||
|
background: linear-gradient(to right, var(--color-background), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container::after {
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to left, var(--color-background), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container.show-left-cutoff::before, .carousel-container.show-right-cutoff::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-scheme=light]) .flat-icon {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments > * {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
background-color: var(--color-separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
border: 1px solid var(--color-progress-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
height: 1.5rem;
|
||||||
|
/* naughty, but oh so beautiful */
|
||||||
|
margin-inline: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-combined {
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-active > .progress-bar {
|
||||||
|
transition: border-color .3s;
|
||||||
|
border-color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-value {
|
||||||
|
--half-border-radius: calc(var(--border-radius) / 2);
|
||||||
|
border-radius: 0 var(--half-border-radius) var(--half-border-radius) 0;
|
||||||
|
background: var(--color-progress-value);
|
||||||
|
width: calc(var(--percent) * 1%);
|
||||||
|
min-width: 1px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-value:first-child {
|
||||||
|
border-top-left-radius: var(--half-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-value:last-child {
|
||||||
|
border-bottom-left-radius: var(--half-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-value-notice {
|
||||||
|
background: linear-gradient(to right, var(--color-progress-value) 65%, var(--color-negative));
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-separator {
|
||||||
|
min-width: 2rem;
|
||||||
|
margin-inline: 0.8rem;
|
||||||
|
flex: 1;
|
||||||
|
height: calc(1em * 1.1);
|
||||||
|
border-bottom: 1px dotted var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
filter: grayscale(0.2) contrast(0.9);
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: filter 0.2s, opacity .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-container {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--color-separator);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-container > * {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-parent:hover .thumbnail {
|
||||||
|
opacity: 1;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.hide-scrollbars {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide on Safari and Chrome */
|
||||||
|
.hide-scrollbars::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-icon {
|
||||||
|
width: 2.3rem;
|
||||||
|
height: 2.3rem;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-h1 { font-size: var(--font-size-h1); }
|
||||||
|
.size-h2 { font-size: var(--font-size-h2); }
|
||||||
|
.size-h3 { font-size: var(--font-size-h3); }
|
||||||
|
.size-h4 { font-size: var(--font-size-h4); }
|
||||||
|
.size-base { font-size: var(--font-size-base); }
|
||||||
|
.size-h5 { font-size: var(--font-size-h5); }
|
||||||
|
.size-h6 { font-size: var(--font-size-h6); }
|
||||||
|
|
||||||
|
.color-highlight { color: var(--color-text-highlight); }
|
||||||
|
.color-paragraph { color: var(--color-text-paragraph); }
|
||||||
|
.color-base { color: var(--color-text-base); }
|
||||||
|
.color-subdue { color: var(--color-text-subdue); }
|
||||||
|
.color-negative { color: var(--color-negative); }
|
||||||
|
.color-positive { color: var(--color-positive); }
|
||||||
|
.color-primary { color: var(--color-primary); }
|
||||||
|
|
||||||
|
.color-primary-if-not-visited:not(:visited) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-and-drop-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-and-drop-decoy {
|
||||||
|
outline: 1px dashed var(--color-primary);
|
||||||
|
opacity: 0.25;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-and-drop-draggable {
|
||||||
|
position: absolute;
|
||||||
|
cursor: grabbing !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-and-drop-draggable:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-and-drop-draggable * {
|
||||||
|
cursor: grabbing !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-scaling-textarea-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-scaling-textarea {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
resize: none;
|
||||||
|
color: inherit;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-scaling-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-scaling-textarea-mimic {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
min-height: 1lh;
|
||||||
|
user-select: none;
|
||||||
|
word-wrap: break-word;
|
||||||
|
font: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-help { cursor: help; }
|
||||||
|
.rounded { border-radius: var(--border-radius); }
|
||||||
|
.break-all { word-break: break-all; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-elevate { margin-top: -0.2em; }
|
||||||
|
.text-compact { word-spacing: -0.18em; }
|
||||||
|
.text-very-compact { word-spacing: -0.35em; }
|
||||||
|
.rtl { direction: rtl; }
|
||||||
|
.shrink { flex-shrink: 1; }
|
||||||
|
.shrink-0 { flex-shrink: 0; }
|
||||||
|
.min-width-0 { min-width: 0; }
|
||||||
|
.max-width-100 { max-width: 100%; }
|
||||||
|
.block { display: block; }
|
||||||
|
.inline-block { display: inline-block; }
|
||||||
|
.overflow-hidden { overflow: hidden; }
|
||||||
|
.relative { position: relative; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.flex-1 { flex: 1; }
|
||||||
|
.flex-wrap { flex-wrap: wrap; }
|
||||||
|
.flex-nowrap { flex-wrap: nowrap; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.justify-stretch { justify-content: stretch; }
|
||||||
|
.justify-evenly { justify-content: space-evenly; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-end { justify-content: end; }
|
||||||
|
.uppercase { text-transform: uppercase; }
|
||||||
|
.grow { flex-grow: 1; }
|
||||||
|
.flex-column { flex-direction: column; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.self-center { align-self: center; }
|
||||||
|
.items-start { align-items: start; }
|
||||||
|
.items-end { align-items: end; }
|
||||||
|
.gap-5 { gap: 0.5rem; }
|
||||||
|
.gap-7 { gap: 0.7rem; }
|
||||||
|
.gap-10 { gap: 1rem; }
|
||||||
|
.gap-12 { gap: 1.2rem; }
|
||||||
|
.gap-15 { gap: 1.5rem; }
|
||||||
|
.gap-20 { gap: 2rem; }
|
||||||
|
.gap-25 { gap: 2.5rem; }
|
||||||
|
.gap-35 { gap: 3.5rem; }
|
||||||
|
.gap-45 { gap: 4.5rem; }
|
||||||
|
.gap-55 { gap: 5.5rem; }
|
||||||
|
.margin-left-auto { margin-left: auto; }
|
||||||
|
.margin-top-3 { margin-top: 0.3rem; }
|
||||||
|
.margin-top-5 { margin-top: 0.5rem; }
|
||||||
|
.margin-top-7 { margin-top: 0.7rem; }
|
||||||
|
.margin-top-10 { margin-top: 1rem; }
|
||||||
|
.margin-top-15 { margin-top: 1.5rem; }
|
||||||
|
.margin-top-20 { margin-top: 2rem; }
|
||||||
|
.margin-top-25 { margin-top: 2.5rem; }
|
||||||
|
.margin-top-35 { margin-top: 3.5rem; }
|
||||||
|
.margin-top-40 { margin-top: 4rem; }
|
||||||
|
.margin-top-auto { margin-top: auto; }
|
||||||
|
.margin-block-3 { margin-block: 0.3rem; }
|
||||||
|
.margin-block-5 { margin-block: 0.5rem; }
|
||||||
|
.margin-block-7 { margin-block: 0.7rem; }
|
||||||
|
.margin-block-8 { margin-block: 0.8rem; }
|
||||||
|
.margin-block-10 { margin-block: 1rem; }
|
||||||
|
.margin-block-15 { margin-block: 1.5rem; }
|
||||||
|
.margin-bottom-3 { margin-bottom: 0.3rem; }
|
||||||
|
.margin-bottom-5 { margin-bottom: 0.5rem; }
|
||||||
|
.margin-bottom-7 { margin-bottom: 0.7rem; }
|
||||||
|
.margin-bottom-10 { margin-bottom: 1rem; }
|
||||||
|
.margin-bottom-15 { margin-bottom: 1.5rem; }
|
||||||
|
.margin-bottom-auto { margin-bottom: auto; }
|
||||||
|
.margin-bottom-widget { margin-bottom: var(--widget-content-vertical-padding); }
|
||||||
|
.padding-widget { padding: var(--widget-content-padding); }
|
||||||
|
.padding-block-widget { padding-block: var(--widget-content-vertical-padding); }
|
||||||
|
.padding-inline-widget { padding-inline: var(--widget-content-horizontal-padding); }
|
||||||
|
.pointer-events-none { pointer-events: none; }
|
||||||
|
.select-none { user-select: none; }
|
||||||
|
.padding-block-5 { padding-block: 0.5rem; }
|
||||||
|
.scale-half { transform: scale(0.5); }
|
||||||
|
.list { --list-half-gap: 0rem; }
|
||||||
|
.list-gap-2 { --list-half-gap: 0.1rem; }
|
||||||
|
.list-gap-4 { --list-half-gap: 0.2rem; }
|
||||||
|
.list-gap-8 { --list-half-gap: 0.4rem; }
|
||||||
|
.list-gap-10 { --list-half-gap: 0.5rem; }
|
||||||
|
.list-gap-14 { --list-half-gap: 0.7rem; }
|
||||||
|
.list-gap-20 { --list-half-gap: 1rem; }
|
||||||
|
.list-gap-24 { --list-half-gap: 1.2rem; }
|
||||||
|
.list-gap-34 { --list-half-gap: 1.7rem; }
|
||||||
|
|
||||||
|
@media (max-width: 1190px) {
|
||||||
|
.size-base-on-mobile { font-size: var(--font-size-base); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
.bookmarks-group {
|
||||||
|
--bookmarks-group-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-group-title {
|
||||||
|
color: var(--bookmarks-group-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
|
||||||
|
content: '↗' / "";
|
||||||
|
margin-left: 0.5em;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
top: 0.15em;
|
||||||
|
color: var(--bookmarks-group-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-icon-container {
|
||||||
|
margin-block: 0.1rem;
|
||||||
|
background-color: var(--color-widget-background-highlight);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 0.5rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
.old-calendar-day {
|
||||||
|
width: calc(100% / 7);
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.old-calendar-day-today {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-dates {
|
||||||
|
text-align: center;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-date {
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-current-date {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: var(--color-popover-border);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-spillover-date {
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header-button {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
z-index: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header-button::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -0.2rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: var(--color-text-subdue);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header-button:hover::before {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-undo-button {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: text-top;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
margin-left: 0.7rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.clock-time {
|
||||||
|
min-width: 8ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-time span {
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
.dns-stats-totals {
|
||||||
|
transition: opacity .3s;
|
||||||
|
transition-delay: 50ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats:has(.dns-stats-graph .popover-active) .dns-stats-totals {
|
||||||
|
opacity: 0.1;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph {
|
||||||
|
--graph-height: 70px;
|
||||||
|
height: var(--graph-height);
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-gridlines-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-gridlines {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-columns {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-column {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
width: calc(100% / 8);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-column::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 1px 0;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--color-text-base);
|
||||||
|
transition: opacity .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-column:hover::before {
|
||||||
|
opacity: 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-bar {
|
||||||
|
width: 14px;
|
||||||
|
height: calc((var(--bar-height) / 100) * var(--graph-height));
|
||||||
|
border: 1px solid var(--color-progress-border);
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
display: flex;
|
||||||
|
background: var(--color-widget-background);
|
||||||
|
padding: 2px 2px 0 2px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
transition: border-color .2s;
|
||||||
|
min-height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-column.popover-active .dns-stats-graph-bar {
|
||||||
|
border-color: var(--color-text-subdue);
|
||||||
|
border-bottom-color: var(--color-progress-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-bar > * {
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-vertical-progress-value);
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-bar > .queries {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-bar > *:last-child {
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-bar > .blocked {
|
||||||
|
background-color: var(--color-negative);
|
||||||
|
flex-basis: calc(var(--percent) - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-time, .dns-stats-graph-columns:hover .dns-stats-graph-time {
|
||||||
|
position: absolute;
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
inset-inline: 0;
|
||||||
|
text-align: center;
|
||||||
|
height: 2.5rem;
|
||||||
|
line-height: 2.5rem;
|
||||||
|
top: 100%;
|
||||||
|
user-select: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-0.5rem);
|
||||||
|
transition: opacity .2s, transform .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-column:hover .dns-stats-graph-time {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-columns:hover .dns-stats-graph-column:not(:hover) .dns-stats-graph-time {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.docker-container-icon {
|
||||||
|
display: block;
|
||||||
|
filter: grayscale(0.4);
|
||||||
|
object-fit: contain;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
width: 2.7rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: filter 0.3s, opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-container-icon.flat-icon {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-container:hover .docker-container-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-container:hover .docker-container-icon:not(.flat-icon) {
|
||||||
|
filter: grayscale(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-container-status-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
.widget-group-header {
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-title {
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
border: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px dotted transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color .3s, border-color .3s;
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
line-height: calc(1.6em - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-title:hover:not(.widget-group-title-current) {
|
||||||
|
color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-title-current {
|
||||||
|
border-bottom-color: var(--color-text-base-muted);
|
||||||
|
color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-content {
|
||||||
|
animation: widgetGroupContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-content[data-direction="right"] {
|
||||||
|
--direction: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-content[data-direction="left"] {
|
||||||
|
--direction: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes widgetGroupContentEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(var(--direction));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-content:not(.widget-group-content-current) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.market-chart {
|
||||||
|
margin-left: auto;
|
||||||
|
width: 6.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-chart svg {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-values {
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
.monitor-site-icon {
|
||||||
|
display: block;
|
||||||
|
opacity: 0.8;
|
||||||
|
filter: grayscale(0.4);
|
||||||
|
object-fit: contain;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
width: 3.2rem;
|
||||||
|
position: relative;
|
||||||
|
top: -0.1rem;
|
||||||
|
transition: filter 0.3s, opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-site-icon.flat-icon {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-site:hover .monitor-site-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
|
||||||
|
filter: grayscale(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-site-status-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-site-status-icon-compact {
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
.reddit-card-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: 0% 20%;
|
||||||
|
opacity: 0.15;
|
||||||
|
filter: blur(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reddit-card-thumbnail-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reddit-card-thumbnail-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(0deg, var(--color-widget-background) 10%, transparent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.release-source-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
.rss-card-image {
|
||||||
|
height: var(--rss-thumbnail-height, 10rem);
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-card-2 {
|
||||||
|
position: relative;
|
||||||
|
height: var(--rss-card-height, 27rem);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-card-2::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
0deg,
|
||||||
|
var(--color-widget-background),
|
||||||
|
hsla(var(--color-widget-background-hsl-values), 0.8) 6rem, transparent 14rem
|
||||||
|
);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-card-2-image {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
/* +1px is required to fix some weird graphical bug where the image overflows on the bottom in firefox */
|
||||||
|
border-radius: calc(var(--border-radius) + 1px);
|
||||||
|
opacity: 0.9;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-card-2-content {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
bottom: var(--widget-content-vertical-padding);
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-detailed-description {
|
||||||
|
max-width: 55rem;
|
||||||
|
color: var(--color-text-base-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-detailed-thumbnail {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-detailed-thumbnail > * {
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
height: 8.7rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
.search-icon {
|
||||||
|
width: 2.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon-container {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* gives a wider hit area for the 3 people that will notice the animation : ) */
|
||||||
|
.search-icon-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon-container:hover > .search-icon {
|
||||||
|
animation: searchIconHover 2.9s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes searchIconHover {
|
||||||
|
0%, 39% { translate: 0 0; }
|
||||||
|
20% { scale: 1.3; }
|
||||||
|
40% { scale: 1; }
|
||||||
|
50% { translate: -30% 30%; }
|
||||||
|
70% { translate: 30% -30%; }
|
||||||
|
90% { translate: -30% -30%; }
|
||||||
|
100% { translate: 0 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
transition: border-color .2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:hover {
|
||||||
|
border-color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6rem;
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: var(--color-text-base-muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bangs { display: none; }
|
||||||
|
|
||||||
|
.search-bang {
|
||||||
|
border-radius: calc(var(--border-radius) * 2);
|
||||||
|
background: var(--color-widget-background-highlight);
|
||||||
|
padding: 0.3rem 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes searchBangsEntrance {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bang:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
.widget-type-server-info {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server + .server {
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-details {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon {
|
||||||
|
height: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-spicy-cpu-icon {
|
||||||
|
height: 1em;
|
||||||
|
align-self: center;
|
||||||
|
margin-left: 0.4em;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-stat-unavailable {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container widget (min-width: 650px) {
|
||||||
|
.server {
|
||||||
|
gap: 2rem;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server + .server {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: unset;
|
||||||
|
margin-right: auto;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-stats {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: right;
|
||||||
|
min-width: 450px;
|
||||||
|
margin-top: 0;
|
||||||
|
gap: 2rem;
|
||||||
|
padding-bottom: 0.8rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-stats > * {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
.todo-widget {
|
||||||
|
padding-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-plus-icon {
|
||||||
|
--icon-color: var(--color-text-subdue);
|
||||||
|
position: relative;
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-plus-icon::before, .todo-plus-icon::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--icon-color);
|
||||||
|
transition: background-color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-plus-icon::before {
|
||||||
|
width: 2px;
|
||||||
|
inset-block: 0.2rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-plus-icon::after {
|
||||||
|
height: 2px;
|
||||||
|
inset-inline: 0.2rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-input textarea::placeholder {
|
||||||
|
color: var(--color-text-base-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-input {
|
||||||
|
position: relative;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-input:focus-within .todo-plus-icon {
|
||||||
|
--icon-color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item {
|
||||||
|
transform-origin: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
border: 2px solid var(--color-text-subdue);
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
transition: border-color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox::before {
|
||||||
|
content: "";
|
||||||
|
inset: -1rem;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0.3rem;
|
||||||
|
border-radius: 0.1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox:checked::after {
|
||||||
|
background: var(--color-primary);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-text {
|
||||||
|
color: var(--color-text-base);
|
||||||
|
transition: color .35s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-text:focus {
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.5rem;
|
||||||
|
inset-inline: 0;
|
||||||
|
height: 1rem;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.is-being-dragged .todo-item-drag-handle {
|
||||||
|
height: 3rem;
|
||||||
|
top: -1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item:has(.todo-item-checkbox:checked) .todo-item-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-delete {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .2s;
|
||||||
|
outline-offset: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item:hover .todo-item-delete, .todo-item:focus-within .todo-item-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.is-being-dragged .todo-item-delete {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.twitch-category-thumbnail {
|
||||||
|
width: 5rem;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitch-channel-avatar {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitch-channel-avatar-container {
|
||||||
|
width: 4.4rem;
|
||||||
|
height: 4.4rem;
|
||||||
|
border: 2px solid var(--color-text-subdue);
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitch-channel-live .twitch-channel-avatar-container {
|
||||||
|
border: 2px solid var(--color-positive);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitch-channel-live .twitch-channel-avatar-container::after {
|
||||||
|
content: 'LIVE';
|
||||||
|
position: absolute;
|
||||||
|
background: var(--color-positive);
|
||||||
|
color: var(--color-widget-background);
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
left: 50%;
|
||||||
|
bottom: -35%;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding-inline: 0.3rem;
|
||||||
|
transform: translate(-50%);
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.video-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 8.9;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-horizontal-list-thumbnail {
|
||||||
|
height: 4rem;
|
||||||
|
aspect-ratio: 16 / 8.9;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
.weather-column {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: end;
|
||||||
|
flex-direction: column;
|
||||||
|
width: calc(100% / 12);
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-value, .weather-columns:hover .weather-column-value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
letter-spacing: -0.1rem;
|
||||||
|
margin-right: 0.1rem;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.5rem);
|
||||||
|
transition: opacity .2s, transform .2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-current .weather-column-value, .weather-column:hover .weather-column-value {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-value::after {
|
||||||
|
position: absolute;
|
||||||
|
content: '°';
|
||||||
|
left: 100%;
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-value.weather-column-value-negative::before {
|
||||||
|
position: absolute;
|
||||||
|
content: '-';
|
||||||
|
right: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-bar, .weather-columns:hover .weather-bar {
|
||||||
|
height: calc(20px + var(--weather-bar-height) * 40px);
|
||||||
|
width: 6px;
|
||||||
|
background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 18%)));
|
||||||
|
border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 24%)));
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
mask-image: linear-gradient(0deg, transparent 0, #000 10px);
|
||||||
|
-webkit-mask-image: linear-gradient(0deg, transparent 0, #000 10px);
|
||||||
|
transition: background-color .2s, border-color .2s, width .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-current .weather-bar, .weather-column:hover .weather-bar {
|
||||||
|
width: 10px;
|
||||||
|
background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%)));
|
||||||
|
border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 50%)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-rain {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
bottom: 20%;
|
||||||
|
overflow: hidden;
|
||||||
|
mask-image: linear-gradient(0deg, transparent 40%, #000);
|
||||||
|
-webkit-mask-image: linear-gradient(0deg, transparent 40%, #000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-rain::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
/* TODO: figure out a way to make it look continuous between columns, right now */
|
||||||
|
/* depending on the width of the page the rain inside two columns next to each other */
|
||||||
|
/* can overlap and look bad */
|
||||||
|
background: radial-gradient(circle at 4px 4px, hsl(200, 90%, 70%, 0.4) 1px, transparent 0);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
transform: rotate(45deg) translate(-50%, 25%);
|
||||||
|
height: 130%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
left: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column:nth-child(3) .weather-column-time,
|
||||||
|
.weather-column:nth-child(7) .weather-column-time,
|
||||||
|
.weather-column:nth-child(11) .weather-column-time {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-time, .weather-columns:hover .weather-column-time {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-0.5rem);
|
||||||
|
transition: opacity .2s, transform .2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column:hover .weather-column-time {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-daylight {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(0deg, transparent 30px, hsl(50, 50%, 30%, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-daylight-sunrise {
|
||||||
|
border-radius: 20px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-daylight-sunset {
|
||||||
|
border-radius: 0 20px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-icon {
|
||||||
|
width: 0.8em;
|
||||||
|
height: 0.8em;
|
||||||
|
border-radius: 0 50% 50% 50%;
|
||||||
|
background-color: currentColor;
|
||||||
|
transform: rotate(225deg) translate(.1em, .1em);
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
width: .4em;
|
||||||
|
height: .4em;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-widget-background);
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
@import "widget-bookmarks.css";
|
||||||
|
@import "widget-calendar.css";
|
||||||
|
@import "widget-clock.css";
|
||||||
|
@import "widget-dns-stats.css";
|
||||||
|
@import "widget-docker-containers.css";
|
||||||
|
@import "widget-group.css";
|
||||||
|
@import "widget-markets.css";
|
||||||
|
@import "widget-monitor.css";
|
||||||
|
@import "widget-reddit.css";
|
||||||
|
@import "widget-releases.css";
|
||||||
|
@import "widget-rss.css";
|
||||||
|
@import "widget-search.css";
|
||||||
|
@import "widget-server-stats.css";
|
||||||
|
@import "widget-twitch.css";
|
||||||
|
@import "widget-videos.css";
|
||||||
|
@import "widget-weather.css";
|
||||||
|
@import "widget-todo.css";
|
||||||
|
|
||||||
|
@import "forum-posts.css";
|
||||||
|
|
||||||
|
.widget-error-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1.8rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-error-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(0rem - (var(--widget-content-vertical-padding) / 2)) calc(0rem - (var(--widget-content-horizontal-padding) / 2));
|
||||||
|
background: var(--color-negative);
|
||||||
|
opacity: 0.05;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-error-icon {
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
stroke: var(--color-negative);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-widgets {
|
||||||
|
margin-bottom: var(--widget-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-content {
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-content:not(.widget-content-frameless) {
|
||||||
|
padding: var(--widget-content-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-content:not(.widget-content-frameless), .widget-content-frame {
|
||||||
|
background: var(--color-widget-background);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 1px solid var(--color-widget-content-border);
|
||||||
|
box-shadow: 0px 3px 0px 0px hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl)) - 0.5%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-header {
|
||||||
|
padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-beta-icon {
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform .45s, opacity .45s, stroke .45s;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-beta-icon:hover, .widget-header .popover-active > .widget-beta-icon {
|
||||||
|
fill: var(--color-text-highlight);
|
||||||
|
transform: translateY(-10%) scale(1.3);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget + .widget {
|
||||||
|
margin-top: var(--widget-gap);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 607 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="26" height="26" rx="3" fill="#151519"/>
|
||||||
|
<rect x="2" y="2" width="10" height="22" rx="2" fill="#ededed"/>
|
||||||
|
<rect x="14" y="2" width="10" height="10" rx="2" fill="#ededed"/>
|
||||||
|
<path d="M16.3018 5.04032L17.328 4H22V8.72984L20.9014 9.81855V6.49193C20.9014 6.35484 20.9095 6.21774 20.9256 6.08065C20.9497 5.93548 20.9859 5.81855 21.0342 5.72984L16.7847 10L16 9.2379L20.3099 4.93145C20.2294 4.97984 20.1167 5.0121 19.9718 5.02823C19.827 5.03629 19.674 5.04032 19.5131 5.04032H16.3018Z" fill="#151519"/>
|
||||||
|
<rect x="14" y="14" width="10" height="10" rx="2" fill="#ededed"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 677 B |
@@ -31,3 +31,28 @@ export function slideFade({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function animateReposition(
|
||||||
|
element,
|
||||||
|
onAnimEnd,
|
||||||
|
animOptions = { duration: 400, easing: easeOutQuint }
|
||||||
|
) {
|
||||||
|
const rectBefore = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const rectAfter = element.getBoundingClientRect();
|
||||||
|
const offsetY = rectBefore.y - rectAfter.y;
|
||||||
|
const offsetX = rectBefore.x - rectAfter.x;
|
||||||
|
|
||||||
|
element.animate({
|
||||||
|
keyframes: [
|
||||||
|
{ transform: `translate(${offsetX}px, ${offsetY}px)` },
|
||||||
|
{ transform: 'none' }
|
||||||
|
],
|
||||||
|
options: animOptions
|
||||||
|
}, onAnimEnd);
|
||||||
|
|
||||||
|
return rectAfter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const [datesEntranceLeft, datesEntranceRight] = directions(
|
|||||||
const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
|
const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
|
||||||
|
|
||||||
export default function(element) {
|
export default function(element) {
|
||||||
element.swap(Calendar(
|
element.swapWith(Calendar(
|
||||||
Number(element.dataset.firstDayOfWeek ?? 1)
|
Number(element.dataset.firstDayOfWeek ?? 1)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { find } from "./templating.js";
|
||||||
|
|
||||||
|
const AUTH_ENDPOINT = pageData.baseURL + "/api/authenticate";
|
||||||
|
|
||||||
|
const showPasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const hidePasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const container = find("#login-container");
|
||||||
|
const usernameInput = find("#username");
|
||||||
|
const passwordInput = find("#password");
|
||||||
|
const errorMessage = find("#error-message");
|
||||||
|
const loginButton = find("#login-button");
|
||||||
|
const toggleVisibilityButton = find("#toggle-password-visibility");
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
lastUsername: "",
|
||||||
|
lastPassword: "",
|
||||||
|
isLoading: false,
|
||||||
|
isRateLimited: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const lang = {
|
||||||
|
showPassword: "Show password",
|
||||||
|
hidePassword: "Hide password",
|
||||||
|
incorrectCredentials: "Incorrect username or password",
|
||||||
|
rateLimited: "Too many login attempts, try again in a few minutes",
|
||||||
|
unknownError: "An error occurred, please try again",
|
||||||
|
};
|
||||||
|
|
||||||
|
container.clearStyles("display");
|
||||||
|
setTimeout(() => usernameInput.focus(), 200);
|
||||||
|
|
||||||
|
toggleVisibilityButton
|
||||||
|
.html(showPasswordSVG)
|
||||||
|
.attr("title", lang.showPassword)
|
||||||
|
.on("click", function() {
|
||||||
|
if (passwordInput.type === "password") {
|
||||||
|
passwordInput.type = "text";
|
||||||
|
toggleVisibilityButton.html(hidePasswordSVG).attr("title", lang.hidePassword);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordInput.type = "password";
|
||||||
|
toggleVisibilityButton.html(showPasswordSVG).attr("title", lang.showPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
function enableLoginButtonIfCriteriaMet() {
|
||||||
|
const usernameValue = usernameInput.value.trim();
|
||||||
|
const passwordValue = passwordInput.value.trim();
|
||||||
|
|
||||||
|
const usernameValid = usernameValue.length >= 3;
|
||||||
|
const passwordValid = passwordValue.length >= 6;
|
||||||
|
|
||||||
|
const isUsingLastCredentials =
|
||||||
|
usernameValue === state.lastUsername
|
||||||
|
&& passwordValue === state.lastPassword;
|
||||||
|
|
||||||
|
loginButton.disabled = !(
|
||||||
|
usernameValid
|
||||||
|
&& passwordValid
|
||||||
|
&& !isUsingLastCredentials
|
||||||
|
&& !state.isLoading
|
||||||
|
&& !state.isRateLimited
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameInput.on("input", enableLoginButtonIfCriteriaMet);
|
||||||
|
passwordInput.on("input", enableLoginButtonIfCriteriaMet);
|
||||||
|
|
||||||
|
async function handleLoginAttempt() {
|
||||||
|
state.lastUsername = usernameInput.value;
|
||||||
|
state.lastPassword = passwordInput.value;
|
||||||
|
errorMessage.text("");
|
||||||
|
|
||||||
|
loginButton.disable();
|
||||||
|
state.isLoading = true;
|
||||||
|
|
||||||
|
const response = await fetch(AUTH_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: usernameInput.value,
|
||||||
|
password: passwordInput.value
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
state.isLoading = false;
|
||||||
|
if (response.status === 200) {
|
||||||
|
setTimeout(() => { window.location.href = pageData.baseURL + "/"; }, 300);
|
||||||
|
|
||||||
|
container.animate({
|
||||||
|
keyframes: [{ offset: 1, transform: "scale(0.95)", opacity: 0 }],
|
||||||
|
options: { duration: 300, easing: "ease", fill: "forwards" }}
|
||||||
|
);
|
||||||
|
|
||||||
|
find("footer")?.animate({
|
||||||
|
keyframes: [{ offset: 1, opacity: 0 }],
|
||||||
|
options: { duration: 300, easing: "ease", fill: "forwards", delay: 50 }
|
||||||
|
});
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
errorMessage.text(lang.incorrectCredentials);
|
||||||
|
passwordInput.focus();
|
||||||
|
} else if (response.status === 429) {
|
||||||
|
errorMessage.text(lang.rateLimited);
|
||||||
|
state.isRateLimited = true;
|
||||||
|
const retryAfter = response.headers.get("Retry-After") || 30;
|
||||||
|
setTimeout(() => {
|
||||||
|
state.lastUsername = "";
|
||||||
|
state.lastPassword = "";
|
||||||
|
state.isRateLimited = false;
|
||||||
|
|
||||||
|
enableLoginButtonIfCriteriaMet();
|
||||||
|
}, retryAfter * 1000);
|
||||||
|
} else {
|
||||||
|
errorMessage.text(lang.unknownError);
|
||||||
|
passwordInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loginButton.disable().on("click", handleLoginAttempt);
|
||||||
@@ -37,9 +37,6 @@ export function setupMasonries() {
|
|||||||
columnsFragment.append(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++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
columnsFragment.children[i % columnsCount].appendChild(items[i]);
|
columnsFragment.children[i % columnsCount].appendChild(items[i]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { setupPopovers } from './popover.js';
|
import { setupPopovers } from './popover.js';
|
||||||
import { setupMasonries } from './masonry.js';
|
import { setupMasonries } from './masonry.js';
|
||||||
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
|
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
|
||||||
|
import { elem, find, findAll } from './templating.js';
|
||||||
|
|
||||||
async function fetchPageContent(pageData) {
|
async function fetchPageContent(pageData) {
|
||||||
// TODO: handle non 200 status codes/time outs
|
// TODO: handle non 200 status codes/time outs
|
||||||
@@ -104,6 +105,7 @@ function setupSearchBoxes() {
|
|||||||
for (let i = 0; i < searchWidgets.length; i++) {
|
for (let i = 0; i < searchWidgets.length; i++) {
|
||||||
const widget = searchWidgets[i];
|
const widget = searchWidgets[i];
|
||||||
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
|
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
|
||||||
|
const target = widget.dataset.target || "_blank";
|
||||||
const newTab = widget.dataset.newTab === "true";
|
const newTab = widget.dataset.newTab === "true";
|
||||||
const inputElement = widget.getElementsByClassName("search-input")[0];
|
const inputElement = widget.getElementsByClassName("search-input")[0];
|
||||||
const bangElement = widget.getElementsByClassName("search-bang")[0];
|
const bangElement = widget.getElementsByClassName("search-bang")[0];
|
||||||
@@ -143,7 +145,7 @@ function setupSearchBoxes() {
|
|||||||
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
|
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
|
||||||
|
|
||||||
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
|
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
|
||||||
window.open(url, '_blank').focus();
|
window.open(url, target).focus();
|
||||||
} else {
|
} else {
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
@@ -640,6 +642,16 @@ async function setupCalendars() {
|
|||||||
calendar.default(elems[i]);
|
calendar.default(elems[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setupTodos() {
|
||||||
|
const elems = document.getElementsByClassName("todo");
|
||||||
|
if (elems.length == 0) return;
|
||||||
|
|
||||||
|
const todo = await import ('./todo.js');
|
||||||
|
|
||||||
|
for (let i = 0; i < elems.length; i++)
|
||||||
|
todo.default(elems[i]);
|
||||||
|
}
|
||||||
|
|
||||||
function setupTruncatedElementTitles() {
|
function setupTruncatedElementTitles() {
|
||||||
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
|
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
|
||||||
|
|
||||||
@@ -649,11 +661,89 @@ 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.title === "") element.title = element.textContent;
|
if (element.getAttribute("title") === null) element.title = element.innerText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeTheme(key, onChanged) {
|
||||||
|
const themeStyleElem = find("#theme-style");
|
||||||
|
|
||||||
|
const response = await fetch(`${pageData.baseURL}/api/set-theme/${key}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status != 200) {
|
||||||
|
alert("Failed to set theme: " + response.statusText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newThemeStyle = await response.text();
|
||||||
|
|
||||||
|
const tempStyle = elem("style")
|
||||||
|
.html("* { transition: none !important; }")
|
||||||
|
.appendTo(document.head);
|
||||||
|
|
||||||
|
themeStyleElem.html(newThemeStyle);
|
||||||
|
document.documentElement.setAttribute("data-theme", key);
|
||||||
|
document.documentElement.setAttribute("data-scheme", response.headers.get("X-Scheme"));
|
||||||
|
typeof onChanged == "function" && onChanged();
|
||||||
|
setTimeout(() => { tempStyle.remove(); }, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initThemePicker() {
|
||||||
|
const themeChoicesInMobileNav = find(".mobile-navigation .theme-choices");
|
||||||
|
if (!themeChoicesInMobileNav) return;
|
||||||
|
|
||||||
|
const themeChoicesInHeader = find(".header-container .theme-choices");
|
||||||
|
|
||||||
|
if (themeChoicesInHeader) {
|
||||||
|
themeChoicesInHeader.replaceWith(
|
||||||
|
themeChoicesInMobileNav.cloneNode(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetElems = findAll(".theme-choices .theme-preset");
|
||||||
|
let themePreviewElems = document.getElementsByClassName("current-theme-preview");
|
||||||
|
let isLoading = false;
|
||||||
|
|
||||||
|
presetElems.forEach((presetElement) => {
|
||||||
|
const themeKey = presetElement.dataset.key;
|
||||||
|
|
||||||
|
if (themeKey === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (themeKey == pageData.theme) {
|
||||||
|
presetElement.classList.add("current");
|
||||||
|
}
|
||||||
|
|
||||||
|
presetElement.addEventListener("click", () => {
|
||||||
|
if (themeKey == pageData.theme) return;
|
||||||
|
if (isLoading) return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
changeTheme(themeKey, function() {
|
||||||
|
isLoading = false;
|
||||||
|
pageData.theme = themeKey;
|
||||||
|
presetElems.forEach((e) => { e.classList.remove("current"); });
|
||||||
|
|
||||||
|
Array.from(themePreviewElems).forEach((preview) => {
|
||||||
|
preview.querySelector(".theme-preset").replaceWith(
|
||||||
|
presetElement.cloneNode(true)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
presetElems.forEach((e) => {
|
||||||
|
if (e.dataset.key != themeKey) return;
|
||||||
|
e.classList.add("current");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function setupPage() {
|
async function setupPage() {
|
||||||
|
initThemePicker();
|
||||||
|
|
||||||
const pageElement = document.getElementById("page");
|
const pageElement = document.getElementById("page");
|
||||||
const pageContentElement = document.getElementById("page-content");
|
const pageContentElement = document.getElementById("page-content");
|
||||||
const pageContent = await fetchPageContent(pageData);
|
const pageContent = await fetchPageContent(pageData);
|
||||||
@@ -664,6 +754,7 @@ async function setupPage() {
|
|||||||
setupPopovers();
|
setupPopovers();
|
||||||
setupClocks()
|
setupClocks()
|
||||||
await setupCalendars();
|
await setupCalendars();
|
||||||
|
await setupTodos();
|
||||||
setupCarousels();
|
setupCarousels();
|
||||||
setupSearchBoxes();
|
setupSearchBoxes();
|
||||||
setupCollapsibleLists();
|
setupCollapsibleLists();
|
||||||
@@ -38,6 +38,8 @@ function handleMouseEnter(event) {
|
|||||||
if (activeTarget !== target) {
|
if (activeTarget !== target) {
|
||||||
hidePopover();
|
hidePopover();
|
||||||
requestAnimationFrame(() => requestAnimationFrame(showPopover));
|
requestAnimationFrame(() => requestAnimationFrame(showPopover));
|
||||||
|
} else if (activeTarget.dataset.popoverTrigger === "click") {
|
||||||
|
hidePopover();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -100,11 +102,14 @@ function showPopover() {
|
|||||||
contentElement.style.maxWidth = contentMaxWidth;
|
contentElement.style.maxWidth = contentMaxWidth;
|
||||||
activeTarget.classList.add("popover-active");
|
activeTarget.classList.add("popover-active");
|
||||||
document.addEventListener("keydown", handleHidePopoverOnEscape);
|
document.addEventListener("keydown", handleHidePopoverOnEscape);
|
||||||
|
window.addEventListener("scroll", queueRepositionContainer);
|
||||||
window.addEventListener("resize", queueRepositionContainer);
|
window.addEventListener("resize", queueRepositionContainer);
|
||||||
observer.observe(containerElement);
|
observer.observe(containerElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
function repositionContainer() {
|
function repositionContainer() {
|
||||||
|
if (activeTarget === null) return;
|
||||||
|
|
||||||
containerElement.style.display = "block";
|
containerElement.style.display = "block";
|
||||||
|
|
||||||
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
|
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
|
||||||
@@ -157,7 +162,11 @@ function hidePopover() {
|
|||||||
|
|
||||||
activeTarget.classList.remove("popover-active");
|
activeTarget.classList.remove("popover-active");
|
||||||
containerElement.style.display = "none";
|
containerElement.style.display = "none";
|
||||||
|
containerElement.style.removeProperty("top");
|
||||||
|
containerElement.style.removeProperty("left");
|
||||||
|
containerElement.style.removeProperty("right");
|
||||||
document.removeEventListener("keydown", handleHidePopoverOnEscape);
|
document.removeEventListener("keydown", handleHidePopoverOnEscape);
|
||||||
|
window.removeEventListener("scroll", queueRepositionContainer);
|
||||||
window.removeEventListener("resize", queueRepositionContainer);
|
window.removeEventListener("resize", queueRepositionContainer);
|
||||||
observer.unobserve(containerElement);
|
observer.unobserve(containerElement);
|
||||||
|
|
||||||
@@ -181,7 +190,12 @@ export function setupPopovers() {
|
|||||||
for (let i = 0; i < targets.length; i++) {
|
for (let i = 0; i < targets.length; i++) {
|
||||||
const target = targets[i];
|
const target = targets[i];
|
||||||
|
|
||||||
target.addEventListener("mouseenter", handleMouseEnter);
|
if (target.dataset.popoverTrigger === "click") {
|
||||||
|
target.addEventListener("click", handleMouseEnter);
|
||||||
|
} else {
|
||||||
|
target.addEventListener("mouseenter", handleMouseEnter);
|
||||||
|
}
|
||||||
|
|
||||||
target.addEventListener("mouseleave", handleMouseLeave);
|
target.addEventListener("mouseleave", handleMouseLeave);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export function findAll(selector) {
|
|||||||
return document.querySelectorAll(selector);
|
return document.querySelectorAll(selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
HTMLCollection.prototype.map = function(fn) {
|
||||||
|
return Array.from(this).map(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
HTMLCollection.prototype.indexOf = function(element) {
|
||||||
|
return Array.prototype.indexOf.call(this, element);
|
||||||
|
}
|
||||||
|
|
||||||
const ep = HTMLElement.prototype;
|
const ep = HTMLElement.prototype;
|
||||||
const fp = DocumentFragment.prototype;
|
const fp = DocumentFragment.prototype;
|
||||||
const tp = Text.prototype;
|
const tp = Text.prototype;
|
||||||
@@ -110,7 +119,7 @@ ep.appendTo = function(parent) {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
ep.swap = function(element) {
|
ep.swapWith = function(element) {
|
||||||
this.replaceWith(element);
|
this.replaceWith(element);
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
@@ -147,6 +156,22 @@ ep.styles = function(s) {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ep.clearStyles = function(...props) {
|
||||||
|
for (let i = 0; i < props.length; i++)
|
||||||
|
this.style.removeProperty(props[i]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
ep.disable = function() {
|
||||||
|
this.disabled = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
ep.enable = function() {
|
||||||
|
this.disabled = false;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
const epAnimate = ep.animate;
|
const epAnimate = ep.animate;
|
||||||
ep.animate = function(anim, callback) {
|
ep.animate = function(anim, callback) {
|
||||||
const a = epAnimate.call(this, anim.keyframes, anim.options);
|
const a = epAnimate.call(this, anim.keyframes, anim.options);
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import { elem, fragment } from "./templating.js";
|
||||||
|
import { animateReposition } from "./animations.js";
|
||||||
|
import { clamp, Vec2, toggleableEvents, throttledDebounce } from "./utils.js";
|
||||||
|
|
||||||
|
const trashIconSvg = `<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z" clip-rule="evenodd" />
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
export default function(element) {
|
||||||
|
element.swapWith(
|
||||||
|
Todo(element.dataset.todoId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemAnim(height, entrance = true) {
|
||||||
|
const visible = { height: height + "px", opacity: 1 };
|
||||||
|
const hidden = { height: "0", opacity: 0, padding: "0" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyframes: [
|
||||||
|
entrance ? hidden : visible,
|
||||||
|
entrance ? visible : hidden
|
||||||
|
],
|
||||||
|
options: { duration: 200, easing: "ease" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputMarginAnim(entrance = true) {
|
||||||
|
const amount = "1.5rem";
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyframes: [
|
||||||
|
{ marginBottom: entrance ? "0px" : amount },
|
||||||
|
{ marginBottom: entrance ? amount : "0" }
|
||||||
|
],
|
||||||
|
options: { duration: 200, easing: "ease", fill: "forwards" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromLocalStorage(id) {
|
||||||
|
return JSON.parse(localStorage.getItem(`todo-${id}`) || "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToLocalStorage(id, data) {
|
||||||
|
localStorage.setItem(`todo-${id}`, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function Item(unserialize = {}, onUpdate, onDelete, onEscape, onDragStart) {
|
||||||
|
let item, input, inputArea;
|
||||||
|
|
||||||
|
const serializeable = {
|
||||||
|
text: unserialize.text || "",
|
||||||
|
checked: unserialize.checked || false
|
||||||
|
};
|
||||||
|
|
||||||
|
item = elem().classes("todo-item", "flex", "gap-10", "items-center").append(
|
||||||
|
elem("input")
|
||||||
|
.classes("todo-item-checkbox", "shrink-0")
|
||||||
|
.styles({ marginTop: "-0.1rem" })
|
||||||
|
.attrs({ type: "checkbox" })
|
||||||
|
.on("change", (e) => {
|
||||||
|
serializeable.checked = e.target.checked;
|
||||||
|
onUpdate();
|
||||||
|
})
|
||||||
|
.tap(self => self.checked = serializeable.checked),
|
||||||
|
|
||||||
|
input = autoScalingTextarea(textarea => inputArea = textarea
|
||||||
|
.classes("todo-item-text")
|
||||||
|
.attrs({
|
||||||
|
placeholder: "empty task",
|
||||||
|
spellcheck: "false"
|
||||||
|
})
|
||||||
|
.on("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onEscape();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("input", () => {
|
||||||
|
serializeable.text = inputArea.value;
|
||||||
|
onUpdate();
|
||||||
|
})
|
||||||
|
).classes("min-width-0", "grow").append(
|
||||||
|
elem()
|
||||||
|
.classes("todo-item-drag-handle")
|
||||||
|
.on("mousedown", (e) => onDragStart(e, item))
|
||||||
|
),
|
||||||
|
|
||||||
|
elem("button")
|
||||||
|
.classes("todo-item-delete", "shrink-0")
|
||||||
|
.html(trashIconSvg)
|
||||||
|
.on("click", () => onDelete(item))
|
||||||
|
);
|
||||||
|
|
||||||
|
input.component.setValue(serializeable.text);
|
||||||
|
return item.component({
|
||||||
|
focusInput: () => inputArea.focus(),
|
||||||
|
serialize: () => serializeable
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function Todo(id) {
|
||||||
|
let items, input, inputArea, inputContainer, lastAddedItem;
|
||||||
|
let queuedForRemoval = 0;
|
||||||
|
let reorderable;
|
||||||
|
let isDragging = false;
|
||||||
|
|
||||||
|
const onDragEnd = () => isDragging = false;
|
||||||
|
const onDragStart = (event, element) => {
|
||||||
|
isDragging = true;
|
||||||
|
reorderable.component.onDragStart(event, element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveItems = () => {
|
||||||
|
if (isDragging) return;
|
||||||
|
|
||||||
|
saveToLocalStorage(
|
||||||
|
id, items.children.map(item => item.component.serialize())
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onItemRepositioned = () => saveItems();
|
||||||
|
const debouncedOnItemUpdate = throttledDebounce(saveItems, 10, 1000);
|
||||||
|
|
||||||
|
const onItemDelete = (item) => {
|
||||||
|
if (lastAddedItem === item) lastAddedItem = null;
|
||||||
|
const height = item.clientHeight;
|
||||||
|
queuedForRemoval++;
|
||||||
|
item.animate(itemAnim(height, false), () => {
|
||||||
|
item.remove();
|
||||||
|
queuedForRemoval--;
|
||||||
|
saveItems();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.children.length - queuedForRemoval === 0)
|
||||||
|
inputContainer.animate(inputMarginAnim(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const newItem = (data) => Item(
|
||||||
|
data,
|
||||||
|
debouncedOnItemUpdate,
|
||||||
|
onItemDelete,
|
||||||
|
() => inputArea.focus(),
|
||||||
|
onDragStart
|
||||||
|
);
|
||||||
|
|
||||||
|
const addNewItem = (itemText, prepend) => {
|
||||||
|
const totalItemsBeforeAppending = items.children.length;
|
||||||
|
const item = lastAddedItem = newItem({ text: itemText });
|
||||||
|
|
||||||
|
prepend ? items.prepend(item) : items.append(item);
|
||||||
|
saveItems();
|
||||||
|
const height = item.clientHeight;
|
||||||
|
item.animate(itemAnim(height));
|
||||||
|
|
||||||
|
if (totalItemsBeforeAppending === 0)
|
||||||
|
inputContainer.animate(inputMarginAnim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputKeyDown = (e) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
const value = e.target.value.trim();
|
||||||
|
if (value === "") return;
|
||||||
|
addNewItem(value, e.ctrlKey);
|
||||||
|
input.component.setValue("");
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
e.target.blur();
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
if (!lastAddedItem) return;
|
||||||
|
e.preventDefault();
|
||||||
|
lastAddedItem.component.focusInput();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
items = elem()
|
||||||
|
.classes("todo-items")
|
||||||
|
.append(
|
||||||
|
...loadFromLocalStorage(id).map(data => newItem(data))
|
||||||
|
);
|
||||||
|
|
||||||
|
return fragment().append(
|
||||||
|
inputContainer = elem()
|
||||||
|
.classes("todo-input", "flex", "gap-10", "items-center")
|
||||||
|
.classesIf(items.children.length > 0, "margin-bottom-15")
|
||||||
|
.styles({ paddingRight: "2.5rem" })
|
||||||
|
.append(
|
||||||
|
elem().classes("todo-plus-icon", "shrink-0"),
|
||||||
|
input = autoScalingTextarea(textarea => inputArea = textarea
|
||||||
|
.on("keydown", handleInputKeyDown)
|
||||||
|
.attrs({
|
||||||
|
placeholder: "Add a task",
|
||||||
|
spellcheck: "false"
|
||||||
|
})
|
||||||
|
).classes("grow", "min-width-0")
|
||||||
|
),
|
||||||
|
|
||||||
|
reorderable = verticallyReorderable(items, onItemRepositioned, onDragEnd),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// See https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/
|
||||||
|
export function autoScalingTextarea(yieldTextarea = null) {
|
||||||
|
let textarea, mimic;
|
||||||
|
|
||||||
|
const updateMimic = (newValue) => mimic.text(newValue + ' ');
|
||||||
|
const container = elem().classes("auto-scaling-textarea-container").append(
|
||||||
|
textarea = elem("textarea")
|
||||||
|
.classes("auto-scaling-textarea")
|
||||||
|
.on("input", () => updateMimic(textarea.value)),
|
||||||
|
mimic = elem().classes("auto-scaling-textarea-mimic")
|
||||||
|
)
|
||||||
|
|
||||||
|
if (typeof yieldTextarea === "function") yieldTextarea(textarea);
|
||||||
|
|
||||||
|
return container.component({ setValue: (newValue) => {
|
||||||
|
textarea.value = newValue;
|
||||||
|
updateMimic(newValue);
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verticallyReorderable(itemsContainer, onItemRepositioned, onDragEnd) {
|
||||||
|
const classToAddToDraggedItem = "is-being-dragged";
|
||||||
|
|
||||||
|
const currentlyBeingDragged = {
|
||||||
|
element: null,
|
||||||
|
initialIndex: null,
|
||||||
|
clientOffset: Vec2.new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const decoy = {
|
||||||
|
element: null,
|
||||||
|
currentIndex: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const draggableContainer = {
|
||||||
|
element: null,
|
||||||
|
initialRect: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastClientPos = Vec2.new();
|
||||||
|
let initialScrollY = null;
|
||||||
|
let addDocumentEvents, removeDocumentEvents;
|
||||||
|
|
||||||
|
const handleReposition = (event) => {
|
||||||
|
if (currentlyBeingDragged.element == null) return;
|
||||||
|
|
||||||
|
if (event.clientY !== undefined && event.clientX !== undefined)
|
||||||
|
lastClientPos.setFromEvent(event);
|
||||||
|
|
||||||
|
const client = lastClientPos;
|
||||||
|
const container = draggableContainer;
|
||||||
|
const item = currentlyBeingDragged;
|
||||||
|
|
||||||
|
const scrollOffset = window.scrollY - initialScrollY;
|
||||||
|
const offsetY = client.y - container.initialRect.y - item.clientOffset.y + scrollOffset;
|
||||||
|
const offsetX = client.x - container.initialRect.x - item.clientOffset.x;
|
||||||
|
|
||||||
|
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||||
|
const viewportWidth = window.innerWidth - scrollbarWidth;
|
||||||
|
|
||||||
|
const confinedX = clamp(
|
||||||
|
offsetX,
|
||||||
|
-container.initialRect.x,
|
||||||
|
viewportWidth - container.initialRect.x - container.initialRect.width
|
||||||
|
);
|
||||||
|
|
||||||
|
container.element.styles({
|
||||||
|
transform: `translate(${confinedX}px, ${offsetY}px)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerTop = client.y - item.clientOffset.y;
|
||||||
|
const containerBottom = client.y + container.initialRect.height - item.clientOffset.y;
|
||||||
|
|
||||||
|
let swapWithLast = true;
|
||||||
|
let swapWithIndex = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < itemsContainer.children.length; i++) {
|
||||||
|
const childRect = itemsContainer.children[i].getBoundingClientRect();
|
||||||
|
const topThreshold = childRect.top + childRect.height * .6;
|
||||||
|
const bottomThreshold = childRect.top + childRect.height * .4;
|
||||||
|
|
||||||
|
if (containerBottom > topThreshold) {
|
||||||
|
if (containerTop < bottomThreshold && i != decoy.currentIndex) {
|
||||||
|
swapWithIndex = i;
|
||||||
|
swapWithLast = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
swapWithLast = false;
|
||||||
|
|
||||||
|
if (i == decoy.currentIndex || i-1 == decoy.currentIndex) break;
|
||||||
|
swapWithIndex = (i < decoy.currentIndex) ? i : i-1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastItemIndex = itemsContainer.children.length - 1;
|
||||||
|
|
||||||
|
if (swapWithLast && decoy.currentIndex != lastItemIndex)
|
||||||
|
swapWithIndex = lastItemIndex;
|
||||||
|
|
||||||
|
if (swapWithIndex === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const diff = swapWithIndex - decoy.currentIndex;
|
||||||
|
if (Math.abs(diff) > 1) {
|
||||||
|
swapWithIndex = decoy.currentIndex + Math.sign(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblingToSwapWith = itemsContainer.children[swapWithIndex];
|
||||||
|
|
||||||
|
if (siblingToSwapWith.isCurrentlyAnimating) return;
|
||||||
|
|
||||||
|
const animateDecoy = animateReposition(decoy.element);
|
||||||
|
const animateChild = animateReposition(
|
||||||
|
siblingToSwapWith,
|
||||||
|
() => {
|
||||||
|
siblingToSwapWith.isCurrentlyAnimating = false;
|
||||||
|
handleReposition({
|
||||||
|
clientX: client.x,
|
||||||
|
clientY: client.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
siblingToSwapWith.isCurrentlyAnimating = true;
|
||||||
|
|
||||||
|
if (swapWithIndex > decoy.currentIndex)
|
||||||
|
decoy.element.before(siblingToSwapWith);
|
||||||
|
else
|
||||||
|
decoy.element.after(siblingToSwapWith);
|
||||||
|
|
||||||
|
decoy.currentIndex = itemsContainer.children.indexOf(decoy.element);
|
||||||
|
|
||||||
|
animateDecoy();
|
||||||
|
animateChild();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRelease = (event) => {
|
||||||
|
if (event.buttons != 0) return;
|
||||||
|
|
||||||
|
removeDocumentEvents();
|
||||||
|
const item = currentlyBeingDragged;
|
||||||
|
const element = item.element;
|
||||||
|
element.styles({ pointerEvents: "none" });
|
||||||
|
const animate = animateReposition(element, () => {
|
||||||
|
item.element = null;
|
||||||
|
element
|
||||||
|
.clearClasses(classToAddToDraggedItem)
|
||||||
|
.clearStyles("pointer-events");
|
||||||
|
|
||||||
|
if (typeof onDragEnd === "function") onDragEnd(element);
|
||||||
|
|
||||||
|
if (item.initialIndex != decoy.currentIndex && typeof onItemRepositioned === "function")
|
||||||
|
onItemRepositioned(element, item.initialIndex, decoy.currentIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
decoy.element.swapWith(element);
|
||||||
|
draggableContainer.element.append(decoy.element);
|
||||||
|
draggableContainer.element.clearStyles("transform", "width");
|
||||||
|
|
||||||
|
item.element = null;
|
||||||
|
decoy.element.remove();
|
||||||
|
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const preventDefault = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGrab = (event, element) => {
|
||||||
|
if (currentlyBeingDragged.element != null) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const item = currentlyBeingDragged;
|
||||||
|
if (item.element != null) return;
|
||||||
|
|
||||||
|
addDocumentEvents();
|
||||||
|
initialScrollY = window.scrollY;
|
||||||
|
const client = lastClientPos.setFromEvent(event);
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
item.element = element;
|
||||||
|
item.initialIndex = decoy.currentIndex = itemsContainer.children.indexOf(element);
|
||||||
|
item.clientOffset.set(client.x - elementRect.x, client.y - elementRect.y);
|
||||||
|
|
||||||
|
// We use getComputedStyle here to get width and height because .clientWidth and .clientHeight
|
||||||
|
// return integers and not the real float values, which can cause the decoy to be off by a pixel
|
||||||
|
const elementStyle = getComputedStyle(element);
|
||||||
|
const initialWidth = elementStyle.width;
|
||||||
|
|
||||||
|
decoy.element = elem().classes("drag-and-drop-decoy").styles({
|
||||||
|
height: elementStyle.height,
|
||||||
|
width: initialWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = draggableContainer;
|
||||||
|
|
||||||
|
element.swapWith(decoy.element);
|
||||||
|
container.element.append(element);
|
||||||
|
element.classes(classToAddToDraggedItem);
|
||||||
|
|
||||||
|
decoy.element.animate({
|
||||||
|
keyframes: [{ transform: "scale(.9)", opacity: 0, offset: 0 }],
|
||||||
|
options: { duration: 300, easing: "ease" }
|
||||||
|
})
|
||||||
|
|
||||||
|
container.element.styles({ width: initialWidth, transform: "none" });
|
||||||
|
container.initialRect = container.element.getBoundingClientRect();
|
||||||
|
|
||||||
|
const offsetY = elementRect.y - container.initialRect.y;
|
||||||
|
const offsetX = elementRect.x - container.initialRect.x;
|
||||||
|
|
||||||
|
container.element.styles({ transform: `translate(${offsetX}px, ${offsetY}px)` });
|
||||||
|
}
|
||||||
|
|
||||||
|
[addDocumentEvents, removeDocumentEvents] = toggleableEvents(document, {
|
||||||
|
"mousemove": handleReposition,
|
||||||
|
"scroll": handleReposition,
|
||||||
|
"mousedown": preventDefault,
|
||||||
|
"contextmenu": preventDefault,
|
||||||
|
"mouseup": handleRelease,
|
||||||
|
});
|
||||||
|
|
||||||
|
return elem().classes("drag-and-drop-container").append(
|
||||||
|
itemsContainer,
|
||||||
|
draggableContainer.element = elem().classes("drag-and-drop-draggable")
|
||||||
|
).component({
|
||||||
|
onDragStart: handleGrab
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -36,3 +36,46 @@ export function openURLInNewTab(url, focus = true) {
|
|||||||
|
|
||||||
if (focus && newWindow != null) newWindow.focus();
|
if (focus && newWindow != null) newWindow.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class Vec2 {
|
||||||
|
constructor(x, y) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
static new(x = 0, y = 0) {
|
||||||
|
return new Vec2(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromEvent(event) {
|
||||||
|
return new Vec2(event.clientX, event.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFromEvent(event) {
|
||||||
|
this.x = event.clientX;
|
||||||
|
this.y = event.clientY;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(x, y) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleableEvents(element, eventToHandlerMap) {
|
||||||
|
return [
|
||||||
|
() => {
|
||||||
|
for (const [event, handler] of Object.entries(eventToHandlerMap)) {
|
||||||
|
element.addEventListener(event, handler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
for (const [event, handler] of Object.entries(eventToHandlerMap)) {
|
||||||
|
element.removeEventListener(event, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Glance",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#151519",
|
|
||||||
"scope": "/",
|
|
||||||
"start_url": "/",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "app-icon.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -21,15 +21,19 @@ 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)))
|
||||||
},
|
},
|
||||||
"formatPrice": func(price float64) string {
|
"formatPrice": func(price float64) string {
|
||||||
return intl.Sprintf("%.2f", price)
|
return intl.Sprintf("%.2f", price)
|
||||||
},
|
},
|
||||||
"dynamicRelativeTimeAttrs": func(t interface{ Unix() int64 }) template.HTMLAttr {
|
"formatPriceWithPrecision": func(precision int, price float64) string {
|
||||||
return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
|
return intl.Sprintf("%."+strconv.Itoa(precision)+"f", price)
|
||||||
},
|
},
|
||||||
|
"dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs,
|
||||||
"formatServerMegabytes": func(mb uint64) template.HTML {
|
"formatServerMegabytes": func(mb uint64) template.HTML {
|
||||||
var value string
|
var value string
|
||||||
var label string
|
var label string
|
||||||
@@ -81,3 +85,7 @@ func formatApproxNumber(count int) string {
|
|||||||
|
|
||||||
return strconv.FormatFloat(float64(count)/1_000_000, 'f', 1, 64) + "m"
|
return strconv.FormatFloat(float64(count)/1_000_000, 'f', 1, 64) + "m"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dynamicRelativeTimeAttrs(t interface{ Unix() int64 }) template.HTMLAttr {
|
||||||
|
return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,22 +2,29 @@
|
|||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="dynamic-columns list-gap-24 list-with-separator">
|
<div class="dynamic-columns list-gap-24 list-with-separator">
|
||||||
{{ range .Groups }}
|
{{- range .Groups }}
|
||||||
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.String | safeCSS }}"{{ end }}>
|
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.String | safeCSS }}"{{ end }}>
|
||||||
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
{{- if ne .Title "" }}
|
||||||
|
<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>
|
||||||
|
{{- end }}
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .Links }}
|
{{- range .Links }}
|
||||||
<li class="flex items-center gap-10">
|
<li>
|
||||||
{{ if ne "" .Icon.URL }}
|
<div class="flex items-center gap-10">
|
||||||
<div class="bookmarks-icon-container">
|
{{- if ne "" .Icon.URL }}
|
||||||
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
<div class="bookmarks-icon-container">
|
||||||
|
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||||
|
</div>
|
||||||
|
{{- 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>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{- if .Description }}
|
||||||
<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>
|
<div class="margin-bottom-5">{{ .Description }}</div>
|
||||||
|
{{- end }}
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{- end }}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
{{- range .Children }}
|
{{- range .Children }}
|
||||||
<li class="flex gap-7 items-center">
|
<li class="flex gap-7 items-center">
|
||||||
<div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
|
<div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
|
||||||
<div class="color-highlight">{{ .Title }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
|
<div class="color-highlight">{{ .Name }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
|
||||||
</li>
|
</li>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -22,11 +22,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-width-0">
|
<div class="min-width-0 grow">
|
||||||
{{- if .URL }}
|
{{- if .URL }}
|
||||||
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Name }}</a>
|
||||||
{{- else }}
|
{{- else }}
|
||||||
<div class="color-highlight text-truncate size-title-dynamic">{{ .Title }}</div>
|
<div class="color-highlight text-truncate size-title-dynamic">{{ .Name }}</div>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Description }}
|
{{- if .Description }}
|
||||||
<div class="text-truncate">{{ .Description }}</div>
|
<div class="text-truncate">{{ .Description }}</div>
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html {{ block "document-root-attrs" . }}{{ end }} lang="en" id="top">
|
<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>
|
||||||
|
if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');
|
||||||
|
const pageData = {
|
||||||
|
/*{{ if .Page }}*/slug: "{{ .Page.Slug }}",/*{{ end }}*/
|
||||||
|
baseURL: "{{ .App.Config.Server.BaseURL }}",
|
||||||
|
theme: "{{ .Request.Theme.Key }}",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<title>{{ block "document-title" . }}{{ end }}</title>
|
<title>{{ block "document-title" . }}{{ end }}</title>
|
||||||
<script>if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');</script>
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Glance">
|
<meta name="apple-mobile-web-app-title" content="{{ .App.Config.Branding.AppName }}">
|
||||||
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
|
<meta name="theme-color" content="{{ .Request.Theme.BackgroundColorAsHex }}">
|
||||||
<link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
|
<link rel="apple-touch-icon" sizes="512x512" href='{{ .App.Config.Branding.AppIconURL }}'>
|
||||||
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
|
<link rel="manifest" href='{{ .App.VersionedAssetPath "manifest.json" }}'>
|
||||||
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
|
<link rel="icon" type="{{ .App.Config.Branding.FaviconType }}" href="{{ .App.Config.Branding.FaviconURL }}" />
|
||||||
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
|
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
|
||||||
<script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
|
<style id="theme-style">{{ .Request.Theme.CSS }}</style>
|
||||||
|
{{ if .App.Config.Theme.CustomCSSFile }}<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.CreatedAt.Unix }}">{{ end }}
|
||||||
{{ block "document-head-after" . }}{{ end }}
|
{{ block "document-head-after" . }}{{ end }}
|
||||||
|
{{ if .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{ template "document-body" . }}
|
{{ template "document-body" . }}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{{ if not .App.Config.Branding.HideFooter }}
|
||||||
|
<footer class="footer flex items-center flex-column">
|
||||||
|
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
||||||
|
<div>
|
||||||
|
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
{{ .App.Config.Branding.CustomFooter }}
|
||||||
|
{{ end }}
|
||||||
|
</footer>
|
||||||
|
{{ end }}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
<div class="grow min-width-0">
|
<div class="grow min-width-0">
|
||||||
<a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl | safeURL }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
{{- if .Tags }}
|
{{- if .Tags }}
|
||||||
<div class="inline-block forum-post-tags-container">
|
<div class="inline-block forum-post-tags-container">
|
||||||
<ul class="attachments">
|
<ul class="attachments">
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<ul class="list-horizontal-text flex-nowrap text-compact">
|
<ul class="list-horizontal-text flex-nowrap text-compact">
|
||||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li class="shrink-0">{{ .Score | formatApproxNumber }} points</li>
|
<li class="shrink-0">{{ .Score | formatApproxNumber }} points</li>
|
||||||
<li class="shrink-0{{ if .TargetUrl }} forum-post-autohide{{ end }}">{{ .CommentCount | formatApproxNumber }} comments</li>
|
<li class="shrink-0{{ if .TargetUrl | safeURL }} forum-post-autohide{{ end }}">{{ .CommentCount | formatApproxNumber }} comments</li>
|
||||||
{{- if .TargetUrl }}
|
{{- if .TargetUrl }}
|
||||||
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Login{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-head-before" }}
|
||||||
|
<link rel="preload" href='{{ .App.StaticAssetPath "js/templating.js" }}' as="script"/>
|
||||||
|
<link rel="prefetch" href='{{ .App.StaticAssetPath "js/page.js" }}'/>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "document-head-after" }}
|
||||||
|
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/login.css" }}'>
|
||||||
|
<script type="module" src='{{ .App.StaticAssetPath "js/login.js" }}'></script>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<div class="flex grow items-center justify-center" style="padding-bottom: 5rem">
|
||||||
|
<h1 class="visually-hidden">Login</h1>
|
||||||
|
<main id="login-container" class="grow login-bounds" style="display: none;">
|
||||||
|
<div class="animate-entrance">
|
||||||
|
<label class="form-label widget-header" for="username">Username</label>
|
||||||
|
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
|
||||||
|
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" />
|
||||||
|
</svg>
|
||||||
|
<input type="text" id="username" class="input" placeholder="Enter your username" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="animate-entrance">
|
||||||
|
<label class="form-label widget-header margin-top-20" for="password">Password</label>
|
||||||
|
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
|
||||||
|
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<input type="password" id="password" class="input" placeholder="********" autocomplete="off">
|
||||||
|
<button class="toggle-password-visibility" id="toggle-password-visibility" tabindex="-1"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-error-message" id="error-message"></div>
|
||||||
|
|
||||||
|
<button class="login-button animate-entrance" id="login-button">
|
||||||
|
<div>LOGIN</div>
|
||||||
|
<svg stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "{{ .App.Config.Branding.AppName }}",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
|
||||||
|
"theme_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "{{ .App.Config.Branding.AppIconURL }}",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,13 +11,13 @@
|
|||||||
|
|
||||||
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
||||||
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
|
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
|
||||||
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-linejoin="round" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="market-values shrink-0">
|
<div class="market-values shrink-0">
|
||||||
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
||||||
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
|
<div class="text-right">{{ .Currency }}{{ .Price | formatPriceWithPrecision .PriceHint }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
{{ 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.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="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>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
{{ if not .Status.Error }}
|
{{ if not .Status.Error }}
|
||||||
|
|||||||
@@ -2,12 +2,20 @@
|
|||||||
<div class="mobile-reachability-header">{{ .Page.Title }}</div>
|
<div class="mobile-reachability-header">{{ .Page.Title }}</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if .Page.HeadWidgets }}
|
||||||
|
<div class="head-widgets">
|
||||||
|
{{- range .Page.HeadWidgets }}
|
||||||
|
{{- .Render }}
|
||||||
|
{{- end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
<div class="page-columns">
|
<div class="page-columns">
|
||||||
{{ range .Page.Columns }}
|
{{- range .Page.Columns }}
|
||||||
<div class="page-column page-column-{{ .Size }}">
|
<div class="page-column page-column-{{ .Size }}">
|
||||||
{{ range .Widgets }}
|
{{- range .Widgets }}
|
||||||
{{ .Render }}
|
{{- .Render }}
|
||||||
{{ end }}
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,25 +2,8 @@
|
|||||||
|
|
||||||
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
|
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
|
||||||
|
|
||||||
{{ define "document-head-before" }}
|
|
||||||
<script>
|
|
||||||
const pageData = {
|
|
||||||
slug: "{{ .Page.Slug }}",
|
|
||||||
baseURL: "{{ .App.Config.Server.BaseURL }}",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }}
|
|
||||||
|
|
||||||
{{ define "document-head-after" }}
|
{{ define "document-head-after" }}
|
||||||
{{ .App.ParsedThemeStyle }}
|
<script type="module" src='{{ .App.StaticAssetPath "js/page.js" }}'></script>
|
||||||
|
|
||||||
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
|
||||||
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "navigation-links" }}
|
{{ define "navigation-links" }}
|
||||||
@@ -32,13 +15,41 @@
|
|||||||
{{ define "document-body" }}
|
{{ define "document-body" }}
|
||||||
<div class="flex flex-column body-content">
|
<div class="flex flex-column body-content">
|
||||||
{{ if not .Page.HideDesktopNavigation }}
|
{{ if not .Page.HideDesktopNavigation }}
|
||||||
<div class="header-container content-bounds">
|
<div class="header-container content-bounds{{ if .Page.DesktopNavigationWidth }} content-bounds-{{ .Page.DesktopNavigationWidth }} {{ end }}">
|
||||||
<div class="header flex padding-inline-widget widget-content-frame">
|
<div class="header flex padding-inline-widget widget-content-frame">
|
||||||
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
<div class="logo" aria-hidden="true">
|
||||||
<div class="logo" aria-hidden="true">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
|
{{- if .App.Config.Branding.LogoURL }}
|
||||||
<nav class="nav flex grow">
|
<img src="{{ .App.Config.Branding.LogoURL }}" alt="">
|
||||||
|
{{- else if .App.Config.Branding.LogoText }}
|
||||||
|
{{- .App.Config.Branding.LogoText }}
|
||||||
|
{{- else }}
|
||||||
|
<svg style="max-height: 2rem;" width="100%" viewBox="0 0 108 108" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect fill="var(--color-text-subdue)" width="50" height="108" rx="6.875" />
|
||||||
|
<path fill="var(--color-primary)" fill-rule="evenodd" clip-rule="evenodd" d="M64.875 0C61.078 0 58 3.07804 58 6.875V43.125C58 46.922 61.078 50 64.875 50H101.125C104.922 50 108 46.922 108 43.125V6.875C108 3.07804 104.922 0 101.125 0H64.875ZM75.7545 11L71.3078 15.6814H85.2233C85.9209 15.6814 86.5835 15.6633 87.2113 15.627C87.839 15.5544 88.3273 15.4093 88.6761 15.1915L70 34.5706L73.4004 38L91.8149 18.7843C91.6056 19.1835 91.4487 19.7097 91.3441 20.3629C91.2743 20.9798 91.2394 21.5968 91.2394 22.2137V37.1835L96 32.2843V11H75.7545Z"/>
|
||||||
|
<rect fill="var(--color-text-base)" x="58" y="58" width="50" height="50" rx="6.875" />
|
||||||
|
</svg>
|
||||||
|
{{- end }}
|
||||||
|
</div>
|
||||||
|
<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="current-theme-preview">
|
||||||
|
{{ .Request.Theme.PreviewHTML }}
|
||||||
|
</div>
|
||||||
|
<div data-popover-html>
|
||||||
|
<div class="theme-choices"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{- if .App.RequiresAuth }}
|
||||||
|
<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">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -49,37 +60,61 @@
|
|||||||
{{ range $i, $column := .Page.Columns }}
|
{{ 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 $i $.Page.PrimaryColumnIndex }} 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 }}
|
{{ 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>
|
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-navigation-page-links">
|
|
||||||
|
<div class="mobile-navigation-page-links hide-scrollbars">
|
||||||
{{ template "navigation-links" . }}
|
{{ template "navigation-links" . }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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 data-popover-html>
|
||||||
|
<div class="theme-choices">
|
||||||
|
{{ .App.Config.Theme.PreviewHTML }}
|
||||||
|
{{ range $_, $preset := .App.Config.Theme.Presets.Items }}
|
||||||
|
{{ $preset.PreviewHTML }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="size-h3 pointer-events-none select-none">Change theme</div>
|
||||||
|
|
||||||
|
<div class="flex gap-15 items-center pointer-events-none">
|
||||||
|
<div class="current-theme-preview">
|
||||||
|
{{ .Request.Theme.PreviewHTML }}
|
||||||
|
</div>
|
||||||
|
<svg class="ui-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if .App.RequiresAuth }}
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/logout" class="flex justify-between items-center">
|
||||||
|
<div class="size-h3">Logout</div>
|
||||||
|
<svg class="ui-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-bounds grow">
|
<div class="content-bounds grow{{ if .Page.Width }} content-bounds-{{ .Page.Width }}{{ end }}">
|
||||||
<main class="page" 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">
|
||||||
<!-- TODO: add a bigger/better loading indicator -->
|
<div class="visually-hidden">Loading</div>
|
||||||
<div class="visually-hidden">Loading</div>
|
|
||||||
<div class="loading-icon" aria-hidden="true"></div>
|
<div class="loading-icon" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ if not .App.Config.Branding.HideFooter }}
|
{{ template "footer.html" . }}
|
||||||
<footer class="footer flex items-center flex-column">
|
|
||||||
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
|
||||||
<div>
|
|
||||||
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
|
||||||
</div>
|
|
||||||
{{ else }}
|
|
||||||
{{ .App.Config.Branding.CustomFooter }}
|
|
||||||
{{ end }}
|
|
||||||
</footer>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<div class="mobile-navigation-offset"></div>
|
<div class="mobile-navigation-offset"></div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
{{ if gt (len .Repository.Commits) 0 }}
|
{{ if gt (len .Repository.Commits) 0 }}
|
||||||
<hr class="margin-block-8">
|
<hr class="margin-block-8">
|
||||||
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
|
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
|
||||||
<div class="flex gap-7 size-h5 margin-top-3">
|
<div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .Repository.Commits }}
|
{{ range .Repository.Commits }}
|
||||||
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
{{ if gt (len .Repository.PullRequests) 0 }}
|
{{ if gt (len .Repository.PullRequests) 0 }}
|
||||||
<hr class="margin-block-8">
|
<hr class="margin-block-8">
|
||||||
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)</a>
|
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)</a>
|
||||||
<div class="flex gap-7 size-h5 margin-top-3">
|
<div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .Repository.PullRequests }}
|
{{ range .Repository.PullRequests }}
|
||||||
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
{{ if gt (len .Repository.Issues) 0 }}
|
{{ if gt (len .Repository.Issues) 0 }}
|
||||||
<hr class="margin-block-10">
|
<hr class="margin-block-10">
|
||||||
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .Repository.OpenIssues | formatNumber }} total)</a>
|
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .Repository.OpenIssues | formatNumber }} total)</a>
|
||||||
<div class="flex gap-7 size-h5 margin-top-3">
|
<div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .Repository.Issues }}
|
{{ range .Repository.Issues }}
|
||||||
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}">
|
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}" data-target="{{ .Target }}">
|
||||||
<div class="search-bangs">
|
<div class="search-bangs">
|
||||||
{{ range .Bangs }}
|
{{ range .Bangs }}
|
||||||
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
|
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{{- $background := "hsl(240, 8%, 9%)" | safeCSS }}
|
||||||
|
{{- $primary := "hsl(43, 50%, 70%)" | safeCSS }}
|
||||||
|
{{- $positive := "hsl(43, 50%, 70%)" | safeCSS }}
|
||||||
|
{{- $negative := "hsl(0, 70%, 70%)" | safeCSS }}
|
||||||
|
{{- if .BackgroundColor }}{{ $background = .BackgroundColor.String | safeCSS }}{{ end }}
|
||||||
|
{{- if .PrimaryColor }}
|
||||||
|
{{- $primary = .PrimaryColor.String | safeCSS }}
|
||||||
|
{{- if not .PositiveColor }}
|
||||||
|
{{- $positive = $primary }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $positive = .PositiveColor.String | safeCSS }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .NegativeColor }}{{ $negative = .NegativeColor.String | safeCSS }}{{ end }}
|
||||||
|
<button class="theme-preset{{ if .Light }} theme-preset-light{{ end }}" style="--color: {{ $background }}" data-key="{{ .Key }}">
|
||||||
|
<div class="theme-color" style="--color: {{ $primary }}"></div>
|
||||||
|
<div class="theme-color" style="--color: {{ $positive }}"></div>
|
||||||
|
<div class="theme-color" style="--color: {{ $negative }}"></div>
|
||||||
|
</button>
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
<style>
|
|
||||||
:root {
|
:root {
|
||||||
{{ if .BackgroundColor }}
|
{{ if .BackgroundColor }}
|
||||||
--bgh: {{ .BackgroundColor.Hue }};
|
--bgh: {{ .BackgroundColor.H }};
|
||||||
--bgs: {{ .BackgroundColor.Saturation }}%;
|
--bgs: {{ .BackgroundColor.S }}%;
|
||||||
--bgl: {{ .BackgroundColor.Lightness }}%;
|
--bgl: {{ .BackgroundColor.L }}%;
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
|
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
|
||||||
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
|
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
|
||||||
@@ -11,4 +10,3 @@
|
|||||||
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.String | safeCSS }};{{ end }}
|
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.String | safeCSS }};{{ end }}
|
||||||
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.String | safeCSS }};{{ end }}
|
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.String | safeCSS }};{{ end }}
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="todo" data-todo-id="{{ .TodoID }}"></div>
|
||||||
|
{{ end }}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{{ define "video-card-contents" }}
|
{{ define "video-card-contents" }}
|
||||||
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||||
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
|
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
|
||||||
<a class="text-truncate-2-lines margin-bottom-auto color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a class="text-truncate-2-lines margin-bottom-auto color-primary-if-not-visited" href="{{ .Url | safeURL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||||
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li class="min-width-0">
|
<li class="min-width-0">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
|
<div class="widget widget-type-{{ .GetType }}{{ if .CSSClass }} {{ .CSSClass }}{{ end }}">
|
||||||
{{- if not .HideHeader}}
|
{{- if not .HideHeader }}
|
||||||
<div class="widget-header">
|
<div class="widget-header">
|
||||||
{{- if ne "" .TitleURL }}
|
{{- if ne "" .TitleURL }}
|
||||||
<h2><a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a></h2>
|
<h2><a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a></h2>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
themeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
|
||||||
|
themePresetPreviewTemplate = mustParseTemplate("theme-preset-preview.html")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *application) handleThemeChangeRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
themeKey := r.PathValue("key")
|
||||||
|
|
||||||
|
properties, exists := a.Config.Theme.Presets.Get(themeKey)
|
||||||
|
if !exists && themeKey != "default" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if themeKey == "default" {
|
||||||
|
properties = &a.Config.Theme.themeProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "theme",
|
||||||
|
Value: themeKey,
|
||||||
|
Path: a.Config.Server.BaseURL + "/",
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Expires: time.Now().Add(2 * 365 * 24 * time.Hour),
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
w.Header().Set("X-Scheme", ternary(properties.Light, "light", "dark"))
|
||||||
|
w.Write([]byte(properties.CSS))
|
||||||
|
}
|
||||||
|
|
||||||
|
type themeProperties struct {
|
||||||
|
BackgroundColor *hslColorField `yaml:"background-color"`
|
||||||
|
PrimaryColor *hslColorField `yaml:"primary-color"`
|
||||||
|
PositiveColor *hslColorField `yaml:"positive-color"`
|
||||||
|
NegativeColor *hslColorField `yaml:"negative-color"`
|
||||||
|
Light bool `yaml:"light"`
|
||||||
|
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
||||||
|
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
||||||
|
|
||||||
|
Key string `yaml:"-"`
|
||||||
|
CSS template.CSS `yaml:"-"`
|
||||||
|
PreviewHTML template.HTML `yaml:"-"`
|
||||||
|
BackgroundColorAsHex string `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *themeProperties) init() error {
|
||||||
|
css, err := executeTemplateToString(themeStyleTemplate, t)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compiling theme style: %v", err)
|
||||||
|
}
|
||||||
|
t.CSS = template.CSS(whitespaceAtBeginningOfLinePattern.ReplaceAllString(css, ""))
|
||||||
|
|
||||||
|
previewHTML, err := executeTemplateToString(themePresetPreviewTemplate, t)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compiling theme preview: %v", err)
|
||||||
|
}
|
||||||
|
t.PreviewHTML = template.HTML(previewHTML)
|
||||||
|
|
||||||
|
if t.BackgroundColor != nil {
|
||||||
|
t.BackgroundColorAsHex = t.BackgroundColor.ToHex()
|
||||||
|
} else {
|
||||||
|
t.BackgroundColorAsHex = "#151519"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t1 *themeProperties) SameAs(t2 *themeProperties) bool {
|
||||||
|
if t1 == nil && t2 == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if t1 == nil || t2 == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t1.Light != t2.Light {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t1.ContrastMultiplier != t2.ContrastMultiplier {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t1.TextSaturationMultiplier != t2.TextSaturationMultiplier {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !t1.BackgroundColor.SameAs(t2.BackgroundColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !t1.PrimaryColor.SameAs(t2.PrimaryColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !t1.PositiveColor.SameAs(t2.PositiveColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !t1.NegativeColor.SameAs(t2.NegativeColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -14,8 +15,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||||
|
var whitespaceAtBeginningOfLinePattern = regexp.MustCompile(`(?m)^\s+`)
|
||||||
|
|
||||||
func percentChange(current, previous float64) float64 {
|
func percentChange(current, previous float64) float64 {
|
||||||
|
if previous == 0 {
|
||||||
|
if current == 0 {
|
||||||
|
return 0 // 0% change if both are 0
|
||||||
|
}
|
||||||
|
return 100 // 100% increase if going from 0 to something
|
||||||
|
}
|
||||||
|
|
||||||
return (current/previous - 1) * 100
|
return (current/previous - 1) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,14 +128,6 @@ func parseRFC3339Time(t string) time.Time {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolToString(b bool, trueValue, falseValue string) string {
|
|
||||||
if b {
|
|
||||||
return trueValue
|
|
||||||
}
|
|
||||||
|
|
||||||
return falseValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeVersionFormat(version string) string {
|
func normalizeVersionFormat(version string) string {
|
||||||
version = strings.ToLower(strings.TrimSpace(version))
|
version = strings.ToLower(strings.TrimSpace(version))
|
||||||
|
|
||||||
@@ -156,15 +157,14 @@ func fileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.H
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeTemplateToHTML(t *template.Template, data interface{}) (template.HTML, error) {
|
func executeTemplateToString(t *template.Template, data any) (string, error) {
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
|
|
||||||
err := t.Execute(&b, data)
|
err := t.Execute(&b, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("executing template: %w", err)
|
return "", fmt.Errorf("executing template: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return template.HTML(b.String()), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringToBool(s string) bool {
|
func stringToBool(s string) bool {
|
||||||
@@ -190,3 +190,58 @@ func ternary[T any](condition bool, a, b T) T {
|
|||||||
// Having compile time errors about unused variables is cool and all, but I don't want to
|
// Having compile time errors about unused variables is cool and all, but I don't want to
|
||||||
// have to constantly comment out my code while I'm working on it and testing things out
|
// have to constantly comment out my code while I'm working on it and testing things out
|
||||||
func ItsUsedTrustMeBro(...any) {}
|
func ItsUsedTrustMeBro(...any) {}
|
||||||
|
|
||||||
|
func hslToHex(h, s, l float64) string {
|
||||||
|
s /= 100.0
|
||||||
|
l /= 100.0
|
||||||
|
|
||||||
|
var r, g, b float64
|
||||||
|
|
||||||
|
if s == 0 {
|
||||||
|
r, g, b = l, l, l
|
||||||
|
} else {
|
||||||
|
hueToRgb := func(p, q, t float64) float64 {
|
||||||
|
if t < 0 {
|
||||||
|
t += 1
|
||||||
|
}
|
||||||
|
if t > 1 {
|
||||||
|
t -= 1
|
||||||
|
}
|
||||||
|
if t < 1.0/6.0 {
|
||||||
|
return p + (q-p)*6.0*t
|
||||||
|
}
|
||||||
|
if t < 1.0/2.0 {
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
if t < 2.0/3.0 {
|
||||||
|
return p + (q-p)*(2.0/3.0-t)*6.0
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
q := 0.0
|
||||||
|
if l < 0.5 {
|
||||||
|
q = l * (1 + s)
|
||||||
|
} else {
|
||||||
|
q = l + s - l*s
|
||||||
|
}
|
||||||
|
|
||||||
|
p := 2*l - q
|
||||||
|
|
||||||
|
h /= 360.0
|
||||||
|
|
||||||
|
r = hueToRgb(p, q, h+1.0/3.0)
|
||||||
|
g = hueToRgb(p, q, h)
|
||||||
|
b = hueToRgb(p, q, h-1.0/3.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
ir := int(math.Round(r * 255.0))
|
||||||
|
ig := int(math.Round(g * 255.0))
|
||||||
|
ib := int(math.Round(b * 255.0))
|
||||||
|
|
||||||
|
ir = int(math.Max(0, math.Min(255, float64(ir))))
|
||||||
|
ig = int(math.Max(0, math.Min(255, float64(ig))))
|
||||||
|
ib = int(math.Max(0, math.Min(255, float64(ib))))
|
||||||
|
|
||||||
|
return fmt.Sprintf("#%02x%02x%02x", ir, ig, ib)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ type bookmarksWidget struct {
|
|||||||
HideArrow bool `yaml:"hide-arrow"`
|
HideArrow bool `yaml:"hide-arrow"`
|
||||||
Target string `yaml:"target"`
|
Target string `yaml:"target"`
|
||||||
Links []struct {
|
Links []struct {
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
Icon customIconField `yaml:"icon"`
|
Description string `yaml:"description"`
|
||||||
|
Icon customIconField `yaml:"icon"`
|
||||||
// we need a pointer to bool to know whether a value was provided,
|
// we need a pointer to bool to know whether a value was provided,
|
||||||
// however there's no way to dereference a pointer in a template so
|
// however there's no way to dereference a pointer in a template so
|
||||||
// {{ if not .SameTab }} would return true for any non-nil pointer
|
// {{ if not .SameTab }} would return true for any non-nil pointer
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package glance
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
@@ -10,6 +11,11 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
@@ -17,22 +23,42 @@ import (
|
|||||||
|
|
||||||
var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
|
var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
|
||||||
|
|
||||||
|
// Needs to be exported for the YAML unmarshaler to work
|
||||||
|
type CustomAPIRequest struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
AllowInsecure bool `yaml:"allow-insecure"`
|
||||||
|
Headers map[string]string `yaml:"headers"`
|
||||||
|
Parameters queryParametersField `yaml:"parameters"`
|
||||||
|
Method string `yaml:"method"`
|
||||||
|
BodyType string `yaml:"body-type"`
|
||||||
|
Body any `yaml:"body"`
|
||||||
|
SkipJSONValidation bool `yaml:"skip-json-validation"`
|
||||||
|
bodyReader io.ReadSeeker `yaml:"-"`
|
||||||
|
httpRequest *http.Request `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
type customAPIWidget struct {
|
type customAPIWidget struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
URL string `yaml:"url"`
|
*CustomAPIRequest `yaml:",inline"` // the primary request
|
||||||
Template string `yaml:"template"`
|
Subrequests map[string]*CustomAPIRequest `yaml:"subrequests"`
|
||||||
Frameless bool `yaml:"frameless"`
|
Options customAPIOptions `yaml:"options"`
|
||||||
Headers map[string]string `yaml:"headers"`
|
Template string `yaml:"template"`
|
||||||
APIRequest *http.Request `yaml:"-"`
|
Frameless bool `yaml:"frameless"`
|
||||||
compiledTemplate *template.Template `yaml:"-"`
|
compiledTemplate *template.Template `yaml:"-"`
|
||||||
CompiledHTML template.HTML `yaml:"-"`
|
CompiledHTML template.HTML `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *customAPIWidget) initialize() error {
|
func (widget *customAPIWidget) initialize() error {
|
||||||
widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
|
widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
|
||||||
|
|
||||||
if widget.URL == "" {
|
if err := widget.CustomAPIRequest.initialize(); err != nil {
|
||||||
return errors.New("URL is required")
|
return fmt.Errorf("initializing primary request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key := range widget.Subrequests {
|
||||||
|
if err := widget.Subrequests[key].initialize(); err != nil {
|
||||||
|
return fmt.Errorf("initializing subrequest %q: %v", key, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if widget.Template == "" {
|
if widget.Template == "" {
|
||||||
@@ -46,22 +72,13 @@ func (widget *customAPIWidget) initialize() error {
|
|||||||
|
|
||||||
widget.compiledTemplate = compiledTemplate
|
widget.compiledTemplate = compiledTemplate
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, widget.URL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, value := range widget.Headers {
|
|
||||||
req.Header.Add(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
widget.APIRequest = req
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *customAPIWidget) update(ctx context.Context) {
|
func (widget *customAPIWidget) update(ctx context.Context) {
|
||||||
compiledHTML, err := fetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
|
compiledHTML, err := fetchAndRenderCustomAPIRequest(
|
||||||
|
widget.CustomAPIRequest, widget.Subrequests, widget.Options, widget.compiledTemplate,
|
||||||
|
)
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -73,39 +90,261 @@ func (widget *customAPIWidget) Render() template.HTML {
|
|||||||
return widget.renderTemplate(widget, customAPIWidgetTemplate)
|
return widget.renderTemplate(widget, customAPIWidgetTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
|
type customAPIOptions map[string]any
|
||||||
emptyBody := template.HTML("")
|
|
||||||
|
|
||||||
resp, err := defaultHTTPClient.Do(req)
|
func (o *customAPIOptions) StringOr(key, defaultValue string) string {
|
||||||
|
return customAPIGetOptionOrDefault(*o, key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *customAPIOptions) IntOr(key string, defaultValue int) int {
|
||||||
|
return customAPIGetOptionOrDefault(*o, key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *customAPIOptions) FloatOr(key string, defaultValue float64) float64 {
|
||||||
|
return customAPIGetOptionOrDefault(*o, key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *customAPIOptions) BoolOr(key string, defaultValue bool) bool {
|
||||||
|
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 {
|
if err != nil {
|
||||||
return emptyBody, err
|
panic(fmt.Sprintf("marshaling %s: %v", key, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func customAPIGetOptionOrDefault[T any](o customAPIOptions, key string, defaultValue T) T {
|
||||||
|
if value, exists := o[key]; exists {
|
||||||
|
if typedValue, ok := value.(T); ok {
|
||||||
|
return typedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req *CustomAPIRequest) initialize() error {
|
||||||
|
if req == nil || req.URL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Body != nil {
|
||||||
|
if req.Method == "" {
|
||||||
|
req.Method = http.MethodPost
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.BodyType == "" {
|
||||||
|
req.BodyType = "json"
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.BodyType != "json" && req.BodyType != "string" {
|
||||||
|
return errors.New("invalid body type, must be either 'json' or 'string'")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.BodyType {
|
||||||
|
case "json":
|
||||||
|
encoded, err := json.Marshal(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.bodyReader = bytes.NewReader(encoded)
|
||||||
|
case "string":
|
||||||
|
bodyAsString, ok := req.Body.(string)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("body must be a string when body-type is 'string'")
|
||||||
|
}
|
||||||
|
|
||||||
|
req.bodyReader = strings.NewReader(bodyAsString)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if req.Method == "" {
|
||||||
|
req.Method = http.MethodGet
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequest(strings.ToUpper(req.Method), req.URL, req.bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Parameters) > 0 {
|
||||||
|
httpReq.URL.RawQuery = req.Parameters.toQueryString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.BodyType == "json" {
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range req.Headers {
|
||||||
|
httpReq.Header.Add(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.httpRequest = httpReq
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type customAPIResponseData struct {
|
||||||
|
JSON decoratedGJSONResult
|
||||||
|
Response *http.Response
|
||||||
|
}
|
||||||
|
|
||||||
|
type customAPITemplateData struct {
|
||||||
|
*customAPIResponseData
|
||||||
|
subrequests map[string]*customAPIResponseData
|
||||||
|
Options customAPIOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (data *customAPITemplateData) JSONLines() []decoratedGJSONResult {
|
||||||
|
result := make([]decoratedGJSONResult, 0, 5)
|
||||||
|
|
||||||
|
gjson.ForEachLine(data.JSON.Raw, func(line gjson.Result) bool {
|
||||||
|
result = append(result, decoratedGJSONResult{line})
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData {
|
||||||
|
req, exists := data.subrequests[key]
|
||||||
|
if !exists {
|
||||||
|
// We have to panic here since there's nothing sensible we can return and the
|
||||||
|
// lack of an error would cause requested data to return zero values which
|
||||||
|
// would be confusing from the user's perspective. Go's template module
|
||||||
|
// handles recovering from panics and will return the panic message as an
|
||||||
|
// error during template execution.
|
||||||
|
panic(fmt.Sprintf("subrequest with key %q has not been defined", key))
|
||||||
|
}
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCustomAPIResponse(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) {
|
||||||
|
if req == nil || req.URL == "" {
|
||||||
|
return &customAPIResponseData{
|
||||||
|
JSON: decoratedGJSONResult{gjson.Result{}},
|
||||||
|
Response: &http.Response{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.bodyReader != nil {
|
||||||
|
req.bodyReader.Seek(0, io.SeekStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := ternary(req.AllowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
|
||||||
|
resp, err := client.Do(req.httpRequest.WithContext(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.TrimSpace(string(bodyBytes))
|
||||||
|
|
||||||
|
if !req.SkipJSONValidation && body != "" && !gjson.Valid(body) {
|
||||||
|
if 200 <= resp.StatusCode && resp.StatusCode < 300 {
|
||||||
|
truncatedBody, isTruncated := limitStringLength(body, 100)
|
||||||
|
if isTruncated {
|
||||||
|
truncatedBody += "... <truncated>"
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Error("Invalid response JSON in custom API widget", "url", req.httpRequest.URL.String(), "body", truncatedBody)
|
||||||
|
return nil, errors.New("invalid response JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("%d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return &customAPIResponseData{
|
||||||
|
JSON: decoratedGJSONResult{gjson.Parse(body)},
|
||||||
|
Response: resp,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAndRenderCustomAPIRequest(
|
||||||
|
primaryReq *CustomAPIRequest,
|
||||||
|
subReqs map[string]*CustomAPIRequest,
|
||||||
|
options customAPIOptions,
|
||||||
|
tmpl *template.Template,
|
||||||
|
) (template.HTML, error) {
|
||||||
|
var primaryData *customAPIResponseData
|
||||||
|
subData := make(map[string]*customAPIResponseData, len(subReqs))
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if len(subReqs) == 0 {
|
||||||
|
// If there are no subrequests, we can fetch the primary request in a much simpler way
|
||||||
|
primaryData, err = fetchCustomAPIResponse(context.Background(), primaryReq)
|
||||||
|
} else {
|
||||||
|
// If there are subrequests, we need to fetch them concurrently
|
||||||
|
// and cancel all requests if any of them fail. There's probably
|
||||||
|
// a more elegant way to do this, but this works for now.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex // protects subData and err
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
var localErr error
|
||||||
|
primaryData, localErr = fetchCustomAPIResponse(ctx, primaryReq)
|
||||||
|
mu.Lock()
|
||||||
|
if localErr != nil && err == nil {
|
||||||
|
err = localErr
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for key, req := range subReqs {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
var localErr error
|
||||||
|
var data *customAPIResponseData
|
||||||
|
data, localErr = fetchCustomAPIResponse(ctx, req)
|
||||||
|
mu.Lock()
|
||||||
|
if localErr == nil {
|
||||||
|
subData[key] = data
|
||||||
|
} else if err == nil {
|
||||||
|
err = localErr
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyBody := template.HTML("")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return emptyBody, err
|
return emptyBody, err
|
||||||
}
|
}
|
||||||
|
|
||||||
body := string(bodyBytes)
|
data := customAPITemplateData{
|
||||||
|
customAPIResponseData: primaryData,
|
||||||
if !gjson.Valid(body) {
|
subrequests: subData,
|
||||||
truncatedBody, isTruncated := limitStringLength(body, 100)
|
Options: options,
|
||||||
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
|
var templateBuffer bytes.Buffer
|
||||||
|
|
||||||
data := customAPITemplateData{
|
|
||||||
JSON: decoratedGJSONResult{gjson.Parse(body)},
|
|
||||||
Response: resp,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tmpl.Execute(&templateBuffer, &data)
|
err = tmpl.Execute(&templateBuffer, &data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return emptyBody, err
|
return emptyBody, err
|
||||||
@@ -118,11 +357,6 @@ type decoratedGJSONResult struct {
|
|||||||
gjson.Result
|
gjson.Result
|
||||||
}
|
}
|
||||||
|
|
||||||
type customAPITemplateData struct {
|
|
||||||
JSON decoratedGJSONResult
|
|
||||||
Response *http.Response
|
|
||||||
}
|
|
||||||
|
|
||||||
func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult {
|
func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult {
|
||||||
decoratedResults := make([]decoratedGJSONResult, len(results))
|
decoratedResults := make([]decoratedGJSONResult, len(results))
|
||||||
|
|
||||||
@@ -134,7 +368,7 @@ func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedG
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *decoratedGJSONResult) Exists(key string) bool {
|
func (r *decoratedGJSONResult) Exists(key string) bool {
|
||||||
return r.Get(key).Exists()
|
return r.Result.Get(key).Exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
|
func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
|
||||||
@@ -142,7 +376,7 @@ func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
|
|||||||
return gJsonResultArrayToDecoratedResultArray(r.Result.Array())
|
return gJsonResultArrayToDecoratedResultArray(r.Result.Array())
|
||||||
}
|
}
|
||||||
|
|
||||||
return gJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
|
return gJsonResultArrayToDecoratedResultArray(r.Result.Get(key).Array())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *decoratedGJSONResult) String(key string) string {
|
func (r *decoratedGJSONResult) String(key string) string {
|
||||||
@@ -150,7 +384,7 @@ func (r *decoratedGJSONResult) String(key string) string {
|
|||||||
return r.Result.String()
|
return r.Result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
return r.Get(key).String()
|
return r.Result.Get(key).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *decoratedGJSONResult) Int(key string) int {
|
func (r *decoratedGJSONResult) Int(key string) int {
|
||||||
@@ -158,7 +392,7 @@ func (r *decoratedGJSONResult) Int(key string) int {
|
|||||||
return int(r.Result.Int())
|
return int(r.Result.Int())
|
||||||
}
|
}
|
||||||
|
|
||||||
return int(r.Get(key).Int())
|
return int(r.Result.Get(key).Int())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *decoratedGJSONResult) Float(key string) float64 {
|
func (r *decoratedGJSONResult) Float(key string) float64 {
|
||||||
@@ -166,7 +400,7 @@ func (r *decoratedGJSONResult) Float(key string) float64 {
|
|||||||
return r.Result.Float()
|
return r.Result.Float()
|
||||||
}
|
}
|
||||||
|
|
||||||
return r.Get(key).Float()
|
return r.Result.Get(key).Float()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *decoratedGJSONResult) Bool(key string) bool {
|
func (r *decoratedGJSONResult) Bool(key string) bool {
|
||||||
@@ -174,10 +408,72 @@ func (r *decoratedGJSONResult) Bool(key string) bool {
|
|||||||
return r.Result.Bool()
|
return r.Result.Bool()
|
||||||
}
|
}
|
||||||
|
|
||||||
return r.Get(key).Bool()
|
return r.Result.Get(key).Bool()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *decoratedGJSONResult) Get(key string) *decoratedGJSONResult {
|
||||||
|
return &decoratedGJSONResult{r.Result.Get(key)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func customAPIDoMathOp[T int | float64](a, b T, op string) T {
|
||||||
|
switch op {
|
||||||
|
case "add":
|
||||||
|
return a + b
|
||||||
|
case "sub":
|
||||||
|
return a - b
|
||||||
|
case "mul":
|
||||||
|
return a * b
|
||||||
|
case "div":
|
||||||
|
if b == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return a / b
|
||||||
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var customAPITemplateFuncs = func() template.FuncMap {
|
var customAPITemplateFuncs = func() template.FuncMap {
|
||||||
|
var regexpCacheMu sync.Mutex
|
||||||
|
var regexpCache = make(map[string]*regexp.Regexp)
|
||||||
|
|
||||||
|
getCachedRegexp := func(pattern string) *regexp.Regexp {
|
||||||
|
regexpCacheMu.Lock()
|
||||||
|
defer regexpCacheMu.Unlock()
|
||||||
|
|
||||||
|
regex, exists := regexpCache[pattern]
|
||||||
|
if !exists {
|
||||||
|
regex = regexp.MustCompile(pattern)
|
||||||
|
regexpCache[pattern] = regex
|
||||||
|
}
|
||||||
|
|
||||||
|
return regex
|
||||||
|
}
|
||||||
|
|
||||||
|
doMathOpWithAny := func(a, b any, op string) any {
|
||||||
|
switch at := a.(type) {
|
||||||
|
case int:
|
||||||
|
switch bt := b.(type) {
|
||||||
|
case int:
|
||||||
|
return customAPIDoMathOp(at, bt, op)
|
||||||
|
case float64:
|
||||||
|
return customAPIDoMathOp(float64(at), bt, op)
|
||||||
|
default:
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
switch bt := b.(type) {
|
||||||
|
case int:
|
||||||
|
return customAPIDoMathOp(at, float64(bt), op)
|
||||||
|
case float64:
|
||||||
|
return customAPIDoMathOp(at, bt, op)
|
||||||
|
default:
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
funcs := template.FuncMap{
|
funcs := template.FuncMap{
|
||||||
"toFloat": func(a int) float64 {
|
"toFloat": func(a int) float64 {
|
||||||
return float64(a)
|
return float64(a)
|
||||||
@@ -185,21 +481,202 @@ var customAPITemplateFuncs = func() template.FuncMap {
|
|||||||
"toInt": func(a float64) int {
|
"toInt": func(a float64) int {
|
||||||
return int(a)
|
return int(a)
|
||||||
},
|
},
|
||||||
"add": func(a, b float64) float64 {
|
"add": func(a, b any) any {
|
||||||
return a + b
|
return doMathOpWithAny(a, b, "add")
|
||||||
},
|
},
|
||||||
"sub": func(a, b float64) float64 {
|
"sub": func(a, b any) any {
|
||||||
return a - b
|
return doMathOpWithAny(a, b, "sub")
|
||||||
},
|
},
|
||||||
"mul": func(a, b float64) float64 {
|
"mul": func(a, b any) any {
|
||||||
return a * b
|
return doMathOpWithAny(a, b, "mul")
|
||||||
},
|
},
|
||||||
"div": func(a, b float64) float64 {
|
"div": func(a, b any) any {
|
||||||
|
return doMathOpWithAny(a, b, "div")
|
||||||
|
},
|
||||||
|
"mod": func(a, b int) int {
|
||||||
if b == 0 {
|
if b == 0 {
|
||||||
return math.NaN()
|
return 0
|
||||||
|
}
|
||||||
|
return a % b
|
||||||
|
},
|
||||||
|
"now": func() time.Time {
|
||||||
|
return time.Now()
|
||||||
|
},
|
||||||
|
"offsetNow": func(offset string) time.Time {
|
||||||
|
d, err := time.ParseDuration(offset)
|
||||||
|
if err != nil {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
return time.Now().Add(d)
|
||||||
|
},
|
||||||
|
"duration": func(str string) time.Duration {
|
||||||
|
d, err := time.ParseDuration(str)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return a / b
|
return d
|
||||||
|
},
|
||||||
|
"parseTime": func(layout, value string) time.Time {
|
||||||
|
return customAPIFuncParseTimeInLocation(layout, value, time.UTC)
|
||||||
|
},
|
||||||
|
"formatTime": customAPIFuncFormatTime,
|
||||||
|
"parseLocalTime": func(layout, value string) time.Time {
|
||||||
|
return customAPIFuncParseTimeInLocation(layout, value, time.Local)
|
||||||
|
},
|
||||||
|
"toRelativeTime": dynamicRelativeTimeAttrs,
|
||||||
|
"parseRelativeTime": func(layout, value string) template.HTMLAttr {
|
||||||
|
// Shorthand to do both of the above with a single function call
|
||||||
|
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:
|
||||||
|
// {{ .JSON.String "foo" | trimPrefix "bar" | doSomethingElse }}
|
||||||
|
// instead of doing this:
|
||||||
|
// {{ trimPrefix (.JSON.String "foo") "bar" | doSomethingElse }}
|
||||||
|
// since the piped value gets passed as the last argument to the function.
|
||||||
|
"trimPrefix": func(prefix, s string) string {
|
||||||
|
return strings.TrimPrefix(s, prefix)
|
||||||
|
},
|
||||||
|
"trimSuffix": func(suffix, s string) string {
|
||||||
|
return strings.TrimSuffix(s, suffix)
|
||||||
|
},
|
||||||
|
"trimSpace": strings.TrimSpace,
|
||||||
|
"replaceAll": func(old, new, s string) string {
|
||||||
|
return strings.ReplaceAll(s, old, new)
|
||||||
|
},
|
||||||
|
"replaceMatches": func(pattern, replacement, s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCachedRegexp(pattern).ReplaceAllString(s, replacement)
|
||||||
|
},
|
||||||
|
"findMatch": func(pattern, s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCachedRegexp(pattern).FindString(s)
|
||||||
|
},
|
||||||
|
"findSubmatch": func(pattern, s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
regex := getCachedRegexp(pattern)
|
||||||
|
return itemAtIndexOrDefault(regex.FindStringSubmatch(s), 1, "")
|
||||||
|
},
|
||||||
|
"percentChange": percentChange,
|
||||||
|
"sortByString": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
|
||||||
|
sort.Slice(results, func(a, b int) bool {
|
||||||
|
if order == "asc" {
|
||||||
|
return results[a].String(key) < results[b].String(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results[a].String(key) > results[b].String(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
},
|
||||||
|
"sortByInt": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
|
||||||
|
sort.Slice(results, func(a, b int) bool {
|
||||||
|
if order == "asc" {
|
||||||
|
return results[a].Int(key) < results[b].Int(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results[a].Int(key) > results[b].Int(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
},
|
||||||
|
"sortByFloat": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
|
||||||
|
sort.Slice(results, func(a, b int) bool {
|
||||||
|
if order == "asc" {
|
||||||
|
return results[a].Float(key) < results[b].Float(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results[a].Float(key) > results[b].Float(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
},
|
||||||
|
"sortByTime": func(key, layout, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
|
||||||
|
sort.Slice(results, func(a, b int) bool {
|
||||||
|
timeA := customAPIFuncParseTimeInLocation(layout, results[a].String(key), time.UTC)
|
||||||
|
timeB := customAPIFuncParseTimeInLocation(layout, results[b].String(key), time.UTC)
|
||||||
|
|
||||||
|
if order == "asc" {
|
||||||
|
return timeA.Before(timeB)
|
||||||
|
}
|
||||||
|
|
||||||
|
return timeA.After(timeB)
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
},
|
||||||
|
"concat": func(items ...string) string {
|
||||||
|
return strings.Join(items, "")
|
||||||
|
},
|
||||||
|
"unique": func(key string, results []decoratedGJSONResult) []decoratedGJSONResult {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
out := make([]decoratedGJSONResult, 0, len(results))
|
||||||
|
for _, result := range results {
|
||||||
|
val := result.String(key)
|
||||||
|
if _, ok := seen[val]; !ok {
|
||||||
|
seen[val] = struct{}{}
|
||||||
|
out = append(out, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
},
|
||||||
|
"newRequest": func(url string) *CustomAPIRequest {
|
||||||
|
return &CustomAPIRequest{
|
||||||
|
URL: url,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"withHeader": func(key, value string, req *CustomAPIRequest) *CustomAPIRequest {
|
||||||
|
if req.Headers == nil {
|
||||||
|
req.Headers = make(map[string]string)
|
||||||
|
}
|
||||||
|
req.Headers[key] = value
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
"withParameter": func(key, value string, req *CustomAPIRequest) *CustomAPIRequest {
|
||||||
|
if req.Parameters == nil {
|
||||||
|
req.Parameters = make(queryParametersField)
|
||||||
|
}
|
||||||
|
req.Parameters[key] = append(req.Parameters[key], value)
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
"withStringBody": func(body string, req *CustomAPIRequest) *CustomAPIRequest {
|
||||||
|
req.Body = body
|
||||||
|
req.BodyType = "string"
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
"getResponse": func(req *CustomAPIRequest) *customAPIResponseData {
|
||||||
|
err := req.initialize()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("initializing request: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := fetchCustomAPIResponse(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Could not fetch response within custom API template", "error", err)
|
||||||
|
return &customAPIResponseData{
|
||||||
|
JSON: decoratedGJSONResult{gjson.Result{}},
|
||||||
|
Response: &http.Response{
|
||||||
|
Status: err.Error(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,3 +688,47 @@ var customAPITemplateFuncs = func() template.FuncMap {
|
|||||||
|
|
||||||
return funcs
|
return funcs
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
func customAPIFuncFormatTime(layout string, t time.Time) string {
|
||||||
|
switch strings.ToLower(layout) {
|
||||||
|
case "unix":
|
||||||
|
return strconv.FormatInt(t.Unix(), 10)
|
||||||
|
case "rfc3339":
|
||||||
|
layout = time.RFC3339
|
||||||
|
case "rfc3339nano":
|
||||||
|
layout = time.RFC3339Nano
|
||||||
|
case "datetime":
|
||||||
|
layout = time.DateTime
|
||||||
|
case "dateonly":
|
||||||
|
layout = time.DateOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.Format(layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func customAPIFuncParseTimeInLocation(layout, value string, loc *time.Location) time.Time {
|
||||||
|
switch strings.ToLower(layout) {
|
||||||
|
case "unix":
|
||||||
|
asInt, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return time.Unix(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Unix(asInt, 0)
|
||||||
|
case "rfc3339":
|
||||||
|
layout = time.RFC3339
|
||||||
|
case "rfc3339nano":
|
||||||
|
layout = time.RFC3339Nano
|
||||||
|
case "datetime":
|
||||||
|
layout = time.DateTime
|
||||||
|
case "dateonly":
|
||||||
|
layout = time.DateOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := time.ParseInLocation(layout, value, loc)
|
||||||
|
if err != nil {
|
||||||
|
return time.Unix(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ type dnsStatsWidget struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
dnsServiceAdguard = "adguard"
|
dnsServiceAdguard = "adguard"
|
||||||
dnsServicePihole = "pihole"
|
dnsServicePihole = "pihole"
|
||||||
dnsServicePiholeV6 = "pihole-v6"
|
dnsServiceTechnitium = "technitium"
|
||||||
|
dnsServicePiholeV6 = "pihole-v6"
|
||||||
)
|
)
|
||||||
|
|
||||||
func makeDNSWidgetTimeLabels(format string) [8]string {
|
func makeDNSWidgetTimeLabels(format string) [8]string {
|
||||||
@@ -60,17 +61,24 @@ func makeDNSWidgetTimeLabels(format string) [8]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (widget *dnsStatsWidget) initialize() error {
|
func (widget *dnsStatsWidget) initialize() error {
|
||||||
|
titleURL := strings.TrimRight(widget.URL, "/")
|
||||||
|
switch widget.Service {
|
||||||
|
case dnsServicePihole, dnsServicePiholeV6:
|
||||||
|
titleURL = titleURL + "/admin"
|
||||||
|
}
|
||||||
|
|
||||||
widget.
|
widget.
|
||||||
withTitle("DNS Stats").
|
withTitle("DNS Stats").
|
||||||
withTitleURL(string(widget.URL)).
|
withTitleURL(titleURL).
|
||||||
withCacheDuration(10 * time.Minute)
|
withCacheDuration(10 * time.Minute)
|
||||||
|
|
||||||
switch widget.Service {
|
switch widget.Service {
|
||||||
case dnsServiceAdguard:
|
case dnsServiceAdguard:
|
||||||
case dnsServicePiholeV6:
|
case dnsServicePiholeV6:
|
||||||
case dnsServicePihole:
|
case dnsServicePihole:
|
||||||
|
case dnsServiceTechnitium:
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("service must be one of: %s, %s, %s", dnsServiceAdguard, dnsServicePihole, dnsServicePiholeV6)
|
return fmt.Errorf("service must be one of: %s, %s, %s, %s", dnsServiceAdguard, dnsServicePihole, dnsServicePiholeV6, dnsServiceTechnitium)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -85,6 +93,8 @@ func (widget *dnsStatsWidget) update(ctx context.Context) {
|
|||||||
stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph)
|
stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph)
|
||||||
case dnsServicePihole:
|
case dnsServicePihole:
|
||||||
stats, err = fetchPihole5Stats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
|
stats, err = fetchPihole5Stats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
|
||||||
|
case dnsServiceTechnitium:
|
||||||
|
stats, err = fetchTechnitiumStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
|
||||||
case dnsServicePiholeV6:
|
case dnsServicePiholeV6:
|
||||||
var newSessionID string
|
var newSessionID string
|
||||||
stats, newSessionID, err = fetchPiholeStats(
|
stats, newSessionID, err = fetchPiholeStats(
|
||||||
@@ -672,3 +682,139 @@ func checkPiholeSessionIDIsValid(instanceURL string, client *http.Client, sessio
|
|||||||
|
|
||||||
return response.StatusCode == http.StatusOK, nil
|
return response.StatusCode == http.StatusOK, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type technitiumStatsResponse struct {
|
||||||
|
Response struct {
|
||||||
|
Stats struct {
|
||||||
|
TotalQueries int `json:"totalQueries"`
|
||||||
|
BlockedQueries int `json:"totalBlocked"`
|
||||||
|
BlockedZones int `json:"blockedZones"`
|
||||||
|
BlockListZones int `json:"blockListZones"`
|
||||||
|
} `json:"stats"`
|
||||||
|
MainChartData struct {
|
||||||
|
Datasets []struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Data []int `json:"data"`
|
||||||
|
} `json:"datasets"`
|
||||||
|
} `json:"mainChartData"`
|
||||||
|
TopBlockedDomains []struct {
|
||||||
|
Domain string `json:"name"`
|
||||||
|
Count int `json:"hits"`
|
||||||
|
}
|
||||||
|
} `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTechnitiumStats(instanceUrl string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) {
|
||||||
|
if token == "" {
|
||||||
|
return nil, errors.New("missing API token")
|
||||||
|
}
|
||||||
|
|
||||||
|
requestURL := strings.TrimRight(instanceUrl, "/") + "/api/dashboard/stats/get?token=" + token + "&type=LastDay"
|
||||||
|
|
||||||
|
request, err := http.NewRequest("GET", requestURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var client requestDoer
|
||||||
|
if !allowInsecure {
|
||||||
|
client = defaultHTTPClient
|
||||||
|
} else {
|
||||||
|
client = defaultInsecureHTTPClient
|
||||||
|
}
|
||||||
|
|
||||||
|
responseJson, err := decodeJsonFromRequest[technitiumStatsResponse](client, request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var topBlockedDomainsCount = min(len(responseJson.Response.TopBlockedDomains), 5)
|
||||||
|
|
||||||
|
stats := &dnsStats{
|
||||||
|
TotalQueries: responseJson.Response.Stats.TotalQueries,
|
||||||
|
BlockedQueries: responseJson.Response.Stats.BlockedQueries,
|
||||||
|
TopBlockedDomains: make([]dnsStatsBlockedDomain, 0, topBlockedDomainsCount),
|
||||||
|
DomainsBlocked: responseJson.Response.Stats.BlockedZones + responseJson.Response.Stats.BlockListZones,
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.TotalQueries <= 0 {
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.BlockedPercent = int(float64(responseJson.Response.Stats.BlockedQueries) / float64(responseJson.Response.Stats.TotalQueries) * 100)
|
||||||
|
|
||||||
|
for i := 0; i < topBlockedDomainsCount; i++ {
|
||||||
|
domain := responseJson.Response.TopBlockedDomains[i]
|
||||||
|
firstDomain := domain.Domain
|
||||||
|
|
||||||
|
if firstDomain == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.TopBlockedDomains = append(stats.TopBlockedDomains, dnsStatsBlockedDomain{
|
||||||
|
Domain: firstDomain,
|
||||||
|
})
|
||||||
|
|
||||||
|
if stats.BlockedQueries > 0 {
|
||||||
|
stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain.Count) / float64(responseJson.Response.Stats.BlockedQueries) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if noGraph {
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var queriesSeries, blockedSeries []int
|
||||||
|
|
||||||
|
for _, label := range responseJson.Response.MainChartData.Datasets {
|
||||||
|
switch label.Label {
|
||||||
|
case "Total":
|
||||||
|
queriesSeries = label.Data
|
||||||
|
case "Blocked":
|
||||||
|
blockedSeries = label.Data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(queriesSeries) > dnsStatsHoursSpan {
|
||||||
|
queriesSeries = queriesSeries[len(queriesSeries)-dnsStatsHoursSpan:]
|
||||||
|
} else if len(queriesSeries) < dnsStatsHoursSpan {
|
||||||
|
queriesSeries = append(make([]int, dnsStatsHoursSpan-len(queriesSeries)), queriesSeries...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(blockedSeries) > dnsStatsHoursSpan {
|
||||||
|
blockedSeries = blockedSeries[len(blockedSeries)-dnsStatsHoursSpan:]
|
||||||
|
} else if len(blockedSeries) < dnsStatsHoursSpan {
|
||||||
|
blockedSeries = append(make([]int, dnsStatsHoursSpan-len(blockedSeries)), blockedSeries...)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxQueriesInSeries := 0
|
||||||
|
|
||||||
|
for i := 0; i < dnsStatsBars; i++ {
|
||||||
|
queries := 0
|
||||||
|
blocked := 0
|
||||||
|
|
||||||
|
for j := 0; j < dnsStatsHoursPerBar; j++ {
|
||||||
|
queries += queriesSeries[i*dnsStatsHoursPerBar+j]
|
||||||
|
blocked += blockedSeries[i*dnsStatsHoursPerBar+j]
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Series[i] = dnsStatsSeries{
|
||||||
|
Queries: queries,
|
||||||
|
Blocked: blocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
if queries > 0 {
|
||||||
|
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
if queries > maxQueriesInSeries {
|
||||||
|
maxQueriesInSeries = queries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < dnsStatsBars; i++ {
|
||||||
|
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,10 +16,14 @@ import (
|
|||||||
var dockerContainersWidgetTemplate = mustParseTemplate("docker-containers.html", "widget-base.html")
|
var dockerContainersWidgetTemplate = mustParseTemplate("docker-containers.html", "widget-base.html")
|
||||||
|
|
||||||
type dockerContainersWidget struct {
|
type dockerContainersWidget struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
HideByDefault bool `yaml:"hide-by-default"`
|
HideByDefault bool `yaml:"hide-by-default"`
|
||||||
SockPath string `yaml:"sock-path"`
|
RunningOnly bool `yaml:"running-only"`
|
||||||
Containers dockerContainerList `yaml:"-"`
|
Category string `yaml:"category"`
|
||||||
|
SockPath string `yaml:"sock-path"`
|
||||||
|
FormatContainerNames bool `yaml:"format-container-names"`
|
||||||
|
Containers dockerContainerList `yaml:"-"`
|
||||||
|
LabelOverrides map[string]map[string]string `yaml:"containers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *dockerContainersWidget) initialize() error {
|
func (widget *dockerContainersWidget) initialize() error {
|
||||||
@@ -32,7 +37,14 @@ func (widget *dockerContainersWidget) initialize() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (widget *dockerContainersWidget) update(ctx context.Context) {
|
func (widget *dockerContainersWidget) update(ctx context.Context) {
|
||||||
containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault)
|
containers, err := fetchDockerContainers(
|
||||||
|
widget.SockPath,
|
||||||
|
widget.HideByDefault,
|
||||||
|
widget.Category,
|
||||||
|
widget.RunningOnly,
|
||||||
|
widget.FormatContainerNames,
|
||||||
|
widget.LabelOverrides,
|
||||||
|
)
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -54,6 +66,7 @@ const (
|
|||||||
dockerContainerLabelIcon = "glance.icon"
|
dockerContainerLabelIcon = "glance.icon"
|
||||||
dockerContainerLabelID = "glance.id"
|
dockerContainerLabelID = "glance.id"
|
||||||
dockerContainerLabelParent = "glance.parent"
|
dockerContainerLabelParent = "glance.parent"
|
||||||
|
dockerContainerLabelCategory = "glance.category"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -98,7 +111,7 @@ func (l *dockerContainerLabels) getOrDefault(label, def string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type dockerContainer struct {
|
type dockerContainer struct {
|
||||||
Title string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
SameTab bool
|
SameTab bool
|
||||||
Image string
|
Image string
|
||||||
@@ -120,7 +133,7 @@ func (containers dockerContainerList) sortByStateIconThenTitle() {
|
|||||||
return (*p)[containers[a].StateIcon] < (*p)[containers[b].StateIcon]
|
return (*p)[containers[a].StateIcon] < (*p)[containers[b].StateIcon]
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.ToLower(containers[a].Title) < strings.ToLower(containers[b].Title)
|
return strings.ToLower(containers[a].Name) < strings.ToLower(containers[b].Name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,8 +150,15 @@ func dockerContainerStateToStateIcon(state string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContainerList, error) {
|
func fetchDockerContainers(
|
||||||
containers, err := fetchAllDockerContainersFromSock(socketPath)
|
socketPath string,
|
||||||
|
hideByDefault bool,
|
||||||
|
category string,
|
||||||
|
runningOnly bool,
|
||||||
|
formatNames bool,
|
||||||
|
labelOverrides map[string]map[string]string,
|
||||||
|
) (dockerContainerList, error) {
|
||||||
|
containers, err := fetchDockerContainersFromSource(socketPath, category, runningOnly, labelOverrides)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetching containers: %w", err)
|
return nil, fmt.Errorf("fetching containers: %w", err)
|
||||||
}
|
}
|
||||||
@@ -150,7 +170,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain
|
|||||||
container := &containers[i]
|
container := &containers[i]
|
||||||
|
|
||||||
dc := dockerContainer{
|
dc := dockerContainer{
|
||||||
Title: deriveDockerContainerTitle(container),
|
Name: deriveDockerContainerName(container, formatNames),
|
||||||
URL: container.Labels.getOrDefault(dockerContainerLabelURL, ""),
|
URL: container.Labels.getOrDefault(dockerContainerLabelURL, ""),
|
||||||
Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""),
|
Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""),
|
||||||
SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")),
|
SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")),
|
||||||
@@ -165,7 +185,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain
|
|||||||
for i := range children {
|
for i := range children {
|
||||||
child := &children[i]
|
child := &children[i]
|
||||||
dc.Children = append(dc.Children, dockerContainer{
|
dc.Children = append(dc.Children, dockerContainer{
|
||||||
Title: deriveDockerContainerTitle(child),
|
Name: deriveDockerContainerName(child, formatNames),
|
||||||
StateText: child.Status,
|
StateText: child.Status,
|
||||||
StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)),
|
StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)),
|
||||||
})
|
})
|
||||||
@@ -193,12 +213,31 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain
|
|||||||
return dockerContainers, nil
|
return dockerContainers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string {
|
func deriveDockerContainerName(container *dockerContainerJsonResponse, formatNames bool) string {
|
||||||
if v := container.Labels.getOrDefault(dockerContainerLabelName, ""); v != "" {
|
if v := container.Labels.getOrDefault(dockerContainerLabelName, ""); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, "n/a"), "/")
|
if len(container.Names) == 0 || container.Names[0] == "" {
|
||||||
|
return "n/a"
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimLeft(container.Names[0], "/")
|
||||||
|
|
||||||
|
if formatNames {
|
||||||
|
name = strings.ReplaceAll(name, "_", " ")
|
||||||
|
name = strings.ReplaceAll(name, "-", " ")
|
||||||
|
|
||||||
|
words := strings.Split(name, " ")
|
||||||
|
for i := range words {
|
||||||
|
if len(words[i]) > 0 {
|
||||||
|
words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name = strings.Join(words, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
func groupDockerContainerChildren(
|
func groupDockerContainerChildren(
|
||||||
@@ -239,17 +278,46 @@ func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefau
|
|||||||
return hideByDefault
|
return hideByDefault
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) {
|
|
||||||
client := &http.Client{
|
func fetchDockerContainersFromSource(
|
||||||
Timeout: 5 * time.Second,
|
source string,
|
||||||
Transport: &http.Transport{
|
category string,
|
||||||
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
runningOnly bool,
|
||||||
return net.Dial("unix", socketPath)
|
labelOverrides map[string]map[string]string,
|
||||||
|
) ([]dockerContainerJsonResponse, error) {
|
||||||
|
var hostname string
|
||||||
|
|
||||||
|
var client *http.Client
|
||||||
|
if strings.HasPrefix(source, "tcp://") || strings.HasPrefix(source, "http://") {
|
||||||
|
client = &http.Client{}
|
||||||
|
parsed, err := url.Parse(source)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port := parsed.Port()
|
||||||
|
if port == "" {
|
||||||
|
port = "80"
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname = parsed.Hostname() + ":" + port
|
||||||
|
} else {
|
||||||
|
hostname = "docker"
|
||||||
|
client = &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial("unix", source)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request, err := http.NewRequest("GET", "http://docker/containers/json?all=true", nil)
|
|
||||||
|
fetchAll := ternary(runningOnly, "false", "true")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
request, err := http.NewRequestWithContext(ctx, "GET", "http://"+hostname+"/containers/json?all="+fetchAll, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("creating request: %w", err)
|
return nil, fmt.Errorf("creating request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -269,5 +337,43 @@ func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonR
|
|||||||
return nil, fmt.Errorf("decoding response: %w", err)
|
return nil, fmt.Errorf("decoding response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i := range containers {
|
||||||
|
container := &containers[i]
|
||||||
|
name := strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, ""), "/")
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
overrides, ok := labelOverrides[name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if container.Labels == nil {
|
||||||
|
container.Labels = make(dockerContainerLabels)
|
||||||
|
}
|
||||||
|
|
||||||
|
for label, value := range overrides {
|
||||||
|
container.Labels["glance."+label] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have to filter here instead of using the `filters` parameter of Docker's API
|
||||||
|
// because the user may define a category override within their config
|
||||||
|
if category != "" {
|
||||||
|
filtered := make([]dockerContainerJsonResponse, 0, len(containers))
|
||||||
|
|
||||||
|
for i := range containers {
|
||||||
|
container := &containers[i]
|
||||||
|
|
||||||
|
if container.Labels.getOrDefault(dockerContainerLabelCategory, "") == category {
|
||||||
|
filtered = append(filtered, *container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
containers = filtered
|
||||||
|
}
|
||||||
|
|
||||||
return containers, nil
|
return containers, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ const extensionWidgetDefaultTitle = "Extension"
|
|||||||
|
|
||||||
type extensionWidget struct {
|
type extensionWidget struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
FallbackContentType string `yaml:"fallback-content-type"`
|
FallbackContentType string `yaml:"fallback-content-type"`
|
||||||
Parameters map[string]string `yaml:"parameters"`
|
Parameters queryParametersField `yaml:"parameters"`
|
||||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
Headers map[string]string `yaml:"headers"`
|
||||||
Extension extension `yaml:"-"`
|
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||||
cachedHTML template.HTML `yaml:"-"`
|
Extension extension `yaml:"-"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *extensionWidget) initialize() error {
|
func (widget *extensionWidget) initialize() error {
|
||||||
@@ -46,6 +47,7 @@ func (widget *extensionWidget) update(ctx context.Context) {
|
|||||||
URL: widget.URL,
|
URL: widget.URL,
|
||||||
FallbackContentType: widget.FallbackContentType,
|
FallbackContentType: widget.FallbackContentType,
|
||||||
Parameters: widget.Parameters,
|
Parameters: widget.Parameters,
|
||||||
|
Headers: widget.Headers,
|
||||||
AllowHtml: widget.AllowHtml,
|
AllowHtml: widget.AllowHtml,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -57,6 +59,10 @@ func (widget *extensionWidget) update(ctx context.Context) {
|
|||||||
widget.Title = extension.Title
|
widget.Title = extension.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if widget.TitleURL == "" && extension.TitleURL != "" {
|
||||||
|
widget.TitleURL = extension.TitleURL
|
||||||
|
}
|
||||||
|
|
||||||
widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate)
|
widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +73,8 @@ func (widget *extensionWidget) Render() template.HTML {
|
|||||||
type extensionType int
|
type extensionType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
extensionContentHTML extensionType = iota
|
extensionContentHTML extensionType = iota
|
||||||
extensionContentUnknown = iota
|
extensionContentUnknown
|
||||||
)
|
)
|
||||||
|
|
||||||
var extensionStringToType = map[string]extensionType{
|
var extensionStringToType = map[string]extensionType{
|
||||||
@@ -77,19 +83,22 @@ var extensionStringToType = map[string]extensionType{
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
extensionHeaderTitle = "Widget-Title"
|
extensionHeaderTitle = "Widget-Title"
|
||||||
|
extensionHeaderTitleURL = "Widget-Title-URL"
|
||||||
extensionHeaderContentType = "Widget-Content-Type"
|
extensionHeaderContentType = "Widget-Content-Type"
|
||||||
extensionHeaderContentFrameless = "Widget-Content-Frameless"
|
extensionHeaderContentFrameless = "Widget-Content-Frameless"
|
||||||
)
|
)
|
||||||
|
|
||||||
type extensionRequestOptions struct {
|
type extensionRequestOptions struct {
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
FallbackContentType string `yaml:"fallback-content-type"`
|
FallbackContentType string `yaml:"fallback-content-type"`
|
||||||
Parameters map[string]string `yaml:"parameters"`
|
Parameters queryParametersField `yaml:"parameters"`
|
||||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
Headers map[string]string `yaml:"headers"`
|
||||||
|
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type extension struct {
|
type extension struct {
|
||||||
Title string
|
Title string
|
||||||
|
TitleURL string
|
||||||
Content template.HTML
|
Content template.HTML
|
||||||
Frameless bool
|
Frameless bool
|
||||||
}
|
}
|
||||||
@@ -109,14 +118,13 @@ func convertExtensionContent(options extensionRequestOptions, content []byte, co
|
|||||||
|
|
||||||
func fetchExtension(options extensionRequestOptions) (extension, error) {
|
func fetchExtension(options extensionRequestOptions) (extension, error) {
|
||||||
request, _ := http.NewRequest("GET", options.URL, nil)
|
request, _ := http.NewRequest("GET", options.URL, nil)
|
||||||
|
if len(options.Parameters) > 0 {
|
||||||
query := url.Values{}
|
request.URL.RawQuery = options.Parameters.toQueryString()
|
||||||
|
|
||||||
for key, value := range options.Parameters {
|
|
||||||
query.Set(key, value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.URL.RawQuery = query.Encode()
|
for key, value := range options.Headers {
|
||||||
|
request.Header.Add(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
response, err := http.DefaultClient.Do(request)
|
response, err := http.DefaultClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,6 +148,10 @@ func fetchExtension(options extensionRequestOptions) (extension, error) {
|
|||||||
extension.Title = response.Header.Get(extensionHeaderTitle)
|
extension.Title = response.Header.Get(extensionHeaderTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if response.Header.Get(extensionHeaderTitleURL) != "" {
|
||||||
|
extension.TitleURL = response.Header.Get(extensionHeaderTitleURL)
|
||||||
|
}
|
||||||
|
|
||||||
contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)]
|
contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)]
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ type market struct {
|
|||||||
Name string
|
Name string
|
||||||
Currency string
|
Currency string
|
||||||
Price float64
|
Price float64
|
||||||
|
PriceHint int
|
||||||
PercentChange float64
|
PercentChange float64
|
||||||
SvgChartPoints string
|
SvgChartPoints string
|
||||||
}
|
}
|
||||||
@@ -106,6 +107,7 @@ type marketResponseJson struct {
|
|||||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||||
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
||||||
ShortName string `json:"shortName"`
|
ShortName string `json:"shortName"`
|
||||||
|
PriceHint int `json:"priceHint"`
|
||||||
} `json:"meta"`
|
} `json:"meta"`
|
||||||
Indicators struct {
|
Indicators struct {
|
||||||
Quote []struct {
|
Quote []struct {
|
||||||
@@ -152,13 +154,14 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
prices := response.Chart.Result[0].Indicators.Quote[0].Close
|
result := &response.Chart.Result[0]
|
||||||
|
prices := result.Indicators.Quote[0].Close
|
||||||
|
|
||||||
if len(prices) > marketChartDays {
|
if len(prices) > marketChartDays {
|
||||||
prices = prices[len(prices)-marketChartDays:]
|
prices = prices[len(prices)-marketChartDays:]
|
||||||
}
|
}
|
||||||
|
|
||||||
previous := response.Chart.Result[0].Meta.RegularMarketPrice
|
previous := result.Meta.RegularMarketPrice
|
||||||
|
|
||||||
if len(prices) >= 2 && prices[len(prices)-2] != 0 {
|
if len(prices) >= 2 && prices[len(prices)-2] != 0 {
|
||||||
previous = prices[len(prices)-2]
|
previous = prices[len(prices)-2]
|
||||||
@@ -166,21 +169,22 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro
|
|||||||
|
|
||||||
points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
||||||
|
|
||||||
currency, exists := currencyToSymbol[strings.ToUpper(response.Chart.Result[0].Meta.Currency)]
|
currency, exists := currencyToSymbol[strings.ToUpper(result.Meta.Currency)]
|
||||||
if !exists {
|
if !exists {
|
||||||
currency = response.Chart.Result[0].Meta.Currency
|
currency = result.Meta.Currency
|
||||||
}
|
}
|
||||||
|
|
||||||
markets = append(markets, market{
|
markets = append(markets, market{
|
||||||
marketRequest: marketRequests[i],
|
marketRequest: marketRequests[i],
|
||||||
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
|
Price: result.Meta.RegularMarketPrice,
|
||||||
Currency: currency,
|
Currency: currency,
|
||||||
|
PriceHint: result.Meta.PriceHint,
|
||||||
Name: ternary(marketRequests[i].CustomName == "",
|
Name: ternary(marketRequests[i].CustomName == "",
|
||||||
response.Chart.Result[0].Meta.ShortName,
|
result.Meta.ShortName,
|
||||||
marketRequests[i].CustomName,
|
marketRequests[i].CustomName,
|
||||||
),
|
),
|
||||||
PercentChange: percentChange(
|
PercentChange: percentChange(
|
||||||
response.Chart.Result[0].Meta.RegularMarketPrice,
|
result.Meta.RegularMarketPrice,
|
||||||
previous,
|
previous,
|
||||||
),
|
),
|
||||||
SvgChartPoints: points,
|
SvgChartPoints: points,
|
||||||
|
|||||||
@@ -115,9 +115,14 @@ func statusCodeToStyle(status int, altStatusCodes []int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SiteStatusRequest struct {
|
type SiteStatusRequest struct {
|
||||||
DefaultURL string `yaml:"url"`
|
DefaultURL string `yaml:"url"`
|
||||||
CheckURL string `yaml:"check-url"`
|
CheckURL string `yaml:"check-url"`
|
||||||
AllowInsecure bool `yaml:"allow-insecure"`
|
AllowInsecure bool `yaml:"allow-insecure"`
|
||||||
|
Timeout durationField `yaml:"timeout"`
|
||||||
|
BasicAuth struct {
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
} `yaml:"basic-auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type siteStatus struct {
|
type siteStatus struct {
|
||||||
@@ -134,16 +139,22 @@ func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (siteStatus, error) {
|
|||||||
} else {
|
} else {
|
||||||
url = statusRequest.DefaultURL
|
url = statusRequest.DefaultURL
|
||||||
}
|
}
|
||||||
request, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
|
timeout := ternary(statusRequest.Timeout > 0, time.Duration(statusRequest.Timeout), 3*time.Second)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return siteStatus{
|
return siteStatus{
|
||||||
Error: err,
|
Error: err,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
if statusRequest.BasicAuth.Username != "" || statusRequest.BasicAuth.Password != "" {
|
||||||
defer cancel()
|
request.SetBasicAuth(statusRequest.BasicAuth.Username, statusRequest.BasicAuth.Password)
|
||||||
request = request.WithContext(ctx)
|
}
|
||||||
|
|
||||||
requestSentAt := time.Now()
|
requestSentAt := time.Now()
|
||||||
var response *http.Response
|
var response *http.Response
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -29,10 +30,20 @@ type redditWidget struct {
|
|||||||
TopPeriod string `yaml:"top-period"`
|
TopPeriod string `yaml:"top-period"`
|
||||||
Search string `yaml:"search"`
|
Search string `yaml:"search"`
|
||||||
ExtraSortBy string `yaml:"extra-sort-by"`
|
ExtraSortBy string `yaml:"extra-sort-by"`
|
||||||
CommentsUrlTemplate string `yaml:"comments-url-template"`
|
CommentsURLTemplate string `yaml:"comments-url-template"`
|
||||||
Limit int `yaml:"limit"`
|
Limit int `yaml:"limit"`
|
||||||
CollapseAfter int `yaml:"collapse-after"`
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
RequestUrlTemplate string `yaml:"request-url-template"`
|
RequestURLTemplate string `yaml:"request-url-template"`
|
||||||
|
|
||||||
|
AppAuth struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Secret string `yaml:"secret"`
|
||||||
|
|
||||||
|
enabled bool
|
||||||
|
accessToken string
|
||||||
|
tokenExpiresAt time.Time
|
||||||
|
} `yaml:"app-auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *redditWidget) initialize() error {
|
func (widget *redditWidget) initialize() error {
|
||||||
@@ -48,20 +59,30 @@ func (widget *redditWidget) initialize() error {
|
|||||||
widget.CollapseAfter = 5
|
widget.CollapseAfter = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isValidRedditSortType(widget.SortBy) {
|
s := widget.SortBy
|
||||||
|
if s != "hot" && s != "new" && s != "top" && s != "rising" {
|
||||||
widget.SortBy = "hot"
|
widget.SortBy = "hot"
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isValidRedditTopPeriod(widget.TopPeriod) {
|
p := widget.TopPeriod
|
||||||
|
if p != "hour" && p != "day" && p != "week" && p != "month" && p != "year" && p != "all" {
|
||||||
widget.TopPeriod = "day"
|
widget.TopPeriod = "day"
|
||||||
}
|
}
|
||||||
|
|
||||||
if widget.RequestUrlTemplate != "" {
|
if widget.RequestURLTemplate != "" {
|
||||||
if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") {
|
if !strings.Contains(widget.RequestURLTemplate, "{REQUEST-URL}") {
|
||||||
return errors.New("no `{REQUEST-URL}` placeholder specified")
|
return errors.New("no `{REQUEST-URL}` placeholder specified")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a := &widget.AppAuth
|
||||||
|
if a.Name != "" || a.ID != "" || a.Secret != "" {
|
||||||
|
if a.Name == "" || a.ID == "" || a.Secret == "" {
|
||||||
|
return errors.New("application name, client ID and client secret are required")
|
||||||
|
}
|
||||||
|
a.enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
widget.
|
widget.
|
||||||
withTitle("r/" + widget.Subreddit).
|
withTitle("r/" + widget.Subreddit).
|
||||||
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
|
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
|
||||||
@@ -70,35 +91,8 @@ func (widget *redditWidget) initialize() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isValidRedditSortType(sortBy string) bool {
|
|
||||||
return sortBy == "hot" ||
|
|
||||||
sortBy == "new" ||
|
|
||||||
sortBy == "top" ||
|
|
||||||
sortBy == "rising"
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidRedditTopPeriod(period string) bool {
|
|
||||||
return period == "hour" ||
|
|
||||||
period == "day" ||
|
|
||||||
period == "week" ||
|
|
||||||
period == "month" ||
|
|
||||||
period == "year" ||
|
|
||||||
period == "all"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (widget *redditWidget) update(ctx context.Context) {
|
func (widget *redditWidget) update(ctx context.Context) {
|
||||||
// TODO: refactor, use a struct to pass all of these
|
posts, err := widget.fetchSubredditPosts()
|
||||||
posts, err := fetchSubredditPosts(
|
|
||||||
widget.Subreddit,
|
|
||||||
widget.SortBy,
|
|
||||||
widget.TopPeriod,
|
|
||||||
widget.Search,
|
|
||||||
widget.CommentsUrlTemplate,
|
|
||||||
widget.RequestUrlTemplate,
|
|
||||||
widget.Proxy.client,
|
|
||||||
widget.ShowFlairs,
|
|
||||||
)
|
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -155,57 +149,69 @@ type subredditResponseJson struct {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
|
func (widget *redditWidget) parseCustomCommentsURL(subreddit, postId, postPath string) string {
|
||||||
template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
|
template := strings.ReplaceAll(widget.CommentsURLTemplate, "{SUBREDDIT}", subreddit)
|
||||||
template = strings.ReplaceAll(template, "{POST-ID}", postId)
|
template = strings.ReplaceAll(template, "{POST-ID}", postId)
|
||||||
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
|
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
|
||||||
|
|
||||||
return template
|
return template
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSubredditPosts(
|
func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) {
|
||||||
subreddit,
|
|
||||||
sort,
|
|
||||||
topPeriod,
|
|
||||||
search,
|
|
||||||
commentsUrlTemplate,
|
|
||||||
requestUrlTemplate string,
|
|
||||||
proxyClient *http.Client,
|
|
||||||
showFlairs bool,
|
|
||||||
) (forumPostList, error) {
|
|
||||||
query := url.Values{}
|
|
||||||
var requestUrl string
|
|
||||||
|
|
||||||
if search != "" {
|
|
||||||
query.Set("q", search+" subreddit:"+subreddit)
|
|
||||||
query.Set("sort", sort)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sort == "top" {
|
|
||||||
query.Set("t", topPeriod)
|
|
||||||
}
|
|
||||||
|
|
||||||
if search != "" {
|
|
||||||
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
|
|
||||||
} else {
|
|
||||||
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
|
|
||||||
}
|
|
||||||
|
|
||||||
var client requestDoer = defaultHTTPClient
|
var client requestDoer = defaultHTTPClient
|
||||||
|
var baseURL string
|
||||||
|
var requestURL string
|
||||||
|
var headers http.Header
|
||||||
|
query := url.Values{}
|
||||||
|
app := &widget.AppAuth
|
||||||
|
|
||||||
if requestUrlTemplate != "" {
|
if !app.enabled {
|
||||||
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
|
baseURL = "https://www.reddit.com"
|
||||||
} else if proxyClient != nil {
|
headers = http.Header{
|
||||||
client = proxyClient
|
"User-Agent": []string{getBrowserUserAgentHeader()},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baseURL = "https://oauth.reddit.com"
|
||||||
|
|
||||||
|
if app.accessToken == "" || time.Now().Add(time.Minute).After(app.tokenExpiresAt) {
|
||||||
|
if err := widget.fetchNewAppAccessToken(); err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching new app access token: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = http.Header{
|
||||||
|
"Authorization": []string{"Bearer " + app.accessToken},
|
||||||
|
"User-Agent": []string{app.Name + "/1.0"},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request, err := http.NewRequest("GET", requestUrl, nil)
|
if widget.Limit > 25 {
|
||||||
|
query.Set("limit", strconv.Itoa(widget.Limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Search != "" {
|
||||||
|
query.Set("q", widget.Search+" subreddit:"+widget.Subreddit)
|
||||||
|
query.Set("sort", widget.SortBy)
|
||||||
|
requestURL = fmt.Sprintf("%s/search.json?%s", baseURL, query.Encode())
|
||||||
|
} else {
|
||||||
|
if widget.SortBy == "top" {
|
||||||
|
query.Set("t", widget.TopPeriod)
|
||||||
|
}
|
||||||
|
requestURL = fmt.Sprintf("%s/r/%s/%s.json?%s", baseURL, widget.Subreddit, widget.SortBy, query.Encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.RequestURLTemplate != "" {
|
||||||
|
requestURL = strings.ReplaceAll(widget.RequestURLTemplate, "{REQUEST-URL}", requestURL)
|
||||||
|
} else if widget.Proxy.client != nil {
|
||||||
|
client = widget.Proxy.client
|
||||||
|
}
|
||||||
|
|
||||||
|
request, err := http.NewRequest("GET", requestURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
request.Header = headers
|
||||||
|
|
||||||
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
|
|
||||||
setBrowserUserAgentHeader(request)
|
|
||||||
responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request)
|
responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -226,10 +232,10 @@ func fetchSubredditPosts(
|
|||||||
|
|
||||||
var commentsUrl string
|
var commentsUrl string
|
||||||
|
|
||||||
if commentsUrlTemplate == "" {
|
if widget.CommentsURLTemplate == "" {
|
||||||
commentsUrl = "https://www.reddit.com" + post.Permalink
|
commentsUrl = "https://www.reddit.com" + post.Permalink
|
||||||
} else {
|
} else {
|
||||||
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
|
commentsUrl = widget.parseCustomCommentsURL(widget.Subreddit, post.Id, post.Permalink)
|
||||||
}
|
}
|
||||||
|
|
||||||
forumPost := forumPost{
|
forumPost := forumPost{
|
||||||
@@ -249,7 +255,7 @@ func fetchSubredditPosts(
|
|||||||
forumPost.TargetUrl = post.Url
|
forumPost.TargetUrl = post.Url
|
||||||
}
|
}
|
||||||
|
|
||||||
if showFlairs && post.Flair != "" {
|
if widget.ShowFlairs && post.Flair != "" {
|
||||||
forumPost.Tags = append(forumPost.Tags, post.Flair)
|
forumPost.Tags = append(forumPost.Tags, post.Flair)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,11 +263,10 @@ func fetchSubredditPosts(
|
|||||||
forumPost.IsCrosspost = true
|
forumPost.IsCrosspost = true
|
||||||
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
|
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
|
||||||
|
|
||||||
if commentsUrlTemplate == "" {
|
if widget.CommentsURLTemplate == "" {
|
||||||
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
|
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
|
||||||
} else {
|
} else {
|
||||||
forumPost.TargetUrl = templateRedditCommentsURL(
|
forumPost.TargetUrl = widget.parseCustomCommentsURL(
|
||||||
commentsUrlTemplate,
|
|
||||||
post.ParentList[0].Subreddit,
|
post.ParentList[0].Subreddit,
|
||||||
post.ParentList[0].Id,
|
post.ParentList[0].Id,
|
||||||
post.ParentList[0].Permalink,
|
post.ParentList[0].Permalink,
|
||||||
@@ -274,3 +279,32 @@ func fetchSubredditPosts(
|
|||||||
|
|
||||||
return posts, nil
|
return posts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (widget *redditWidget) fetchNewAppAccessToken() error {
|
||||||
|
body := strings.NewReader("grant_type=client_credentials")
|
||||||
|
req, err := http.NewRequest("POST", "https://www.reddit.com/api/v1/access_token", body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating request for app access token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := &widget.AppAuth
|
||||||
|
req.SetBasicAuth(app.ID, app.Secret)
|
||||||
|
req.Header.Add("User-Agent", app.Name+"/1.0")
|
||||||
|
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
type tokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
client := ternary(widget.Proxy.client != nil, widget.Proxy.client, defaultHTTPClient)
|
||||||
|
response, err := decodeJsonFromRequest[tokenResponse](client, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
app.accessToken = response.AccessToken
|
||||||
|
app.tokenExpiresAt = time.Now().Add(time.Duration(response.ExpiresIn) * time.Second)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mmcdole/gofeed"
|
"github.com/mmcdole/gofeed"
|
||||||
@@ -25,22 +26,28 @@ var (
|
|||||||
rssWidgetHorizontalCards2Template = mustParseTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
rssWidgetHorizontalCards2Template = mustParseTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var feedParser = gofeed.NewParser()
|
||||||
|
|
||||||
type rssWidget struct {
|
type rssWidget struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
FeedRequests []rssFeedRequest `yaml:"feeds"`
|
FeedRequests []rssFeedRequest `yaml:"feeds"`
|
||||||
Style string `yaml:"style"`
|
Style string `yaml:"style"`
|
||||||
ThumbnailHeight float64 `yaml:"thumbnail-height"`
|
ThumbnailHeight float64 `yaml:"thumbnail-height"`
|
||||||
CardHeight float64 `yaml:"card-height"`
|
CardHeight float64 `yaml:"card-height"`
|
||||||
Items rssFeedItemList `yaml:"-"`
|
|
||||||
Limit int `yaml:"limit"`
|
Limit int `yaml:"limit"`
|
||||||
CollapseAfter int `yaml:"collapse-after"`
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
SingleLineTitles bool `yaml:"single-line-titles"`
|
SingleLineTitles bool `yaml:"single-line-titles"`
|
||||||
PreserveOrder bool `yaml:"preserve-order"`
|
PreserveOrder bool `yaml:"preserve-order"`
|
||||||
NoItemsMessage string `yaml:"-"`
|
|
||||||
|
Items rssFeedItemList `yaml:"-"`
|
||||||
|
NoItemsMessage string `yaml:"-"`
|
||||||
|
|
||||||
|
cachedFeedsMutex sync.Mutex
|
||||||
|
cachedFeeds map[string]*cachedRSSFeed `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *rssWidget) initialize() error {
|
func (widget *rssWidget) initialize() error {
|
||||||
widget.withTitle("RSS Feed").withCacheDuration(1 * time.Hour)
|
widget.withTitle("RSS Feed").withCacheDuration(2 * time.Hour)
|
||||||
|
|
||||||
if widget.Limit <= 0 {
|
if widget.Limit <= 0 {
|
||||||
widget.Limit = 25
|
widget.Limit = 25
|
||||||
@@ -65,12 +72,13 @@ func (widget *rssWidget) initialize() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
widget.NoItemsMessage = "No items were returned from the feeds."
|
widget.NoItemsMessage = "No items were returned from the feeds."
|
||||||
|
widget.cachedFeeds = make(map[string]*cachedRSSFeed)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *rssWidget) update(ctx context.Context) {
|
func (widget *rssWidget) update(ctx context.Context) {
|
||||||
items, err := fetchItemsFromRSSFeeds(widget.FeedRequests)
|
items, err := widget.fetchItemsFromFeeds()
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
@@ -103,6 +111,12 @@ func (widget *rssWidget) Render() template.HTML {
|
|||||||
return widget.renderTemplate(widget, rssWidgetTemplate)
|
return widget.renderTemplate(widget, rssWidgetTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cachedRSSFeed struct {
|
||||||
|
etag string
|
||||||
|
lastModified string
|
||||||
|
items []rssFeedItem
|
||||||
|
}
|
||||||
|
|
||||||
type rssFeedItem struct {
|
type rssFeedItem struct {
|
||||||
ChannelName string
|
ChannelName string
|
||||||
ChannelURL string
|
ChannelURL string
|
||||||
@@ -114,35 +128,6 @@ type rssFeedItem struct {
|
|||||||
PublishedAt time.Time
|
PublishedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// doesn't cover all cases but works the vast majority of the time
|
|
||||||
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
|
|
||||||
|
|
||||||
func sanitizeFeedDescription(description string) string {
|
|
||||||
if description == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
description = strings.ReplaceAll(description, "\n", " ")
|
|
||||||
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
|
|
||||||
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
|
|
||||||
description = strings.TrimSpace(description)
|
|
||||||
description = html.UnescapeString(description)
|
|
||||||
|
|
||||||
return description
|
|
||||||
}
|
|
||||||
|
|
||||||
func shortenFeedDescriptionLen(description string, maxLen int) string {
|
|
||||||
description, _ = limitStringLength(description, 1000)
|
|
||||||
description = sanitizeFeedDescription(description)
|
|
||||||
description, limited := limitStringLength(description, maxLen)
|
|
||||||
|
|
||||||
if limited {
|
|
||||||
description += "…"
|
|
||||||
}
|
|
||||||
|
|
||||||
return description
|
|
||||||
}
|
|
||||||
|
|
||||||
type rssFeedRequest struct {
|
type rssFeedRequest struct {
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
@@ -164,16 +149,68 @@ func (f rssFeedItemList) sortByNewest() rssFeedItemList {
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
var feedParser = gofeed.NewParser()
|
func (widget *rssWidget) fetchItemsFromFeeds() (rssFeedItemList, error) {
|
||||||
|
requests := widget.FeedRequests
|
||||||
|
|
||||||
func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
|
job := newJob(widget.fetchItemsFromFeedTask, requests).withWorkers(30)
|
||||||
|
feeds, errs, err := workerPoolDo(job)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", errNoContent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
failed := 0
|
||||||
|
entries := make(rssFeedItemList, 0, len(feeds)*10)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
for i := range feeds {
|
||||||
|
if errs[i] != nil {
|
||||||
|
failed++
|
||||||
|
slog.Error("Failed to get RSS feed", "url", requests[i].URL, "error", errs[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range feeds[i] {
|
||||||
|
if _, exists := seen[item.Link]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, item)
|
||||||
|
seen[item.Link] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed == len(requests) {
|
||||||
|
return nil, errNoContent
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed > 0 {
|
||||||
|
return entries, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
|
||||||
req, err := http.NewRequest("GET", request.URL, nil)
|
req, err := http.NewRequest("GET", request.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.Header.Add("User-Agent", glanceUserAgentString)
|
||||||
|
|
||||||
|
widget.cachedFeedsMutex.Lock()
|
||||||
|
cache, isCached := widget.cachedFeeds[request.URL]
|
||||||
|
if isCached {
|
||||||
|
if cache.etag != "" {
|
||||||
|
req.Header.Add("If-None-Match", cache.etag)
|
||||||
|
}
|
||||||
|
if cache.lastModified != "" {
|
||||||
|
req.Header.Add("If-Modified-Since", cache.lastModified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
widget.cachedFeedsMutex.Unlock()
|
||||||
|
|
||||||
for key, value := range request.Headers {
|
for key, value := range request.Headers {
|
||||||
req.Header.Add(key, value)
|
req.Header.Set(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := defaultHTTPClient.Do(req)
|
resp, err := defaultHTTPClient.Do(req)
|
||||||
@@ -182,6 +219,10 @@ func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotModified && isCached {
|
||||||
|
return cache.items, nil
|
||||||
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, request.URL)
|
return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, request.URL)
|
||||||
}
|
}
|
||||||
@@ -289,9 +330,29 @@ func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
|
|||||||
items = append(items, rssItem)
|
items = append(items, rssItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if resp.Header.Get("ETag") != "" || resp.Header.Get("Last-Modified") != "" {
|
||||||
|
widget.cachedFeedsMutex.Lock()
|
||||||
|
widget.cachedFeeds[request.URL] = &cachedRSSFeed{
|
||||||
|
etag: resp.Header.Get("ETag"),
|
||||||
|
lastModified: resp.Header.Get("Last-Modified"),
|
||||||
|
items: items,
|
||||||
|
}
|
||||||
|
widget.cachedFeedsMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findThumbnailInItemExtensions(item *gofeed.Item) string {
|
||||||
|
media, ok := item.Extensions["media"]
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return recursiveFindThumbnailInExtensions(media)
|
||||||
|
}
|
||||||
|
|
||||||
func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
|
func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
|
||||||
for _, exts := range extensions {
|
for _, exts := range extensions {
|
||||||
for _, ext := range exts {
|
for _, ext := range exts {
|
||||||
@@ -312,43 +373,30 @@ func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extens
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func findThumbnailInItemExtensions(item *gofeed.Item) string {
|
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
|
||||||
media, ok := item.Extensions["media"]
|
|
||||||
|
|
||||||
if !ok {
|
func sanitizeFeedDescription(description string) string {
|
||||||
|
if description == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return recursiveFindThumbnailInExtensions(media)
|
description = strings.ReplaceAll(description, "\n", " ")
|
||||||
|
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
|
||||||
|
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
|
||||||
|
description = strings.TrimSpace(description)
|
||||||
|
description = html.UnescapeString(description)
|
||||||
|
|
||||||
|
return description
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchItemsFromRSSFeeds(requests []rssFeedRequest) (rssFeedItemList, error) {
|
func shortenFeedDescriptionLen(description string, maxLen int) string {
|
||||||
job := newJob(fetchItemsFromRSSFeedTask, requests).withWorkers(30)
|
description, _ = limitStringLength(description, 1000)
|
||||||
feeds, errs, err := workerPoolDo(job)
|
description = sanitizeFeedDescription(description)
|
||||||
if err != nil {
|
description, limited := limitStringLength(description, maxLen)
|
||||||
return nil, fmt.Errorf("%w: %v", errNoContent, err)
|
|
||||||
|
if limited {
|
||||||
|
description += "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
failed := 0
|
return description
|
||||||
entries := make(rssFeedItemList, 0, len(feeds)*10)
|
|
||||||
|
|
||||||
for i := range feeds {
|
|
||||||
if errs[i] != nil {
|
|
||||||
failed++
|
|
||||||
slog.Error("Failed to get RSS feed", "url", requests[i].URL, "error", errs[i])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entries = append(entries, feeds[i]...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if failed == len(requests) {
|
|
||||||
return nil, errNoContent
|
|
||||||
}
|
|
||||||
|
|
||||||
if failed > 0 {
|
|
||||||
return entries, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type searchWidget struct {
|
|||||||
SearchEngine string `yaml:"search-engine"`
|
SearchEngine string `yaml:"search-engine"`
|
||||||
Bangs []SearchBang `yaml:"bangs"`
|
Bangs []SearchBang `yaml:"bangs"`
|
||||||
NewTab bool `yaml:"new-tab"`
|
NewTab bool `yaml:"new-tab"`
|
||||||
|
Target string `yaml:"target"`
|
||||||
Autofocus bool `yaml:"autofocus"`
|
Autofocus bool `yaml:"autofocus"`
|
||||||
Placeholder string `yaml:"placeholder"`
|
Placeholder string `yaml:"placeholder"`
|
||||||
}
|
}
|
||||||
@@ -33,6 +34,10 @@ func convertSearchUrl(url string) string {
|
|||||||
var searchEngines = map[string]string{
|
var searchEngines = map[string]string{
|
||||||
"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
|
"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
|
||||||
"google": "https://www.google.com/search?q={QUERY}",
|
"google": "https://www.google.com/search?q={QUERY}",
|
||||||
|
"bing": "https://www.bing.com/search?q={QUERY}",
|
||||||
|
"perplexity": "https://www.perplexity.ai/search?q={QUERY}",
|
||||||
|
"kagi": "https://kagi.com/search?q={QUERY}",
|
||||||
|
"startpage": "https://www.startpage.com/search?q={QUERY}",
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *searchWidget) initialize() error {
|
func (widget *searchWidget) initialize() error {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
var todoWidgetTemplate = mustParseTemplate("todo.html", "widget-base.html")
|
||||||
|
|
||||||
|
type todoWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
|
TodoID string `yaml:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *todoWidget) initialize() error {
|
||||||
|
widget.withTitle("To-do").withError(nil)
|
||||||
|
|
||||||
|
widget.cachedHTML = widget.renderTemplate(widget, todoWidgetTemplate)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *todoWidget) Render() template.HTML {
|
||||||
|
return widget.cachedHTML
|
||||||
|
}
|
||||||
@@ -196,6 +196,10 @@ func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) {
|
|||||||
slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt)
|
slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// This prevents live channels with 0 viewers from being
|
||||||
|
// incorrectly sorted lower than offline channels
|
||||||
|
result.ViewersCount = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ var (
|
|||||||
const defaultClientTimeout = 5 * time.Second
|
const defaultClientTimeout = 5 * time.Second
|
||||||
|
|
||||||
var defaultHTTPClient = &http.Client{
|
var defaultHTTPClient = &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConnsPerHost: 10,
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
},
|
||||||
Timeout: defaultClientTimeout,
|
Timeout: defaultClientTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,15 +43,20 @@ type requestDoer interface {
|
|||||||
Do(*http.Request) (*http.Response, error)
|
Do(*http.Request) (*http.Response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var glanceUserAgentString = "Glance/" + buildVersion + " +https://github.com/glanceapp/glance"
|
||||||
var userAgentPersistentVersion atomic.Int32
|
var userAgentPersistentVersion atomic.Int32
|
||||||
|
|
||||||
func setBrowserUserAgentHeader(request *http.Request) {
|
func getBrowserUserAgentHeader() string {
|
||||||
if rand.IntN(2000) == 0 {
|
if rand.IntN(2000) == 0 {
|
||||||
userAgentPersistentVersion.Store(rand.Int32N(5))
|
userAgentPersistentVersion.Store(rand.Int32N(5))
|
||||||
}
|
}
|
||||||
|
|
||||||
version := strconv.Itoa(130 + int(userAgentPersistentVersion.Load()))
|
version := strconv.Itoa(130 + int(userAgentPersistentVersion.Load()))
|
||||||
request.Header.Set("User-Agent", "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"
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBrowserUserAgentHeader(request *http.Request) {
|
||||||
|
request.Header.Set("User-Agent", getBrowserUserAgentHeader())
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) {
|
func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) {
|
||||||
@@ -67,7 +77,7 @@ func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T,
|
|||||||
truncatedBody, _ := limitStringLength(string(body), 256)
|
truncatedBody, _ := limitStringLength(string(body), 256)
|
||||||
|
|
||||||
return result, fmt.Errorf(
|
return result, fmt.Errorf(
|
||||||
"unexpected status code %d for %s, response: %s",
|
"unexpected status code %d from %s, response: %s",
|
||||||
response.StatusCode,
|
response.StatusCode,
|
||||||
request.URL,
|
request.URL,
|
||||||
truncatedBody,
|
truncatedBody,
|
||||||
@@ -147,10 +157,8 @@ const defaultNumWorkers = 10
|
|||||||
func (job *workerPoolJob[I, O]) withWorkers(workers int) *workerPoolJob[I, O] {
|
func (job *workerPoolJob[I, O]) withWorkers(workers int) *workerPoolJob[I, O] {
|
||||||
if workers == 0 {
|
if workers == 0 {
|
||||||
job.workers = defaultNumWorkers
|
job.workers = defaultNumWorkers
|
||||||
} else if workers > len(job.data) {
|
|
||||||
job.workers = len(job.data)
|
|
||||||
} else {
|
} else {
|
||||||
job.workers = workers
|
job.workers = min(workers, len(job.data))
|
||||||
}
|
}
|
||||||
|
|
||||||
return job
|
return job
|
||||||
@@ -181,6 +189,11 @@ func workerPoolDo[I any, O any](job *workerPoolJob[I, O]) ([]O, []error, error)
|
|||||||
return results, errs, nil
|
return results, errs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(job.data) == 1 {
|
||||||
|
results[0], errs[0] = job.task(job.data[0])
|
||||||
|
return results, errs, nil
|
||||||
|
}
|
||||||
|
|
||||||
tasksQueue := make(chan *workerPoolTask[I, O])
|
tasksQueue := make(chan *workerPoolTask[I, O])
|
||||||
resultsQueue := make(chan *workerPoolTask[I, O])
|
resultsQueue := make(chan *workerPoolTask[I, O])
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ func parsePlaceName(name string) (string, string) {
|
|||||||
|
|
||||||
func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, error) {
|
func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, error) {
|
||||||
location, area := parsePlaceName(location)
|
location, area := parsePlaceName(location)
|
||||||
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location))
|
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=20&language=en&format=json", url.QueryEscape(location))
|
||||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||||
responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultHTTPClient, request)
|
responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultHTTPClient, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import (
|
|||||||
var widgetIDCounter atomic.Uint64
|
var widgetIDCounter atomic.Uint64
|
||||||
|
|
||||||
func newWidget(widgetType string) (widget, error) {
|
func newWidget(widgetType string) (widget, error) {
|
||||||
|
if widgetType == "" {
|
||||||
|
return nil, errors.New("widget 'type' property is empty or not specified")
|
||||||
|
}
|
||||||
|
|
||||||
var w widget
|
var w widget
|
||||||
|
|
||||||
switch widgetType {
|
switch widgetType {
|
||||||
@@ -75,6 +79,8 @@ func newWidget(widgetType string) (widget, error) {
|
|||||||
w = &dockerContainersWidget{}
|
w = &dockerContainersWidget{}
|
||||||
case "server-stats":
|
case "server-stats":
|
||||||
w = &serverStatsWidget{}
|
w = &serverStatsWidget{}
|
||||||
|
case "to-do":
|
||||||
|
w = &todoWidget{}
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||||
}
|
}
|
||||||
@@ -104,7 +110,7 @@ func (w *widgets) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
|
|
||||||
widget, err := newWidget(meta.Type)
|
widget, err := newWidget(meta.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("line %d: %w", node.Line, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = node.Decode(widget); err != nil {
|
if err = node.Decode(widget); err != nil {
|
||||||
@@ -146,6 +152,7 @@ type widgetBase struct {
|
|||||||
Type string `yaml:"type"`
|
Type string `yaml:"type"`
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
TitleURL string `yaml:"title-url"`
|
TitleURL string `yaml:"title-url"`
|
||||||
|
HideHeader bool `yaml:"hide-header"`
|
||||||
CSSClass string `yaml:"css-class"`
|
CSSClass string `yaml:"css-class"`
|
||||||
CustomCacheDuration durationField `yaml:"cache"`
|
CustomCacheDuration durationField `yaml:"cache"`
|
||||||
ContentAvailable bool `yaml:"-"`
|
ContentAvailable bool `yaml:"-"`
|
||||||
@@ -157,7 +164,6 @@ type widgetBase struct {
|
|||||||
cacheType cacheType `yaml:"-"`
|
cacheType cacheType `yaml:"-"`
|
||||||
nextUpdate time.Time `yaml:"-"`
|
nextUpdate time.Time `yaml:"-"`
|
||||||
updateRetriedTimes int `yaml:"-"`
|
updateRetriedTimes int `yaml:"-"`
|
||||||
HideHeader bool `yaml:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type widgetProviders struct {
|
type widgetProviders struct {
|
||||||
|
|||||||
@@ -201,11 +201,12 @@ func Collect(req *SystemInfoRequest) (*SystemInfo, []error) {
|
|||||||
// currently disabled on Windows because it requires elevated privilidges, otherwise
|
// currently disabled on Windows because it requires elevated privilidges, otherwise
|
||||||
// keeps returning a single sensor with key "ACPI\\ThermalZone\\TZ00_0" which
|
// keeps returning a single sensor with key "ACPI\\ThermalZone\\TZ00_0" which
|
||||||
// doesn't seem to be the CPU sensor or correspond to anything useful when
|
// doesn't seem to be the CPU sensor or correspond to anything useful when
|
||||||
// compared against the temperatures Libre Hardware Monitor reports
|
// compared against the temperatures Libre Hardware Monitor reports.
|
||||||
// also disabled on openbsd because it's not implemented by go-psutil
|
// Also disabled on the bsd's because it's not implemented by go-psutil for them
|
||||||
if runtime.GOOS != "windows" && runtime.GOOS != "openbsd" {
|
if runtime.GOOS != "windows" && runtime.GOOS != "openbsd" && runtime.GOOS != "netbsd" && runtime.GOOS != "freebsd" {
|
||||||
sensorReadings, err := sensors.SensorsTemperatures()
|
sensorReadings, err := sensors.SensorsTemperatures()
|
||||||
if err == nil {
|
_, errIsWarning := err.(*sensors.Warnings)
|
||||||
|
if err == nil || errIsWarning {
|
||||||
if req.CPUTempSensor != "" {
|
if req.CPUTempSensor != "" {
|
||||||
for i := range sensorReadings {
|
for i := range sensorReadings {
|
||||||
if sensorReadings[i].SensorKey == req.CPUTempSensor {
|
if sensorReadings[i].SensorKey == req.CPUTempSensor {
|
||||||
@@ -227,35 +228,50 @@ func Collect(req *SystemInfoRequest) (*SystemInfo, []error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filesystems, err := disk.Partitions(false)
|
addedMountpoints := map[string]struct{}{}
|
||||||
if err == nil {
|
addMountpointInfo := func(requestedPath string, mpReq MointpointRequest) {
|
||||||
for _, fs := range filesystems {
|
if _, exists := addedMountpoints[requestedPath]; exists {
|
||||||
mpReq, ok := req.Mountpoints[fs.Mountpoint]
|
return
|
||||||
isHidden := req.HideMountpointsByDefault
|
|
||||||
if ok && mpReq.Hide != nil {
|
|
||||||
isHidden = *mpReq.Hide
|
|
||||||
}
|
|
||||||
if isHidden {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
usage, err := disk.Usage(fs.Mountpoint)
|
|
||||||
if err == nil {
|
|
||||||
mpInfo := MountpointInfo{
|
|
||||||
Path: fs.Mountpoint,
|
|
||||||
Name: mpReq.Name,
|
|
||||||
TotalMB: usage.Total / 1024 / 1024,
|
|
||||||
UsedMB: usage.Used / 1024 / 1024,
|
|
||||||
UsedPercent: uint8(math.Min(usage.UsedPercent, 100)),
|
|
||||||
}
|
|
||||||
|
|
||||||
info.Mountpoints = append(info.Mountpoints, mpInfo)
|
|
||||||
} else {
|
|
||||||
addErr(fmt.Errorf("getting filesystem usage for %s: %v", fs.Mountpoint, err))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
addErr(fmt.Errorf("getting filesystems: %v", err))
|
isHidden := req.HideMountpointsByDefault
|
||||||
|
if mpReq.Hide != nil {
|
||||||
|
isHidden = *mpReq.Hide
|
||||||
|
}
|
||||||
|
if isHidden {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := disk.Usage(requestedPath)
|
||||||
|
if err == nil {
|
||||||
|
mpInfo := MountpointInfo{
|
||||||
|
Path: requestedPath,
|
||||||
|
Name: mpReq.Name,
|
||||||
|
TotalMB: usage.Total / 1024 / 1024,
|
||||||
|
UsedMB: usage.Used / 1024 / 1024,
|
||||||
|
UsedPercent: uint8(math.Min(usage.UsedPercent, 100)),
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Mountpoints = append(info.Mountpoints, mpInfo)
|
||||||
|
addedMountpoints[requestedPath] = struct{}{}
|
||||||
|
} else {
|
||||||
|
addErr(fmt.Errorf("getting filesystem usage for %s: %v", requestedPath, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !req.HideMountpointsByDefault {
|
||||||
|
filesystems, err := disk.Partitions(false)
|
||||||
|
if err == nil {
|
||||||
|
for _, fs := range filesystems {
|
||||||
|
addMountpointInfo(fs.Mountpoint, req.Mountpoints[fs.Mountpoint])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addErr(fmt.Errorf("getting filesystems: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for mountpoint, mpReq := range req.Mountpoints {
|
||||||
|
addMountpointInfo(mountpoint, mpReq)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(info.Mountpoints, func(a, b int) bool {
|
sort.Slice(info.Mountpoints, func(a, b int) bool {
|
||||||
|
|||||||