Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7743664527 | ||
|
|
714e1b6d29 | ||
|
|
a0585e097f | ||
|
|
6363b76d33 | ||
|
|
503562f2e5 | ||
|
|
01a7331354 | ||
|
|
6bb44bef20 | ||
|
|
aef0c84286 | ||
|
|
0fbc8dcb41 | ||
|
|
818138340c | ||
|
|
4beab6bcec | ||
|
|
b9b7ae1cac | ||
|
|
82f51293df | ||
|
|
840ba0f240 | ||
|
|
c5e3eed64b | ||
|
|
7dd1cca65d | ||
|
|
57414bdb67 | ||
|
|
646be63895 | ||
|
|
3c1fb97c12 | ||
|
|
e78619f5e1 | ||
|
|
54102ae35e | ||
|
|
7c4c1798b6 | ||
|
|
f30006093d | ||
|
|
c8ef47064a | ||
|
|
56cb97706b | ||
|
|
dd5bec799f | ||
|
|
2389415071 | ||
|
|
976fb02bd3 | ||
|
|
007fe18d27 | ||
|
|
11f2999b52 | ||
|
|
e204029d3c | ||
|
|
d3eaae2fb0 | ||
|
|
251c22a14f | ||
|
|
7d0a7186e8 | ||
|
|
7b444b88e3 | ||
|
|
f317b1b189 | ||
|
|
3523562c3a | ||
|
|
5fdc3e3f10 | ||
|
|
24ef8f6e2a | ||
|
|
d8d6625478 | ||
|
|
ad06146784 | ||
|
|
c97171883c | ||
|
|
9c5b6045af | ||
|
|
eb85a7656f | ||
|
|
25d45b309d | ||
|
|
7d0b4535bb | ||
|
|
f8af2025e8 | ||
|
|
948c117f5b | ||
|
|
58967ab758 | ||
|
|
44a153d30a | ||
|
|
d5d6103327 | ||
|
|
189b8895b0 | ||
|
|
748ad1ff8d | ||
|
|
ccdafcdfab | ||
|
|
78572a534b | ||
|
|
2eb8e71e89 | ||
|
|
5375103c4b | ||
|
|
faeeb7c899 | ||
|
|
99fd77feac |
@@ -2,9 +2,10 @@ FROM alpine:3.19
|
|||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
ARG TARGETVARIANT
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY build/glance-$TARGETOS-$TARGETARCH /app/glance
|
COPY build/glance-$TARGETOS-$TARGETARCH${TARGETVARIANT} /app/glance
|
||||||
|
|
||||||
EXPOSE 8080/tcp
|
EXPOSE 8080/tcp
|
||||||
ENTRYPOINT ["/app/glance"]
|
ENTRYPOINT ["/app/glance"]
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY build/glance /app/glance
|
||||||
|
|
||||||
|
EXPOSE 8080/tcp
|
||||||
|
ENTRYPOINT ["/app/glance"]
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
* iframe
|
* iframe
|
||||||
* Twitch channels & top games
|
* Twitch channels & top games
|
||||||
* GitHub releases
|
* GitHub releases
|
||||||
|
* Repository overview
|
||||||
* Site monitor
|
* Site monitor
|
||||||
|
|
||||||
#### Themeable
|
#### Themeable
|
||||||
@@ -41,16 +42,16 @@ Checkout the [configuration docs](docs/configuration.md) to learn more. A [preco
|
|||||||
#### Manual
|
#### Manual
|
||||||
Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
|
Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
/opt/glance/glance --config /etc/glance.yml
|
/opt/glance/glance --config /etc/glance.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
>
|
>
|
||||||
> Make sure you have a valid `glance.yml` file before running the container.
|
> Make sure you have a valid `glance.yml` file in the same directory before running the container.
|
||||||
|
|
||||||
```console
|
```bash
|
||||||
docker run -d -p 8080:8080 \
|
docker run -d -p 8080:8080 \
|
||||||
-v ./glance.yml:/app/glance.yml \
|
-v ./glance.yml:/app/glance.yml \
|
||||||
-v /etc/timezone:/etc/timezone:ro \
|
-v /etc/timezone:/etc/timezone:ro \
|
||||||
@@ -79,12 +80,34 @@ Requirements: [Go](https://go.dev/dl/) >= v1.22
|
|||||||
|
|
||||||
To build:
|
To build:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
go build .
|
go build -o build/glance .
|
||||||
```
|
```
|
||||||
|
|
||||||
To run:
|
To run:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Building Docker image
|
||||||
|
|
||||||
|
Build Glance with CGO disabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=0 go build -o build/glance .
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the image:
|
||||||
|
|
||||||
|
**Make sure to replace "owner" with your name or organization.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t owner/glance:latest -f Dockerfile.single-platform .
|
||||||
|
```
|
||||||
|
|
||||||
|
Push the image to your registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker push owner/glance:latest
|
||||||
|
```
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
- [Weather](#weather)
|
- [Weather](#weather)
|
||||||
- [Monitor](#monitor)
|
- [Monitor](#monitor)
|
||||||
- [Releases](#releases)
|
- [Releases](#releases)
|
||||||
|
- [Repository](#repository)
|
||||||
- [Bookmarks](#bookmarks)
|
- [Bookmarks](#bookmarks)
|
||||||
- [Calendar](#calendar)
|
- [Calendar](#calendar)
|
||||||
- [Stocks](#stocks)
|
- [Stocks](#stocks)
|
||||||
@@ -155,7 +156,7 @@ Say you have a directory `glance-assets` with a file `gitea-icon.png` in it and
|
|||||||
assets-path: /home/user/glance-assets
|
assets-path: /home/user/glance-assets
|
||||||
```
|
```
|
||||||
|
|
||||||
To be able to point to an asset from your assets path, use the the `/assets/` path like such:
|
To be able to point to an asset from your assets path, use the `/assets/` path like such:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
icon: /assets/gitea-icon.png
|
icon: /assets/gitea-icon.png
|
||||||
@@ -174,18 +175,19 @@ theme:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Themes
|
### Themes
|
||||||
If you don't want to spend time configuring your own theme, there are [several available themes](preconfigured-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
|
||||||
| Name | Type | Required | Default |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| light | bool | no | false |
|
| light | boolean | no | false |
|
||||||
| background-color | HSL | no | 240 8 9 |
|
| background-color | HSL | no | 240 8 9 |
|
||||||
| primary-color | HSL | no | 43 50 70 |
|
| primary-color | HSL | no | 43 50 70 |
|
||||||
| positive-color | HSL | no | same as `primary-color` |
|
| positive-color | HSL | no | same as `primary-color` |
|
||||||
| negative-color | HSL | no | 0 70 70 |
|
| negative-color | HSL | no | 0 70 70 |
|
||||||
| 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 | |
|
||||||
|
|
||||||
#### `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.
|
||||||
@@ -210,6 +212,23 @@ Used to increase or decrease the contrast (in other words visibility) of the tex
|
|||||||
#### `text-saturation-multiplier`
|
#### `text-saturation-multiplier`
|
||||||
Used to increase or decrease the saturation of text, useful when using a custom background color with a high amount of saturation and needing the text to have a more neutral color. `0.5` means that the saturation will be 50% lower and `1.5` means that it'll be 50% higher.
|
Used to increase or decrease the saturation of text, useful when using a custom background color with a high amount of saturation and needing the text to have a more neutral color. `0.5` means that the saturation will be 50% lower and `1.5` means that it'll be 50% higher.
|
||||||
|
|
||||||
|
#### `custom-css-file`
|
||||||
|
Path to a custom CSS file, either external or one from within the server configured assets path. Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
custom-css-file: /assets/my-style.css
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
>
|
||||||
|
> Because Glance uses a lot of utility classes it might be difficult to target some elements. To make it easier to style specific widgets, each widget has a `widget-type-{name}` class, so for example if you wanted to make the links inside just the RSS widget bigger you could use the following selector:
|
||||||
|
>
|
||||||
|
> ```css
|
||||||
|
> .widget-type-rss a {
|
||||||
|
> font-size: 1.5rem;
|
||||||
|
> }
|
||||||
|
|
||||||
|
|
||||||
## Pages & Columns
|
## Pages & Columns
|
||||||

|

|
||||||
@@ -232,11 +251,12 @@ pages:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Properties
|
### Properties
|
||||||
| Name | Type | Required |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| title | string | yes |
|
| title | string | yes | |
|
||||||
| slug | string | no |
|
| slug | string | no | |
|
||||||
| columns | array | yes |
|
| show-mobile-header | boolean | no | false |
|
||||||
|
| columns | array | yes | |
|
||||||
|
|
||||||
#### `title`
|
#### `title`
|
||||||
The name of the page which gets shown in the navigation bar.
|
The name of the page which gets shown in the navigation bar.
|
||||||
@@ -244,6 +264,13 @@ 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.
|
||||||
|
|
||||||
|
#### `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.
|
||||||
|
|
||||||
|
Preview:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### 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:
|
||||||
|
|
||||||
@@ -366,6 +393,8 @@ Example:
|
|||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| style | string | no | vertical-list |
|
| style | string | no | vertical-list |
|
||||||
| feeds | array | yes |
|
| feeds | array | yes |
|
||||||
|
| thumbnail-height | float | no | 10 |
|
||||||
|
| card-height | float | no | 27 |
|
||||||
| limit | integer | no | 25 |
|
| limit | integer | no | 25 |
|
||||||
| collapse-after | integer | no | 5 |
|
| collapse-after | integer | no | 5 |
|
||||||
|
|
||||||
@@ -380,6 +409,16 @@ Used to change the appearance of the widget. Possible values are `vertical-list`
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
`horizontal-cards-2`
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
##### `thumbnail-height`
|
||||||
|
Used to modify the height of the thumbnails. Works only when the style is set to `horizontal-cards`. The default value is `10` and the units are `rem`, if you want to for example double the height of the thumbnails you can set it to `20`.
|
||||||
|
|
||||||
|
##### `card-height`
|
||||||
|
Used to modify the height of cards when using the `horizontal-cards-2` style. The default value is `27` and the units are `rem`.
|
||||||
|
|
||||||
##### `feeds`
|
##### `feeds`
|
||||||
An array of RSS/atom feeds. The title can optionally be changed.
|
An array of RSS/atom feeds. The title can optionally be changed.
|
||||||
|
|
||||||
@@ -416,6 +455,8 @@ Preview:
|
|||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| channels | array | yes | |
|
| channels | array | yes | |
|
||||||
| limit | integer | no | 25 |
|
| limit | integer | no | 25 |
|
||||||
|
| style | string | no | horizontal-cards |
|
||||||
|
| video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
|
||||||
|
|
||||||
##### `channels`
|
##### `channels`
|
||||||
A list of channel IDs. One way of getting the ID of a channel is going to the channel's page and clicking on its description:
|
A list of channel IDs. One way of getting the ID of a channel is going to the channel's page and clicking on its description:
|
||||||
@@ -429,6 +470,24 @@ Then scroll down and click on "Share channel", then "Copy channel ID":
|
|||||||
##### `limit`
|
##### `limit`
|
||||||
The maximum number of videos to show.
|
The maximum number of videos to show.
|
||||||
|
|
||||||
|
##### `style`
|
||||||
|
Used to change the appearance of the widget. Possible values are `horizontal-cards` and `grid-cards`.
|
||||||
|
|
||||||
|
Preview of `grid-cards`:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
##### `video-url-template`
|
||||||
|
Used to replace the default link for videos. Useful when you're running your own YouTube front-end. Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
video-url-template: https://invidious.your-domain.com/watch?v={VIDEO-ID}
|
||||||
|
```
|
||||||
|
|
||||||
|
Placeholders:
|
||||||
|
|
||||||
|
`{VIDEO-ID}` - the ID of the video
|
||||||
|
|
||||||
### Hacker News
|
### Hacker News
|
||||||
Display a list of posts from [Hacker News](https://news.ycombinator.com/).
|
Display a list of posts from [Hacker News](https://news.ycombinator.com/).
|
||||||
|
|
||||||
@@ -448,10 +507,36 @@ Preview:
|
|||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| limit | integer | no | 15 |
|
| limit | integer | no | 15 |
|
||||||
| collapse-after | integer | no | 5 |
|
| collapse-after | integer | no | 5 |
|
||||||
|
| comments-url-template | string | no | https://news.ycombinator.com/item?id={POST-ID} |
|
||||||
|
| sort-by | string | no | top |
|
||||||
|
| extra-sort-by | string | no | |
|
||||||
|
|
||||||
|
##### `comments-url-template`
|
||||||
|
Used to replace the default link for post comments. Useful if you want to use an alternative front-end. Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
comments-url-template: https://www.hckrnws.com/stories/{POST-ID}
|
||||||
|
```
|
||||||
|
|
||||||
|
Placeholders:
|
||||||
|
|
||||||
|
`{POST-ID}` - the ID of the post
|
||||||
|
|
||||||
|
##### `sort-by`
|
||||||
|
Used to specify the order in which the posts should get returned. Possible values are `top`, `new`, and `best`.
|
||||||
|
|
||||||
|
##### `extra-sort-by`
|
||||||
|
Can be used to specify an additional sort which will be applied on top of the already sorted posts. By default does not apply any extra sorting and the only available option is `engagement`.
|
||||||
|
|
||||||
|
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
|
||||||
|
|
||||||
### Reddit
|
### Reddit
|
||||||
Display a list of posts from a specific subreddit.
|
Display a list of posts from a specific subreddit.
|
||||||
|
|
||||||
|
> [!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.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -464,8 +549,15 @@ Example:
|
|||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| subreddit | string | yes | |
|
| subreddit | string | yes | |
|
||||||
| style | string | no | vertical-list |
|
| style | string | no | vertical-list |
|
||||||
|
| show-thumbnails | boolean | no | false |
|
||||||
| limit | integer | no | 15 |
|
| limit | integer | no | 15 |
|
||||||
| collapse-after | integer | no | 5 |
|
| collapse-after | integer | no | 5 |
|
||||||
|
| comments-url-template | string | no | https://www.reddit.com/{POST-PATH} |
|
||||||
|
| request-url-template | string | no | |
|
||||||
|
| sort-by | string | no | hot |
|
||||||
|
| top-period | string | no | day |
|
||||||
|
| search | string | no | |
|
||||||
|
| extra-sort-by | string | no | |
|
||||||
|
|
||||||
##### `subreddit`
|
##### `subreddit`
|
||||||
The subreddit for which to fetch the posts from.
|
The subreddit for which to fetch the posts from.
|
||||||
@@ -485,12 +577,68 @@ Used to change the appearance of the widget. Possible values are `vertical-list`
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
##### `show-thumbnails`
|
||||||
|
Shows or hides thumbnails next to the post. This only works if the `style` is `vertical-list`. Preview:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
>
|
||||||
|
> Thumbnails don't work for some subreddits due to Reddit's API not returning the thumbnail URL. No workaround for this yet.
|
||||||
|
|
||||||
##### `limit`
|
##### `limit`
|
||||||
The maximum number of posts to show.
|
The maximum number of posts to show.
|
||||||
|
|
||||||
##### `collapse-after`
|
##### `collapse-after`
|
||||||
How many posts are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. Not available when using the `vertical-cards` and `horizontal-cards` styles.
|
How many posts are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. Not available when using the `vertical-cards` and `horizontal-cards` styles.
|
||||||
|
|
||||||
|
##### `comments-url-template`
|
||||||
|
Used to replace the default link for post comments. Useful if you want to use the old Reddit design or any other 3rd party front-end. Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
comments-url-template: https://old.reddit.com/{POST-PATH}
|
||||||
|
```
|
||||||
|
|
||||||
|
Placeholders:
|
||||||
|
|
||||||
|
`{POST-PATH}` - the full path to the post, such as:
|
||||||
|
|
||||||
|
```
|
||||||
|
r/selfhosted/comments/bsp01i/welcome_to_rselfhosted_please_read_this_first/
|
||||||
|
```
|
||||||
|
|
||||||
|
`{POST-ID}` - the ID that comes after `/comments/`
|
||||||
|
|
||||||
|
`{SUBREDDIT}` - the subreddit name
|
||||||
|
|
||||||
|
##### `request-url-template`
|
||||||
|
A custom request url that will be used to fetch the data instead. This is useful when you're hosting Glance on a VPS and Reddit is blocking the requests, and you want to route it through an HTTP proxy.
|
||||||
|
|
||||||
|
Placeholders:
|
||||||
|
|
||||||
|
`{REQUEST-URL}` - will be templated and replaced with the expanded request URL (i.e. https://www.reddit.com/r/selfhosted/hot.json). Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://proxy/{REQUEST-URL}
|
||||||
|
https://your.proxy/?url={REQUEST-URL}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `sort-by`
|
||||||
|
Can be used to specify the order in which the posts should get returned. Possible values are `hot`, `new`, `top` and `rising`.
|
||||||
|
|
||||||
|
##### `top-perid`
|
||||||
|
Available only when `sort-by` is set to `top`. Possible values are `hour`, `day`, `week`, `month`, `year` and `all`.
|
||||||
|
|
||||||
|
##### `search`
|
||||||
|
Keywords to search for. Searching within specific fields is also possible, **though keep in mind that Reddit may remove the ability to use any of these at any time**:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
##### `extra-sort-by`
|
||||||
|
Can be used to specify an additional sort which will be applied on top of the already sorted posts. By default does not apply any extra sorting and the only available option is `engagement`.
|
||||||
|
|
||||||
|
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
|
||||||
|
|
||||||
### Weather
|
### Weather
|
||||||
Display weather information for a specific location. The data is provided by https://open-meteo.com/.
|
Display weather information for a specific location. The data is provided by https://open-meteo.com/.
|
||||||
|
|
||||||
@@ -498,9 +646,19 @@ Example:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- type: weather
|
- type: weather
|
||||||
|
units: metric
|
||||||
location: London, United Kingdom
|
location: London, United Kingdom
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
>
|
||||||
|
> US cities which have common names can have their state specified as the second parameter as such:
|
||||||
|
>
|
||||||
|
> * Greenville, North Carolina, United States
|
||||||
|
> * Greenville, South Carolina, United States
|
||||||
|
> * Greenville, Mississippi, United States
|
||||||
|
|
||||||
|
|
||||||
Preview:
|
Preview:
|
||||||
|
|
||||||

|

|
||||||
@@ -512,14 +670,32 @@ Each bar represents a 2 hour interval. The yellow background represents sunrise
|
|||||||
| Name | Type | Required | Default |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| location | string | yes | |
|
| location | string | yes | |
|
||||||
|
| units | string | no | metric |
|
||||||
| hide-location | boolean | no | false |
|
| hide-location | boolean | no | false |
|
||||||
|
| show-area-name | boolean | no | false |
|
||||||
|
|
||||||
##### `location`
|
##### `location`
|
||||||
The name of the city and country to fetch weather information for. Attempting to launch the applcation with an invalid location will result in an error. You can use the [gecoding API page](https://open-meteo.com/en/docs/geocoding-api) to search for your specific location. Glance will use the first result from the list if there are multiple.
|
The name of the city and country to fetch weather information for. Attempting to launch the applcation with an invalid location will result in an error. You can use the [gecoding API page](https://open-meteo.com/en/docs/geocoding-api) to search for your specific location. Glance will use the first result from the list if there are multiple.
|
||||||
|
|
||||||
|
##### `units`
|
||||||
|
Whether to show the temperature in celsius or fahrenheit, possible values are `metric` or `imperial`.
|
||||||
|
|
||||||
##### `hide-location`
|
##### `hide-location`
|
||||||
Optionally don't display the location name on the widget.
|
Optionally don't display the location name on the widget.
|
||||||
|
|
||||||
|
##### `show-area-name`
|
||||||
|
Whether to display the state/administrative area in the location name. If set to `true` the location will be displayed as:
|
||||||
|
|
||||||
|
```
|
||||||
|
Greenville, North Carolina, United States
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise, if set to `false` (which is the default) it'll be displayed as:
|
||||||
|
|
||||||
|
```
|
||||||
|
Greenville, United States
|
||||||
|
```
|
||||||
|
|
||||||
### Monitor
|
### Monitor
|
||||||
Display a list of sites and whether they are reachable (online) or not. This is determined by sending a HEAD 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 HEAD 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.
|
||||||
|
|
||||||
@@ -558,17 +734,22 @@ You can hover over the "ERROR" text to view more information.
|
|||||||
|
|
||||||
| Name | Type | Required |
|
| Name | Type | Required |
|
||||||
| ---- | ---- | -------- |
|
| ---- | ---- | -------- |
|
||||||
| sites | array | yes | |
|
| sites | array | yes |
|
||||||
|
| style | string | no |
|
||||||
|
|
||||||
|
##### `style`
|
||||||
|
To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
|
||||||
|
|
||||||
##### `sites`
|
##### `sites`
|
||||||
|
|
||||||
Properties for each site:
|
Properties for each site:
|
||||||
|
|
||||||
| Name | Type | Required |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| title | string | yes |
|
| title | string | yes | |
|
||||||
| url | string | yes |
|
| url | string | yes | |
|
||||||
| icon | string | no |
|
| icon | string | no | |
|
||||||
|
| same-tab | boolean | no | false |
|
||||||
|
|
||||||
`title`
|
`title`
|
||||||
|
|
||||||
@@ -576,12 +757,16 @@ The title used to indicate the site.
|
|||||||
|
|
||||||
`url`
|
`url`
|
||||||
|
|
||||||
The URL which will be requested and its response will determine the status of the site.
|
The URL which will be requested and its response will determine the status of the site. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`.
|
||||||
|
|
||||||
`icon`
|
`icon`
|
||||||
|
|
||||||
Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path).
|
Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path).
|
||||||
|
|
||||||
|
`same-tab`
|
||||||
|
|
||||||
|
Whether to open the link in the same or a new tab.
|
||||||
|
|
||||||
### Releases
|
### Releases
|
||||||
Display a list of releases for specific repositories on Github. Draft releases and prereleases will not be shown.
|
Display a list of releases for specific repositories on Github. Draft releases and prereleases will not be shown.
|
||||||
|
|
||||||
@@ -641,6 +826,43 @@ The maximum number of releases to show.
|
|||||||
#### `collapse-after`
|
#### `collapse-after`
|
||||||
How many releases are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
|
How many releases are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
|
||||||
|
|
||||||
|
### Repository
|
||||||
|
Display general information about a repository as well as a list of the latest open pull requests and issues.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: repository
|
||||||
|
repository: glanceapp/glance
|
||||||
|
pull-requests-limit: 5
|
||||||
|
issues-limit: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
|
||||||
|
| Name | Type | Required | Default |
|
||||||
|
| ---- | ---- | -------- | ------- |
|
||||||
|
| repository | string | yes | |
|
||||||
|
| token | string | no | |
|
||||||
|
| pull-requests-limit | integer | no | 3 |
|
||||||
|
| issues-limit | integer | no | 3 |
|
||||||
|
|
||||||
|
##### `repository`
|
||||||
|
The owner and repository name that will have their information displayed.
|
||||||
|
|
||||||
|
##### `token`
|
||||||
|
Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if your cache time is low or you have many instances of this widget. To circumvent this you can [create a read only token from your Github account](https://github.com/settings/personal-access-tokens/new) and provide it here.
|
||||||
|
|
||||||
|
##### `pull-requests-limit`
|
||||||
|
The maximum number of latest open pull requests to show. Set to `-1` to not show any.
|
||||||
|
|
||||||
|
##### `issues-limit`
|
||||||
|
The maximum number of latest open issues to show. Set to `-1` to not show any.
|
||||||
|
|
||||||
### Bookmarks
|
### Bookmarks
|
||||||
Display a list of links which can be grouped.
|
Display a list of links which can be grouped.
|
||||||
|
|
||||||
@@ -690,22 +912,51 @@ Preview:
|
|||||||
| Name | Type | Required |
|
| Name | Type | Required |
|
||||||
| ---- | ---- | -------- |
|
| ---- | ---- | -------- |
|
||||||
| groups | array | yes |
|
| groups | array | yes |
|
||||||
|
| style | string | no |
|
||||||
|
|
||||||
##### `groups`
|
##### `groups`
|
||||||
An array of groups which can optionally have a title and a custom color.
|
An array of groups which can optionally have a title and a custom color.
|
||||||
|
|
||||||
|
##### `style`
|
||||||
|
To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
|
||||||
|
|
||||||
###### Properties for each group
|
###### Properties for each group
|
||||||
| Name | Type | Required | Default |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| title | string | no | |
|
| title | string | no | |
|
||||||
| color | HSL | no | the primary theme color |
|
| color | HSL | no | the primary color of the theme |
|
||||||
| links | array | yes | |
|
| links | array | yes | |
|
||||||
|
|
||||||
###### Properties for each link
|
###### Properties for each link
|
||||||
| Name | Type | Required |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| title | string | yes |
|
| title | string | yes | |
|
||||||
| url | string | yes |
|
| url | string | yes | |
|
||||||
|
| icon | string | no | |
|
||||||
|
| same-tab | boolean | no | false |
|
||||||
|
| hide-arrow | boolean | no | false |
|
||||||
|
|
||||||
|
`icon`
|
||||||
|
|
||||||
|
URL pointing to an image. You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
icon: si:gmail
|
||||||
|
icon: si:youtube
|
||||||
|
icon: si:reddit
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
>
|
||||||
|
> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
|
||||||
|
|
||||||
|
`same-tab`
|
||||||
|
|
||||||
|
Whether to open the link in the same tab or a new one.
|
||||||
|
|
||||||
|
`hide-arrow`
|
||||||
|
|
||||||
|
Whether to hide the colored arrow on each link.
|
||||||
|
|
||||||
### Calendar
|
### Calendar
|
||||||
Display a calendar.
|
Display a calendar.
|
||||||
@@ -736,18 +987,12 @@ Example:
|
|||||||
name: S&P 500
|
name: S&P 500
|
||||||
- symbol: BTC-USD
|
- symbol: BTC-USD
|
||||||
name: Bitcoin
|
name: Bitcoin
|
||||||
|
chart-link: https://www.tradingview.com/chart/?symbol=INDEX:BTCUSD
|
||||||
- symbol: NVDA
|
- symbol: NVDA
|
||||||
name: NVIDIA
|
name: NVIDIA
|
||||||
- symbol: AAPL
|
- symbol: AAPL
|
||||||
|
symbol-link: https://www.google.com/search?tbm=nws&q=apple
|
||||||
name: Apple
|
name: Apple
|
||||||
- symbol: MSFT
|
|
||||||
name: Microsoft
|
|
||||||
- symbol: GOOGL
|
|
||||||
name: Google
|
|
||||||
- symbol: AMD
|
|
||||||
name: AMD
|
|
||||||
- symbol: RDDT
|
|
||||||
name: Reddit
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Preview:
|
Preview:
|
||||||
@@ -759,15 +1004,25 @@ Preview:
|
|||||||
| Name | Type | Required |
|
| Name | Type | Required |
|
||||||
| ---- | ---- | -------- |
|
| ---- | ---- | -------- |
|
||||||
| stocks | array | yes |
|
| stocks | array | yes |
|
||||||
|
| sort-by | string | no |
|
||||||
|
| style | string | no |
|
||||||
|
|
||||||
##### `stocks`
|
##### `stocks`
|
||||||
An array of stocks for which to display information about.
|
An array of stocks for which to display information about.
|
||||||
|
|
||||||
|
##### `sort-by`
|
||||||
|
By default the stocks are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change.
|
||||||
|
|
||||||
|
##### `style`
|
||||||
|
To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
|
||||||
|
|
||||||
###### Properties for each stock
|
###### Properties for each stock
|
||||||
| Name | Type | Required |
|
| Name | Type | Required |
|
||||||
| ---- | ---- | -------- |
|
| ---- | ---- | -------- |
|
||||||
| symbol | string | yes |
|
| symbol | string | yes |
|
||||||
| name | string | no |
|
| name | string | no |
|
||||||
|
| symbol-link | string | no |
|
||||||
|
| chart-link | string | no |
|
||||||
|
|
||||||
`symbol`
|
`symbol`
|
||||||
|
|
||||||
@@ -777,6 +1032,12 @@ The symbol, as seen in Yahoo Finance.
|
|||||||
|
|
||||||
The name that will be displayed under the symbol.
|
The name that will be displayed under the symbol.
|
||||||
|
|
||||||
|
`symbol-link`
|
||||||
|
The link to go to when clicking on the symbol.
|
||||||
|
|
||||||
|
`chart-link`
|
||||||
|
The link to go to when clicking on the chart.
|
||||||
|
|
||||||
### Twitch Channels
|
### Twitch Channels
|
||||||
Display a list of channels from Twitch.
|
Display a list of channels from Twitch.
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 531 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 330 KiB |
@@ -11,8 +11,30 @@ theme:
|
|||||||
contrast-multiplier: 1.1
|
contrast-multiplier: 1.1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Catppuccin
|
### Catppuccin Frappe
|
||||||

|

|
||||||
|
```yaml
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catppuccin Macchiato
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 232 23 18
|
||||||
|
contrast-multiplier: 1.2
|
||||||
|
primary-color: 220 83 75
|
||||||
|
positive-color: 105 48 72
|
||||||
|
negative-color: 351 74 73
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catppuccin Mocha
|
||||||
|

|
||||||
```yaml
|
```yaml
|
||||||
theme:
|
theme:
|
||||||
background-color: 240 21 15
|
background-color: 240 21 15
|
||||||
@@ -42,6 +64,18 @@ theme:
|
|||||||
|
|
||||||
## Light
|
## Light
|
||||||
|
|
||||||
|
### Catppuccin Latte
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
light: true
|
||||||
|
background-color: 220 23 95
|
||||||
|
contrast-multiplier: 1.0
|
||||||
|
primary-color: 220 91 54
|
||||||
|
positive-color: 109 58 40
|
||||||
|
negative-color: 347 87 44
|
||||||
|
```
|
||||||
|
|
||||||
### Peachy
|
### Peachy
|
||||||

|

|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ module github.com/glanceapp/glance
|
|||||||
go 1.22.0
|
go 1.22.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
golang.org/x/text v0.14.0
|
golang.org/x/text v0.14.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
github.com/mmcdole/gofeed v1.3.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
github.com/PuerkitoBio/goquery v1.9.1 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // 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
|
||||||
golang.org/x/net v0.4.0 // indirect
|
golang.org/x/net v0.24.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,34 +1,66 @@
|
|||||||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
|
||||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
||||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
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/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-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
|
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
||||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
|
github.com/mmcdole/goxpp v1.1.1/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/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=
|
||||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
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/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
|
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||||
|
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
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/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
--color-widget-background: hsl(var(--color-widget-background-hsl-values));
|
--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-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-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%)));
|
||||||
|
|
||||||
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
|
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
|
||||||
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
|
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
|
|
||||||
.visited-indicator:not(.text-truncate)::after,
|
.visited-indicator:not(.text-truncate)::after,
|
||||||
.visited-indicator.text-truncate::before,
|
.visited-indicator.text-truncate::before,
|
||||||
.bookmarks-link::after {
|
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
|
||||||
content: '↗';
|
content: '↗';
|
||||||
margin-left: 0.5em;
|
margin-left: 0.5em;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -101,12 +102,13 @@
|
|||||||
.list { --list-half-gap: 0rem; }
|
.list { --list-half-gap: 0rem; }
|
||||||
.list-gap-2 { --list-half-gap: 0.1rem; }
|
.list-gap-2 { --list-half-gap: 0.1rem; }
|
||||||
.list-gap-4 { --list-half-gap: 0.2rem; }
|
.list-gap-4 { --list-half-gap: 0.2rem; }
|
||||||
|
.list-gap-10 { --list-half-gap: 0.5rem; }
|
||||||
.list-gap-14 { --list-half-gap: 0.7rem; }
|
.list-gap-14 { --list-half-gap: 0.7rem; }
|
||||||
.list-gap-20 { --list-half-gap: 1rem; }
|
.list-gap-20 { --list-half-gap: 1rem; }
|
||||||
.list-gap-24 { --list-half-gap: 1.2rem; }
|
.list-gap-24 { --list-half-gap: 1.2rem; }
|
||||||
|
|
||||||
.list > *:not(:first-child) {
|
.list > *:not(:first-child) {
|
||||||
margin-top: calc(var(--list-half-gap) * 2 + 1px);
|
margin-top: calc(var(--list-half-gap) * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-with-separator > *:not(:first-child) {
|
.list-with-separator > *:not(:first-child) {
|
||||||
@@ -204,11 +206,29 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--color-separator);
|
||||||
|
}
|
||||||
|
|
||||||
img, svg {
|
img, svg {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
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 {
|
html {
|
||||||
scrollbar-color: var(--color-text-subdue) transparent;
|
scrollbar-color: var(--color-text-subdue) transparent;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
@@ -313,6 +333,44 @@ body {
|
|||||||
padding: 0 var(--content-bounds-padding);
|
padding: 0 var(--content-bounds-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dynamic-columns {
|
||||||
|
gap: calc(var(--widget-content-vertical-padding) / 2);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--columns-per-row), 1fr);
|
||||||
|
margin: calc(0px - var(--widget-content-vertical-padding) / 2) calc(0px - var(--widget-content-horizontal-padding) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-columns > * {
|
||||||
|
padding: calc(var(--widget-content-vertical-padding) / 2) calc(var(--widget-content-horizontal-padding) / 1.5);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 1500px) {
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
|
@container widget (max-width: 1250px) {
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
|
@container widget (max-width: 850px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
|
||||||
|
}
|
||||||
|
@container widget (max-width: 550px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.cards-vertical {
|
.cards-vertical {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -321,30 +379,44 @@ body {
|
|||||||
--cards-per-row: 6.5;
|
--cards-per-row: 6.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cards-grid {
|
.cards-horizontal, .cards-vertical {
|
||||||
--cards-per-row: 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cards-horizontal, .cards-vertical, .cards-grid {
|
|
||||||
--cards-gap: calc(var(--widget-content-vertical-padding) * 0.7);
|
--cards-gap: calc(var(--widget-content-vertical-padding) * 0.7);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--cards-gap);
|
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 {
|
.cards-horizontal {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cards-grid {
|
.cards-grid {
|
||||||
flex-wrap: wrap;
|
--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: 1300px) { .cards-horizontal { --cards-per-row: 5.5; } }
|
||||||
@container widget (max-width: 1100px) { .cards-horizontal { --cards-per-row: 4.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: 850px) { .cards-horizontal { --cards-per-row: 3.5; } }
|
||||||
@container widget (max-width: 750px) { .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.2; } }
|
@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: 1300px) { .cards-grid { --cards-per-row: 5; } }
|
||||||
@container widget (max-width: 1100px) { .cards-grid { --cards-per-row: 4; } }
|
@container widget (max-width: 1100px) { .cards-grid { --cards-per-row: 4; } }
|
||||||
@@ -352,12 +424,7 @@ body {
|
|||||||
@container widget (max-width: 750px) { .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; } }
|
@container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
|
||||||
|
|
||||||
.card {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: calc(100% / var(--cards-per-row) - var(--cards-gap) * (var(--cards-per-row) - 1) / var(--cards-per-row));
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-error-header {
|
.widget-error-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -489,7 +556,7 @@ body {
|
|||||||
animation-delay: 150ms;
|
animation-delay: 150ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-navigation {
|
.mobile-navigation, .mobile-reachability-header {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +583,10 @@ body {
|
|||||||
width: 6.5rem;
|
width: 6.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stock-chart svg {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.stock-values {
|
.stock-values {
|
||||||
min-width: 8rem;
|
min-width: 8rem;
|
||||||
}
|
}
|
||||||
@@ -552,7 +623,7 @@ body {
|
|||||||
|
|
||||||
.video-thumbnail {
|
.video-thumbnail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 8.9;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
}
|
}
|
||||||
@@ -567,6 +638,21 @@ body {
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.forum-post-list-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.bookmarks-group {
|
.bookmarks-group {
|
||||||
--bookmarks-group-color: var(--color-primary);
|
--bookmarks-group-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
@@ -575,10 +661,31 @@ body {
|
|||||||
color: var(--bookmarks-group-color);
|
color: var(--bookmarks-group-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookmarks-link::after {
|
.bookmarks-group .bookmarks-link::after {
|
||||||
color: var(--bookmarks-group-color);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-icon {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not(.light-scheme) .simple-icon {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-day {
|
.calendar-day {
|
||||||
width: calc(100% / 7);
|
width: calc(100% / 7);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -751,6 +858,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.monitor-site-status-icon {
|
.monitor-site-status-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
@@ -768,11 +876,48 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rss-card-image {
|
.rss-card-image {
|
||||||
height: 10rem;
|
height: var(--rss-thumbnail-height, 10rem);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
.twitch-category-thumbnail {
|
.twitch-category-thumbnail {
|
||||||
width: 5rem;
|
width: 5rem;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
@@ -975,6 +1120,25 @@ body {
|
|||||||
--widget-content-horizontal-padding: 10px;
|
--widget-content-horizontal-padding: 10px;
|
||||||
--content-bounds-padding: 10px;
|
--content-bounds-padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
|
||||||
|
.forum-post-list-item {
|
||||||
|
flex-flow: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-on-mobile {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-reachability-header {
|
||||||
|
display: block;
|
||||||
|
font-size: 3rem;
|
||||||
|
padding: 10dvh 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.size-h1 { font-size: var(--font-size-h1); }
|
.size-h1 { font-size: var(--font-size-h1); }
|
||||||
@@ -996,6 +1160,8 @@ body {
|
|||||||
.text-left { text-align: left; }
|
.text-left { text-align: left; }
|
||||||
.text-right { text-align: right; }
|
.text-right { text-align: right; }
|
||||||
.text-center { text-align: center; }
|
.text-center { text-align: center; }
|
||||||
|
.text-elevate { margin-top: -0.2em; }
|
||||||
|
.text-compact { word-spacing: -0.18em; }
|
||||||
.rtl { direction: rtl; }
|
.rtl { direction: rtl; }
|
||||||
.shrink { flex-shrink: 1; }
|
.shrink { flex-shrink: 1; }
|
||||||
.shrink-0 { flex-shrink: 0; }
|
.shrink-0 { flex-shrink: 0; }
|
||||||
@@ -1011,7 +1177,7 @@ body {
|
|||||||
.justify-center { justify-content: center; }
|
.justify-center { justify-content: center; }
|
||||||
.justify-end { justify-content: end; }
|
.justify-end { justify-content: end; }
|
||||||
.uppercase { text-transform: uppercase; }
|
.uppercase { text-transform: uppercase; }
|
||||||
.flex-grow { flex-grow: 1; }
|
.grow { flex-grow: 1; }
|
||||||
.flex-column { flex-direction: column; }
|
.flex-column { flex-direction: column; }
|
||||||
.items-center { align-items: center; }
|
.items-center { align-items: center; }
|
||||||
.items-start { align-items: start; }
|
.items-start { align-items: start; }
|
||||||
@@ -1024,6 +1190,11 @@ body {
|
|||||||
.margin-top-7 { margin-top: 0.7rem; }
|
.margin-top-7 { margin-top: 0.7rem; }
|
||||||
.margin-top-10 { margin-top: 1rem; }
|
.margin-top-10 { margin-top: 1rem; }
|
||||||
.margin-top-15 { margin-top: 1.5rem; }
|
.margin-top-15 { margin-top: 1.5rem; }
|
||||||
|
.margin-block-3 { margin-block: 0.3rem; }
|
||||||
|
.margin-block-5 { margin-block: 0.5rem; }
|
||||||
|
.margin-block-7 { margin-block: 0.7rem; }
|
||||||
|
.margin-block-10 { margin-block: 1rem; }
|
||||||
|
.margin-block-15 { margin-block: 1.5rem; }
|
||||||
.margin-bottom-3 { margin-bottom: 0.3rem; }
|
.margin-bottom-3 { margin-bottom: 0.3rem; }
|
||||||
.margin-bottom-5 { margin-bottom: 0.5rem; }
|
.margin-bottom-5 { margin-bottom: 0.5rem; }
|
||||||
.margin-bottom-7 { margin-bottom: 0.7rem; }
|
.margin-bottom-7 { margin-bottom: 0.7rem; }
|
||||||
|
|||||||
@@ -142,6 +142,33 @@ function setupDynamicRelativeTime() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupLazyImages() {
|
||||||
|
const images = document.querySelectorAll("img[loading=lazy]");
|
||||||
|
|
||||||
|
if (images.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageFinishedTransition(image) {
|
||||||
|
image.classList.add("finished-transition");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const image = images[i];
|
||||||
|
|
||||||
|
if (image.complete) {
|
||||||
|
image.classList.add("cached");
|
||||||
|
setTimeout(() => imageFinishedTransition(image), 5);
|
||||||
|
} else {
|
||||||
|
// TODO: also handle error event
|
||||||
|
image.addEventListener("load", () => {
|
||||||
|
image.classList.add("loaded");
|
||||||
|
setTimeout(() => imageFinishedTransition(image), 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function setupPage() {
|
async function setupPage() {
|
||||||
const pageElement = document.getElementById("page");
|
const pageElement = document.getElementById("page");
|
||||||
const pageContents = await fetchPageContents(pageData.slug);
|
const pageContents = await fetchPageContents(pageData.slug);
|
||||||
@@ -152,6 +179,7 @@ async function setupPage() {
|
|||||||
document.body.classList.add("animate-element-transition");
|
document.body.classList.add("animate-element-transition");
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
|
setTimeout(setupLazyImages, 5);
|
||||||
setupCarousels();
|
setupCarousels();
|
||||||
setupDynamicRelativeTime();
|
setupDynamicRelativeTime();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,16 @@ var (
|
|||||||
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
||||||
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
||||||
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
|
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
|
||||||
VideosTemplate = compileTemplate("videos.html", "widget-base.html")
|
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
||||||
|
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
||||||
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
|
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
|
||||||
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
||||||
RSSCardsTemplate = compileTemplate("rss-cards.html", "widget-base.html")
|
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
|
||||||
|
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
||||||
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
||||||
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
||||||
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
||||||
|
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
var globalTemplateFunctions = template.FuncMap{
|
var globalTemplateFunctions = template.FuncMap{
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
|
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||||
<ul class="list list-gap-24 list-with-separator">
|
<ul class="list list-gap-24 list-with-separator">
|
||||||
{{ range .Groups }}
|
{{ range .Groups }}
|
||||||
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||||
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
{{ template "group" . }}
|
||||||
<ul class="list list-gap-2">
|
|
||||||
{{ range .Links }}
|
|
||||||
<li><a href="{{ .URL }}" class="bookmarks-link color-highlight size-h4" target="_blank" rel="noreferrer">{{ .Title }}</a></li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
|
{{ else }}
|
||||||
|
<div class="dynamic-columns">
|
||||||
|
{{ range .Groups }}
|
||||||
|
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||||
|
{{ template "group" . }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "group" }}
|
||||||
|
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
||||||
|
<ul class="list list-gap-2">
|
||||||
|
{{ range .Links }}
|
||||||
|
<li class="flex items-center gap-10">
|
||||||
|
{{ if ne "" .Icon }}
|
||||||
|
<div class="bookmarks-icon-container">
|
||||||
|
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
{{ if .Page.ShowMobileHeader }}
|
||||||
|
<div class="mobile-reachability-header">{{ .Page.Title }}</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 }}">
|
||||||
|
|||||||
@@ -4,15 +4,32 @@
|
|||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 list-collapsible">
|
||||||
{{ range $i, $post := .Posts }}
|
{{ range $i, $post := .Posts }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||||
<a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<div class="forum-post-list-item thumbnail-container">
|
||||||
<ul class="list-horizontal-text">
|
{{ if $.ShowThumbnails }}
|
||||||
<li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li>
|
{{ if ne $post.ThumbnailUrl "" }}
|
||||||
<li>{{ $post.Score | formatNumber }} points</li>
|
<img class="forum-post-list-thumbnail thumbnail" src="{{ $post.ThumbnailUrl }}" alt="" loading="lazy">
|
||||||
<li>{{ $post.CommentCount | formatNumber }} comments</li>
|
{{ else if $post.HasTargetUrl }}
|
||||||
{{ if $post.HasTargetUrl }}
|
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||||
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||||
|
</svg>
|
||||||
|
{{ else }}
|
||||||
|
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||||
|
</svg>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
<div class="grow">
|
||||||
|
<a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li>
|
||||||
|
<li>{{ $post.Score | formatNumber }} points</li>
|
||||||
|
<li>{{ $post.CommentCount | formatNumber }} comments</li>
|
||||||
|
{{ if $post.HasTargetUrl }}
|
||||||
|
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,39 +1,53 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
|
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||||
<ul class="list list-gap-20 list-with-separator">
|
<ul class="list list-gap-20 list-with-separator">
|
||||||
{{ range .Sites }}
|
{{ range .Sites }}
|
||||||
<li class="monitor-site flex items-center gap-15">
|
<li class="monitor-site flex items-center gap-15">
|
||||||
{{ if .IconUrl }}
|
{{ template "site" . }}
|
||||||
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
|
|
||||||
{{ end }}
|
|
||||||
<div>
|
|
||||||
<a class="size-h3 color-highlight" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
|
||||||
<ul class="list-horizontal-text">
|
|
||||||
{{ if not .Status.Error }}
|
|
||||||
<li>{{ .StatusText }}</li>
|
|
||||||
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
|
||||||
{{ else if .Status.TimedOut }}
|
|
||||||
<li class="color-negative">Timed Out</li>
|
|
||||||
{{ else }}
|
|
||||||
<li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{{ if eq .StatusStyle "good" }}
|
|
||||||
<div class="monitor-site-status-icon">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
|
||||||
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{{ else }}
|
|
||||||
<div class="monitor-site-status-icon">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
|
|
||||||
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
|
{{ else }}
|
||||||
|
<ul class="dynamic-columns">
|
||||||
|
{{ range .Sites }}
|
||||||
|
<div class="flex items-center gap-15">
|
||||||
|
{{ template "site" . }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "site" }}
|
||||||
|
{{ if .IconUrl }}
|
||||||
|
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
|
||||||
|
{{ end }}
|
||||||
|
<div>
|
||||||
|
<a class="size-h3 color-highlight" href="{{ .Url }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
{{ if not .Status.Error }}
|
||||||
|
<li>{{ .StatusText }}</li>
|
||||||
|
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
||||||
|
{{ else if .Status.TimedOut }}
|
||||||
|
<li class="color-negative">Timed Out</li>
|
||||||
|
{{ else }}
|
||||||
|
<li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ if eq .StatusStyle "good" }}
|
||||||
|
<div class="monitor-site-status-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
||||||
|
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="monitor-site-status-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
|
||||||
|
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "document-root-attrs" }}{{ if .App.Config.Theme.Light }}class="light-scheme"{{ end }}{{ end }}
|
{{ define "document-root-attrs" }}{{ if .App.Config.Theme.Light }}class="light-scheme"{{ end }}{{ end }}
|
||||||
{{ define "document-head-after" }}{{ template "page-style-overrides.gotmpl" . }}{{ end }}
|
{{ define "document-head-after" }}
|
||||||
|
{{ template "page-style-overrides.gotmpl" . }}
|
||||||
|
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
||||||
|
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
{{ define "navigation-links" }}
|
{{ define "navigation-links" }}
|
||||||
{{ range .App.Config.Pages }}
|
{{ range .App.Config.Pages }}
|
||||||
@@ -24,7 +29,7 @@
|
|||||||
<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 -->
|
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
||||||
<div class="logo">G</div>
|
<div class="logo">G</div>
|
||||||
<div class="nav flex flex-grow">
|
<div class="nav flex grow">
|
||||||
{{ template "navigation-links" . }}
|
{{ template "navigation-links" . }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
<img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="padding-widget flex flex-column flex-grow relative">
|
<div class="padding-widget flex flex-column grow relative">
|
||||||
{{ if ne "" .TargetUrl }}
|
{{ if ne "" .TargetUrl }}
|
||||||
<a class="color-highlight size-h5 text-truncate visited-indicator" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
|
<a class="color-highlight size-h5 text-truncate visited-indicator" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-10 list-collapsible">
|
||||||
{{ range $i, $release := .Releases }}
|
{{ range $i, $release := .Releases }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||||
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
|
||||||
|
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{{ if gt (len .RepositoryDetails.PullRequests) 0 }}
|
||||||
|
<hr class="margin-block-10">
|
||||||
|
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>
|
||||||
|
<div class="flex gap-7 size-h5 margin-top-3">
|
||||||
|
<ul class="list list-gap-2">
|
||||||
|
{{ range .RepositoryDetails.PullRequests }}
|
||||||
|
<li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
<ul class="list list-gap-2 min-width-0">
|
||||||
|
{{ range .RepositoryDetails.PullRequests }}
|
||||||
|
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if gt (len .RepositoryDetails.Issues) 0 }}
|
||||||
|
<hr class="margin-block-10">
|
||||||
|
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total)</a>
|
||||||
|
<div class="flex gap-7 size-h5 margin-top-3">
|
||||||
|
<ul class="list list-gap-2">
|
||||||
|
{{ range .RepositoryDetails.Issues }}
|
||||||
|
<li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
<ul class="list list-gap-2 min-width-0">
|
||||||
|
{{ range .RepositoryDetails.Issues }}
|
||||||
|
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="carousel-container">
|
||||||
|
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
|
||||||
|
{{ range .Items }}
|
||||||
|
<div class="card rss-card-2 widget-content-frame thumbnail-container">
|
||||||
|
{{ if ne "" .ImageURL }}
|
||||||
|
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
|
{{ else }}
|
||||||
|
<svg class="rss-card-2-image" style="transform: scale(0.35) translateY(-25%)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||||
|
</svg>
|
||||||
|
{{ end }}
|
||||||
|
<div class="rss-card-2-content padding-inline-widget">
|
||||||
|
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text flex-nowrap margin-top-5">
|
||||||
|
<li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
||||||
|
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="cards-horizontal carousel-items-container">
|
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
|
||||||
{{ range .Items }}
|
{{ range .Items }}
|
||||||
<div class="card widget-content-frame thumbnail-container">
|
<div class="card widget-content-frame thumbnail-container">
|
||||||
{{ if ne "" .ImageURL }}
|
{{ if ne "" .ImageURL }}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="margin-bottom-widget padding-inline-widget flex flex-column flex-grow">
|
<div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
|
||||||
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" 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" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
<li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
||||||
@@ -1,23 +1,39 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
|
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||||
<ul class="list list-gap-20 list-with-separator">
|
<ul class="list list-gap-20 list-with-separator">
|
||||||
{{ range .Stocks }}
|
{{ range .Stocks }}
|
||||||
<li class="flex items-center gap-15">
|
<li class="flex items-center gap-15">
|
||||||
<div class="shrink min-width-0">
|
{{ template "stock" . }}
|
||||||
<div class="color-highlight size-h3 text-truncate">{{ .Symbol }}</div>
|
|
||||||
<div class="text-truncate">{{ .Name }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg class="stock-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>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="stock-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="text-right">${{ .Price | formatPrice }}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
|
{{ else }}
|
||||||
|
<div class="dynamic-columns">
|
||||||
|
{{ range .Stocks }}
|
||||||
|
<div class="flex items-center gap-15">
|
||||||
|
{{ template "stock" . }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "stock" }}
|
||||||
|
<div class="shrink min-width-0">
|
||||||
|
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
|
||||||
|
<div class="text-truncate">{{ .Name }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="stock-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
||||||
|
<svg class="stock-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>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="stock-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="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
|
||||||
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{{ define "video-card-contents" }}
|
||||||
|
<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">
|
||||||
|
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||||
|
<li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
||||||
|
<li class="shrink min-width-0">
|
||||||
|
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="cards-grid">
|
||||||
|
{{ range .Videos }}
|
||||||
|
<div class="card widget-content-frame thumbnail-container">
|
||||||
|
{{ template "video-card-contents" . }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -4,19 +4,10 @@
|
|||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="videos cards-horizontal carousel-items-container">
|
<div class="cards-horizontal carousel-items-container">
|
||||||
{{ range .Videos }}
|
{{ range .Videos }}
|
||||||
<div class="card widget-content-frame thumbnail-container">
|
<div class="card widget-content-frame thumbnail-container">
|
||||||
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
{{ template "video-card-contents" . }}
|
||||||
<div class="margin-top-10 margin-bottom-widget flex flex-column flex-grow padding-inline-widget">
|
|
||||||
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
|
||||||
<li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
|
||||||
<li class="shrink min-width-0">
|
|
||||||
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
|
<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
|
||||||
<div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°C</div>
|
<div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}</div>
|
||||||
|
|
||||||
<div class="weather-columns flex margin-top-15 justify-center">
|
<div class="weather-columns flex margin-top-15 justify-center">
|
||||||
{{ range $i, $column := .Weather.Columns }}
|
{{ range $i, $column := .Weather.Columns }}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
{{ if not .HideLocation }}
|
{{ if not .HideLocation }}
|
||||||
<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
|
<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
|
||||||
<div class="location-icon"></div>
|
<div class="location-icon"></div>
|
||||||
<div class="text-truncate">{{ .Place.Name }}, {{ .Place.Country }}</div>
|
<div class="text-truncate">{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,3 +116,133 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
|
|||||||
|
|
||||||
return appReleases, nil
|
return appReleases, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GithubTicket struct {
|
||||||
|
Number int
|
||||||
|
CreatedAt time.Time
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepositoryDetails struct {
|
||||||
|
Name string
|
||||||
|
Stars int
|
||||||
|
Forks int
|
||||||
|
OpenPullRequests int
|
||||||
|
PullRequests []GithubTicket
|
||||||
|
OpenIssues int
|
||||||
|
Issues []GithubTicket
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubRepositoryDetailsResponseJson struct {
|
||||||
|
Name string `json:"full_name"`
|
||||||
|
Stars int `json:"stargazers_count"`
|
||||||
|
Forks int `json:"forks_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubTicketResponseJson struct {
|
||||||
|
Count int `json:"total_count"`
|
||||||
|
Tickets []struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) {
|
||||||
|
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
|
||||||
|
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
token = fmt.Sprintf("Bearer %s", token)
|
||||||
|
repositoryRequest.Header.Add("Authorization", token)
|
||||||
|
PRsRequest.Header.Add("Authorization", token)
|
||||||
|
issuesRequest.Header.Add("Authorization", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailsResponse githubRepositoryDetailsResponseJson
|
||||||
|
var detailsErr error
|
||||||
|
var PRsResponse githubTicketResponseJson
|
||||||
|
var PRsErr error
|
||||||
|
var issuesResponse githubTicketResponseJson
|
||||||
|
var issuesErr error
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
|
||||||
|
})()
|
||||||
|
|
||||||
|
if maxPRs > 0 {
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxIssues > 0 {
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if detailsErr != nil {
|
||||||
|
return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
details := RepositoryDetails{
|
||||||
|
Name: detailsResponse.Name,
|
||||||
|
Stars: detailsResponse.Stars,
|
||||||
|
Forks: detailsResponse.Forks,
|
||||||
|
PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
|
||||||
|
Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = nil
|
||||||
|
|
||||||
|
if maxPRs > 0 {
|
||||||
|
if PRsErr != nil {
|
||||||
|
err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
|
||||||
|
} else {
|
||||||
|
details.OpenPullRequests = PRsResponse.Count
|
||||||
|
|
||||||
|
for i := range PRsResponse.Tickets {
|
||||||
|
details.PullRequests = append(details.PullRequests, GithubTicket{
|
||||||
|
Number: PRsResponse.Tickets[i].Number,
|
||||||
|
CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
|
||||||
|
Title: PRsResponse.Tickets[i].Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxIssues > 0 {
|
||||||
|
if issuesErr != nil {
|
||||||
|
// TODO: fix, overwriting the previous error
|
||||||
|
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
|
||||||
|
} else {
|
||||||
|
details.OpenIssues = issuesResponse.Count
|
||||||
|
|
||||||
|
for i := range issuesResponse.Tickets {
|
||||||
|
details.Issues = append(details.Issues, GithubTicket{
|
||||||
|
Number: issuesResponse.Tickets[i].Number,
|
||||||
|
CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
|
||||||
|
Title: issuesResponse.Tickets[i].Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@ type hackerNewsPostResponseJson struct {
|
|||||||
TimePosted int64 `json:"time"`
|
TimePosted int64 `json:"time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHackerNewsTopPostIds() ([]int, error) {
|
func getHackerNewsPostIds(sort string) ([]int, error) {
|
||||||
request, _ := http.NewRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", nil)
|
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
|
||||||
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
|
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -28,7 +29,7 @@ func getHackerNewsTopPostIds() ([]int, error) {
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHackerNewsPostsFromIds(postIds []int) (ForumPosts, error) {
|
func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (ForumPosts, error) {
|
||||||
requests := make([]*http.Request, len(postIds))
|
requests := make([]*http.Request, len(postIds))
|
||||||
|
|
||||||
for i, id := range postIds {
|
for i, id := range postIds {
|
||||||
@@ -52,9 +53,17 @@ func getHackerNewsPostsFromIds(postIds []int) (ForumPosts, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var commentsUrl string
|
||||||
|
|
||||||
|
if commentsUrlTemplate == "" {
|
||||||
|
commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
|
||||||
|
} else {
|
||||||
|
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
|
||||||
|
}
|
||||||
|
|
||||||
posts = append(posts, ForumPost{
|
posts = append(posts, ForumPost{
|
||||||
Title: results[i].Title,
|
Title: results[i].Title,
|
||||||
DiscussionUrl: "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id),
|
DiscussionUrl: commentsUrl,
|
||||||
TargetUrl: results[i].TargetUrl,
|
TargetUrl: results[i].TargetUrl,
|
||||||
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
|
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
|
||||||
CommentCount: results[i].CommentCount,
|
CommentCount: results[i].CommentCount,
|
||||||
@@ -74,8 +83,8 @@ func getHackerNewsPostsFromIds(postIds []int) (ForumPosts, error) {
|
|||||||
return posts, nil
|
return posts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchHackerNewsTopPosts(limit int) (ForumPosts, error) {
|
func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) {
|
||||||
postIds, err := getHackerNewsTopPostIds()
|
postIds, err := getHackerNewsPostIds(sort)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -85,5 +94,5 @@ func FetchHackerNewsTopPosts(limit int) (ForumPosts, error) {
|
|||||||
postIds = postIds[:limit]
|
postIds = postIds[:limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
return getHackerNewsPostsFromIds(postIds)
|
return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "time/tzdata"
|
_ "time/tzdata"
|
||||||
@@ -17,6 +18,7 @@ type PlacesResponseJson struct {
|
|||||||
|
|
||||||
type PlaceJson struct {
|
type PlaceJson struct {
|
||||||
Name string
|
Name string
|
||||||
|
Area string `json:"admin1"`
|
||||||
Latitude float64
|
Latitude float64
|
||||||
Longitude float64
|
Longitude float64
|
||||||
Timezone string
|
Timezone string
|
||||||
@@ -48,8 +50,41 @@ type weatherColumn struct {
|
|||||||
HasPrecipitation bool
|
HasPrecipitation bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var commonCountryAbbreviations = map[string]string{
|
||||||
|
"US": "United States",
|
||||||
|
"USA": "United States",
|
||||||
|
"UK": "United Kingdom",
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandCountryAbbreviations(name string) string {
|
||||||
|
if expanded, ok := commonCountryAbbreviations[strings.TrimSpace(name)]; ok {
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separates the location that Open Meteo accepts from the administrative area
|
||||||
|
// which can then be used to filter to the correct place after the list of places
|
||||||
|
// has been retrieved. Also expands abbreviations since Open Meteo does not accept
|
||||||
|
// country names like "US", "USA" and "UK"
|
||||||
|
func parsePlaceName(name string) (string, string) {
|
||||||
|
parts := strings.Split(name, ",")
|
||||||
|
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return name, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return parts[0] + ", " + expandCountryAbbreviations(parts[1]), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
func FetchPlaceFromName(location string) (*PlaceJson, error) {
|
func FetchPlaceFromName(location string) (*PlaceJson, error) {
|
||||||
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=en&format=json", url.QueryEscape(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))
|
||||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||||
responseJson, err := decodeJsonFromRequest[PlacesResponseJson](defaultClient, request)
|
responseJson, err := decodeJsonFromRequest[PlacesResponseJson](defaultClient, request)
|
||||||
|
|
||||||
@@ -61,7 +96,24 @@ func FetchPlaceFromName(location string) (*PlaceJson, error) {
|
|||||||
return nil, fmt.Errorf("no places found for %s", location)
|
return nil, fmt.Errorf("no places found for %s", location)
|
||||||
}
|
}
|
||||||
|
|
||||||
place := &responseJson.Results[0]
|
var place *PlaceJson
|
||||||
|
|
||||||
|
if area != "" {
|
||||||
|
area = strings.ToLower(area)
|
||||||
|
|
||||||
|
for i := range responseJson.Results {
|
||||||
|
if strings.ToLower(responseJson.Results[i].Area) == area {
|
||||||
|
place = &responseJson.Results[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if place == nil {
|
||||||
|
return nil, fmt.Errorf("no place found for %s in %s", location, area)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
place = &responseJson.Results[0]
|
||||||
|
}
|
||||||
|
|
||||||
loc, err := time.LoadLocation(place.Timezone)
|
loc, err := time.LoadLocation(place.Timezone)
|
||||||
|
|
||||||
@@ -79,18 +131,25 @@ func barIndexFromHour(h int) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: bunch of spaget, refactor
|
// TODO: bunch of spaget, refactor
|
||||||
// TODO: allow changing between C and F
|
func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
|
||||||
func FetchWeatherForPlace(place *PlaceJson) (*Weather, error) {
|
|
||||||
query := url.Values{}
|
query := url.Values{}
|
||||||
|
var temperatureUnit string
|
||||||
|
|
||||||
|
if units == "imperial" {
|
||||||
|
temperatureUnit = "fahrenheit"
|
||||||
|
} else {
|
||||||
|
temperatureUnit = "celsius"
|
||||||
|
}
|
||||||
|
|
||||||
query.Add("latitude", fmt.Sprintf("%f", place.Latitude))
|
query.Add("latitude", fmt.Sprintf("%f", place.Latitude))
|
||||||
query.Add("longitude", fmt.Sprintf("%f", place.Longitude))
|
query.Add("longitude", fmt.Sprintf("%f", place.Longitude))
|
||||||
query.Add("timeformat", "unixtime")
|
query.Add("timeformat", "unixtime")
|
||||||
query.Add("timezone", place.Timezone)
|
query.Add("timezone", place.Timezone)
|
||||||
query.Add("forecast_days", "1")
|
query.Add("forecast_days", "1")
|
||||||
query.Add("current", "temperature_2m,apparent_temperature,weather_code,wind_speed_10m")
|
query.Add("current", "temperature_2m,apparent_temperature,weather_code")
|
||||||
query.Add("hourly", "temperature_2m,precipitation_probability")
|
query.Add("hourly", "temperature_2m,precipitation_probability")
|
||||||
query.Add("daily", "sunrise,sunset")
|
query.Add("daily", "sunrise,sunset")
|
||||||
|
query.Add("temperature_unit", temperatureUnit)
|
||||||
|
|
||||||
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
|
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
|
||||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||||
|
|||||||
@@ -59,12 +59,40 @@ type Video struct {
|
|||||||
|
|
||||||
type Videos []Video
|
type Videos []Video
|
||||||
|
|
||||||
|
var currencyToSymbol = map[string]string{
|
||||||
|
"USD": "$",
|
||||||
|
"EUR": "€",
|
||||||
|
"JPY": "¥",
|
||||||
|
"CAD": "C$",
|
||||||
|
"AUD": "A$",
|
||||||
|
"GBP": "£",
|
||||||
|
"CHF": "Fr",
|
||||||
|
"NZD": "N$",
|
||||||
|
"INR": "₹",
|
||||||
|
"BRL": "R$",
|
||||||
|
"RUB": "₽",
|
||||||
|
"TRY": "₺",
|
||||||
|
"ZAR": "R",
|
||||||
|
"CNY": "¥",
|
||||||
|
"KRW": "₩",
|
||||||
|
"HKD": "HK$",
|
||||||
|
"SGD": "S$",
|
||||||
|
"SEK": "kr",
|
||||||
|
"NOK": "kr",
|
||||||
|
"DKK": "kr",
|
||||||
|
"PLN": "zł",
|
||||||
|
"PHP": "₱",
|
||||||
|
}
|
||||||
|
|
||||||
type Stock struct {
|
type Stock struct {
|
||||||
Name string
|
Name string `yaml:"name"`
|
||||||
Symbol string
|
Symbol string `yaml:"symbol"`
|
||||||
Price float64
|
ChartLink string `yaml:"chart-link"`
|
||||||
PercentChange float64
|
SymbolLink string `yaml:"symbol-link"`
|
||||||
SvgChartPoints string
|
Currency string `yaml:"-"`
|
||||||
|
Price float64 `yaml:"-"`
|
||||||
|
PercentChange float64 `yaml:"-"`
|
||||||
|
SvgChartPoints string `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stocks []Stock
|
type Stocks []Stock
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"html"
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ type subredditResponseJson struct {
|
|||||||
Data struct {
|
Data struct {
|
||||||
Children []struct {
|
Children []struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
|
Id string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Upvotes int `json:"ups"`
|
Upvotes int `json:"ups"`
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
@@ -28,8 +30,29 @@ type subredditResponseJson struct {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchSubredditPosts(subreddit string) (ForumPosts, error) {
|
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string) (ForumPosts, error) {
|
||||||
requestUrl := fmt.Sprintf("https://www.reddit.com/r/%s/hot.json", url.QueryEscape(subreddit))
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestUrlTemplate != "" {
|
||||||
|
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
|
||||||
|
}
|
||||||
|
|
||||||
request, err := http.NewRequest("GET", requestUrl, nil)
|
request, err := http.NewRequest("GET", requestUrl, nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,9 +80,19 @@ func FetchSubredditPosts(subreddit string) (ForumPosts, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var commentsUrl string
|
||||||
|
|
||||||
|
if commentsUrlTemplate == "" {
|
||||||
|
commentsUrl = "https://www.reddit.com" + post.Permalink
|
||||||
|
} else {
|
||||||
|
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{SUBREDDIT}", subreddit)
|
||||||
|
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-ID}", post.Id)
|
||||||
|
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-PATH}", strings.TrimLeft(post.Permalink, "/"))
|
||||||
|
}
|
||||||
|
|
||||||
forumPost := ForumPost{
|
forumPost := ForumPost{
|
||||||
Title: html.UnescapeString(post.Title),
|
Title: html.UnescapeString(post.Title),
|
||||||
DiscussionUrl: "https://www.reddit.com" + post.Permalink,
|
DiscussionUrl: commentsUrl,
|
||||||
TargetUrlDomain: post.Domain,
|
TargetUrlDomain: post.Domain,
|
||||||
CommentCount: post.CommentsCount,
|
CommentCount: post.CommentsCount,
|
||||||
Score: post.Upvotes,
|
Score: post.Upvotes,
|
||||||
@@ -77,7 +110,5 @@ func FetchSubredditPosts(subreddit string) (ForumPosts, error) {
|
|||||||
posts = append(posts, forumPost)
|
posts = append(posts, forumPost)
|
||||||
}
|
}
|
||||||
|
|
||||||
posts.CalculateEngagement()
|
|
||||||
|
|
||||||
return posts, nil
|
return posts, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ func addBrowserUserAgentHeader(request *http.Request) {
|
|||||||
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0")
|
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func truncateString(s string, maxLen int) string {
|
||||||
|
asRunes := []rune(s)
|
||||||
|
|
||||||
|
if len(asRunes) > maxLen {
|
||||||
|
return string(asRunes[:maxLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, error) {
|
func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, error) {
|
||||||
response, err := client.Do(request)
|
response, err := client.Do(request)
|
||||||
var result T
|
var result T
|
||||||
@@ -40,7 +50,12 @@ func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if response.StatusCode != http.StatusOK {
|
if response.StatusCode != http.StatusOK {
|
||||||
return result, fmt.Errorf("unexpected status code %d for %s, response: %s", response.StatusCode, request.URL, string(body))
|
return result, fmt.Errorf(
|
||||||
|
"unexpected status code %d for %s, response: %s",
|
||||||
|
response.StatusCode,
|
||||||
|
request.URL,
|
||||||
|
truncateString(string(body), 256),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(body, &result)
|
err = json.Unmarshal(body, &result)
|
||||||
@@ -76,7 +91,12 @@ func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if response.StatusCode != http.StatusOK {
|
if response.StatusCode != http.StatusOK {
|
||||||
return result, fmt.Errorf("unexpected status code %d for %s, response: %s", response.StatusCode, request.URL, string(body))
|
return result, fmt.Errorf(
|
||||||
|
"unexpected status code %d for %s, response: %s",
|
||||||
|
response.StatusCode,
|
||||||
|
request.URL,
|
||||||
|
truncateString(string(body), 256),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = xml.Unmarshal(body, &result)
|
err = xml.Unmarshal(body, &result)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func extractDomainFromUrl(u string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimPrefix(parsed.Host, "www.")
|
return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
|
func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type stockResponseJson struct {
|
|||||||
Chart struct {
|
Chart struct {
|
||||||
Result []struct {
|
Result []struct {
|
||||||
Meta struct {
|
Meta struct {
|
||||||
|
Currency string `json:"currency"`
|
||||||
Symbol string `json:"symbol"`
|
Symbol string `json:"symbol"`
|
||||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||||
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
||||||
@@ -23,15 +24,10 @@ type stockResponseJson struct {
|
|||||||
} `json:"chart"`
|
} `json:"chart"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StockRequest struct {
|
|
||||||
Symbol string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: allow changing chart time frame
|
// TODO: allow changing chart time frame
|
||||||
const stockChartDays = 21
|
const stockChartDays = 21
|
||||||
|
|
||||||
func FetchStocksDataFromYahoo(stockRequests []StockRequest) (Stocks, error) {
|
func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
|
||||||
requests := make([]*http.Request, 0, len(stockRequests))
|
requests := make([]*http.Request, 0, len(stockRequests))
|
||||||
|
|
||||||
for i := range stockRequests {
|
for i := range stockRequests {
|
||||||
@@ -78,10 +74,19 @@ func FetchStocksDataFromYahoo(stockRequests []StockRequest) (Stocks, error) {
|
|||||||
|
|
||||||
points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
||||||
|
|
||||||
|
currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
currency = response.Chart.Result[0].Meta.Currency
|
||||||
|
}
|
||||||
|
|
||||||
stocks = append(stocks, Stock{
|
stocks = append(stocks, Stock{
|
||||||
Name: stockRequests[i].Name,
|
Name: stockRequests[i].Name,
|
||||||
Symbol: response.Chart.Result[0].Meta.Symbol,
|
Symbol: response.Chart.Result[0].Meta.Symbol,
|
||||||
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
|
SymbolLink: stockRequests[i].SymbolLink,
|
||||||
|
ChartLink: stockRequests[i].ChartLink,
|
||||||
|
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||||
|
Currency: currency,
|
||||||
PercentChange: percentChange(
|
PercentChange: percentChange(
|
||||||
response.Chart.Result[0].Meta.RegularMarketPrice,
|
response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||||
previous,
|
previous,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -38,7 +39,7 @@ func parseYoutubeFeedTime(t string) time.Time {
|
|||||||
return parsedTime
|
return parsedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchYoutubeChannelUploads(channelIds []string) (Videos, error) {
|
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (Videos, error) {
|
||||||
requests := make([]*http.Request, 0, len(channelIds))
|
requests := make([]*http.Request, 0, len(channelIds))
|
||||||
|
|
||||||
for i := range channelIds {
|
for i := range channelIds {
|
||||||
@@ -75,10 +76,24 @@ func FetchYoutubeChannelUploads(channelIds []string) (Videos, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var videoUrl string
|
||||||
|
|
||||||
|
if videoUrlTemplate == "" {
|
||||||
|
videoUrl = video.Link.Href
|
||||||
|
} else {
|
||||||
|
parsedUrl, err := url.Parse(video.Link.Href)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v"))
|
||||||
|
} else {
|
||||||
|
videoUrl = "#"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
videos = append(videos, Video{
|
videos = append(videos, Video{
|
||||||
ThumbnailUrl: video.Group.Thumbnail.Url,
|
ThumbnailUrl: video.Group.Thumbnail.Url,
|
||||||
Title: video.Title,
|
Title: video.Title,
|
||||||
Url: video.Link.Href,
|
Url: videoUrl,
|
||||||
Author: response.Channel,
|
Author: response.Channel,
|
||||||
AuthorUrl: response.ChannelLink.Href + "/videos",
|
AuthorUrl: response.ChannelLink.Href + "/videos",
|
||||||
TimePosted: parseYoutubeFeedTime(video.Published),
|
TimePosted: parseYoutubeFeedTime(video.Published),
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type Theme struct {
|
|||||||
Light bool `yaml:"light"`
|
Light bool `yaml:"light"`
|
||||||
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
||||||
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
||||||
|
CustomCSSFile string `yaml:"custom-css-file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -54,10 +55,11 @@ type templateData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Page struct {
|
type Page struct {
|
||||||
Title string `yaml:"name"`
|
Title string `yaml:"name"`
|
||||||
Slug string `yaml:"slug"`
|
Slug string `yaml:"slug"`
|
||||||
Columns []Column `yaml:"columns"`
|
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
||||||
mu sync.Mutex
|
Columns []Column `yaml:"columns"`
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Page) UpdateOutdatedWidgets() {
|
func (p *Page) UpdateOutdatedWidgets() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package widget
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/glanceapp/glance/internal/assets"
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
)
|
)
|
||||||
@@ -13,14 +14,34 @@ type Bookmarks struct {
|
|||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
Color *HSLColorField `yaml:"color"`
|
Color *HSLColorField `yaml:"color"`
|
||||||
Links []struct {
|
Links []struct {
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
|
Icon string `yaml:"icon"`
|
||||||
|
IsSimpleIcon bool `yaml:"-"`
|
||||||
|
SameTab bool `yaml:"same-tab"`
|
||||||
|
HideArrow bool `yaml:"hide-arrow"`
|
||||||
} `yaml:"links"`
|
} `yaml:"links"`
|
||||||
} `yaml:"groups"`
|
} `yaml:"groups"`
|
||||||
|
Style string `yaml:"style"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Bookmarks) Initialize() error {
|
func (widget *Bookmarks) Initialize() error {
|
||||||
widget.withTitle("Bookmarks").withError(nil)
|
widget.withTitle("Bookmarks").withError(nil)
|
||||||
|
|
||||||
|
for g := range widget.Groups {
|
||||||
|
for l := range widget.Groups[g].Links {
|
||||||
|
if widget.Groups[g].Links[l].Icon == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(widget.Groups[g].Links[l].Icon, "si:") {
|
||||||
|
icon := strings.TrimPrefix(widget.Groups[g].Links[l].Icon, "si:")
|
||||||
|
widget.Groups[g].Links[l].IsSimpleIcon = true
|
||||||
|
widget.Groups[g].Links[l].Icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
widget.cachedHTML = widget.render(widget, assets.BookmarksTemplate)
|
widget.cachedHTML = widget.render(widget, assets.BookmarksTemplate)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type HackerNews struct {
|
type HackerNews struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
Posts feed.ForumPosts `yaml:"-"`
|
Posts feed.ForumPosts `yaml:"-"`
|
||||||
Limit int `yaml:"limit"`
|
Limit int `yaml:"limit"`
|
||||||
CollapseAfter int `yaml:"collapse-after"`
|
SortBy string `yaml:"sort-by"`
|
||||||
|
ExtraSortBy string `yaml:"extra-sort-by"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
CommentsUrlTemplate string `yaml:"comments-url-template"`
|
||||||
|
ShowThumbnails bool `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *HackerNews) Initialize() error {
|
func (widget *HackerNews) Initialize() error {
|
||||||
@@ -27,18 +31,24 @@ func (widget *HackerNews) Initialize() error {
|
|||||||
widget.CollapseAfter = 5
|
widget.CollapseAfter = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" {
|
||||||
|
widget.SortBy = "top"
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *HackerNews) Update(ctx context.Context) {
|
func (widget *HackerNews) Update(ctx context.Context) {
|
||||||
posts, err := feed.FetchHackerNewsTopPosts(40)
|
posts, err := feed.FetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate)
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
posts.CalculateEngagement()
|
if widget.ExtraSortBy == "engagement" {
|
||||||
posts.SortByEngagement()
|
posts.CalculateEngagement()
|
||||||
|
posts.SortByEngagement()
|
||||||
|
}
|
||||||
|
|
||||||
if widget.Limit < len(posts) {
|
if widget.Limit < len(posts) {
|
||||||
posts = posts[:widget.Limit]
|
posts = posts[:widget.Limit]
|
||||||
|
|||||||
@@ -46,13 +46,15 @@ func statusCodeToStyle(status int) string {
|
|||||||
type Monitor struct {
|
type Monitor struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
Sites []struct {
|
Sites []struct {
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
Url string `yaml:"url"`
|
Url OptionalEnvString `yaml:"url"`
|
||||||
IconUrl string `yaml:"icon"`
|
IconUrl string `yaml:"icon"`
|
||||||
Status *feed.SiteStatus `yaml:"-"`
|
SameTab bool `yaml:"same-tab"`
|
||||||
StatusText string `yaml:"-"`
|
Status *feed.SiteStatus `yaml:"-"`
|
||||||
StatusStyle string `yaml:"-"`
|
StatusText string `yaml:"-"`
|
||||||
|
StatusStyle string `yaml:"-"`
|
||||||
} `yaml:"sites"`
|
} `yaml:"sites"`
|
||||||
|
Style string `yaml:"style"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Monitor) Initialize() error {
|
func (widget *Monitor) Initialize() error {
|
||||||
@@ -65,7 +67,7 @@ func (widget *Monitor) Update(ctx context.Context) {
|
|||||||
requests := make([]*http.Request, len(widget.Sites))
|
requests := make([]*http.Request, len(widget.Sites))
|
||||||
|
|
||||||
for i := range widget.Sites {
|
for i := range widget.Sites {
|
||||||
request, err := http.NewRequest("HEAD", widget.Sites[i].Url, nil)
|
request, err := http.NewRequest("GET", string(widget.Sites[i].Url), nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
message := fmt.Errorf("failed to create http request for %s: %s", widget.Sites[i].Url, err)
|
message := fmt.Errorf("failed to create http request for %s: %s", widget.Sites[i].Url, err)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glanceapp/glance/internal/assets"
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
@@ -11,12 +12,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Reddit struct {
|
type Reddit struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
Posts feed.ForumPosts `yaml:"-"`
|
Posts feed.ForumPosts `yaml:"-"`
|
||||||
Subreddit string `yaml:"subreddit"`
|
Subreddit string `yaml:"subreddit"`
|
||||||
Style string `yaml:"style"`
|
Style string `yaml:"style"`
|
||||||
Limit int `yaml:"limit"`
|
ShowThumbnails bool `yaml:"show-thumbnails"`
|
||||||
CollapseAfter int `yaml:"collapse-after"`
|
SortBy string `yaml:"sort-by"`
|
||||||
|
TopPeriod string `yaml:"top-period"`
|
||||||
|
Search string `yaml:"search"`
|
||||||
|
ExtraSortBy string `yaml:"extra-sort-by"`
|
||||||
|
CommentsUrlTemplate string `yaml:"comments-url-template"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
RequestUrlTemplate string `yaml:"request-url-template"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Reddit) Initialize() error {
|
func (widget *Reddit) Initialize() error {
|
||||||
@@ -32,13 +40,51 @@ func (widget *Reddit) Initialize() error {
|
|||||||
widget.CollapseAfter = 5
|
widget.CollapseAfter = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !isValidRedditSortType(widget.SortBy) {
|
||||||
|
widget.SortBy = "hot"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidRedditTopPeriod(widget.TopPeriod) {
|
||||||
|
widget.TopPeriod = "day"
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.RequestUrlTemplate != "" {
|
||||||
|
if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") {
|
||||||
|
return errors.New("no `{REQUEST-URL}` placeholder specified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
widget.withTitle("/r/" + widget.Subreddit).withCacheDuration(30 * time.Minute)
|
widget.withTitle("/r/" + widget.Subreddit).withCacheDuration(30 * time.Minute)
|
||||||
|
|
||||||
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 *Reddit) Update(ctx context.Context) {
|
func (widget *Reddit) Update(ctx context.Context) {
|
||||||
posts, err := feed.FetchSubredditPosts(widget.Subreddit)
|
// TODO: refactor, use a struct to pass all of these
|
||||||
|
posts, err := feed.FetchSubredditPosts(
|
||||||
|
widget.Subreddit,
|
||||||
|
widget.SortBy,
|
||||||
|
widget.TopPeriod,
|
||||||
|
widget.Search,
|
||||||
|
widget.CommentsUrlTemplate,
|
||||||
|
widget.RequestUrlTemplate,
|
||||||
|
)
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
@@ -48,7 +94,11 @@ func (widget *Reddit) Update(ctx context.Context) {
|
|||||||
posts = posts[:widget.Limit]
|
posts = posts[:widget.Limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
posts.SortByEngagement()
|
if widget.ExtraSortBy == "engagement" {
|
||||||
|
posts.CalculateEngagement()
|
||||||
|
posts.SortByEngagement()
|
||||||
|
}
|
||||||
|
|
||||||
widget.Posts = posts
|
widget.Posts = posts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
"github.com/glanceapp/glance/internal/feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
RequestedRepository string `yaml:"repository"`
|
||||||
|
Token OptionalEnvString `yaml:"token"`
|
||||||
|
PullRequestsLimit int `yaml:"pull-requests-limit"`
|
||||||
|
IssuesLimit int `yaml:"issues-limit"`
|
||||||
|
RepositoryDetails feed.RepositoryDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Repository) Initialize() error {
|
||||||
|
widget.withTitle("Repository").withCacheDuration(1 * time.Hour)
|
||||||
|
|
||||||
|
if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 {
|
||||||
|
widget.PullRequestsLimit = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 {
|
||||||
|
widget.IssuesLimit = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Repository) Update(ctx context.Context) {
|
||||||
|
details, err := feed.FetchRepositoryDetailsFromGithub(
|
||||||
|
widget.RequestedRepository,
|
||||||
|
string(widget.Token),
|
||||||
|
widget.PullRequestsLimit,
|
||||||
|
widget.IssuesLimit,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.RepositoryDetails = details
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Repository) Render() template.HTML {
|
||||||
|
return widget.render(widget, assets.RepositoryTemplate)
|
||||||
|
}
|
||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type RSS struct {
|
type RSS struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
FeedRequests []feed.RSSFeedRequest `yaml:"feeds"`
|
FeedRequests []feed.RSSFeedRequest `yaml:"feeds"`
|
||||||
Style string `yaml:"style"`
|
Style string `yaml:"style"`
|
||||||
Items feed.RSSFeedItems `yaml:"-"`
|
ThumbnailHeight float64 `yaml:"thumbnail-height"`
|
||||||
Limit int `yaml:"limit"`
|
CardHeight float64 `yaml:"card-height"`
|
||||||
CollapseAfter int `yaml:"collapse-after"`
|
Items feed.RSSFeedItems `yaml:"-"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *RSS) Initialize() error {
|
func (widget *RSS) Initialize() error {
|
||||||
@@ -29,6 +31,14 @@ func (widget *RSS) Initialize() error {
|
|||||||
widget.CollapseAfter = 5
|
widget.CollapseAfter = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if widget.ThumbnailHeight < 0 {
|
||||||
|
widget.ThumbnailHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.CardHeight < 0 {
|
||||||
|
widget.CardHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +58,11 @@ func (widget *RSS) Update(ctx context.Context) {
|
|||||||
|
|
||||||
func (widget *RSS) Render() template.HTML {
|
func (widget *RSS) Render() template.HTML {
|
||||||
if widget.Style == "horizontal-cards" {
|
if widget.Style == "horizontal-cards" {
|
||||||
return widget.render(widget, assets.RSSCardsTemplate)
|
return widget.render(widget, assets.RSSHorizontalCardsTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Style == "horizontal-cards-2" {
|
||||||
|
return widget.render(widget, assets.RSSHorizontalCards2Template)
|
||||||
}
|
}
|
||||||
|
|
||||||
return widget.render(widget, assets.RSSListTemplate)
|
return widget.render(widget, assets.RSSListTemplate)
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import (
|
|||||||
"github.com/glanceapp/glance/internal/feed"
|
"github.com/glanceapp/glance/internal/feed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TODO: rename to Markets at some point
|
||||||
type Stocks struct {
|
type Stocks struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
Stocks feed.Stocks `yaml:"-"`
|
Stocks feed.Stocks `yaml:"stocks"`
|
||||||
Tickers []feed.StockRequest `yaml:"stocks"`
|
Sort string `yaml:"sort-by"`
|
||||||
|
Style string `yaml:"style"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Stocks) Initialize() error {
|
func (widget *Stocks) Initialize() error {
|
||||||
@@ -22,13 +24,16 @@ func (widget *Stocks) Initialize() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Stocks) Update(ctx context.Context) {
|
func (widget *Stocks) Update(ctx context.Context) {
|
||||||
stocks, err := feed.FetchStocksDataFromYahoo(widget.Tickers)
|
stocks, err := feed.FetchStocksDataFromYahoo(widget.Stocks)
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stocks.SortByAbsChange()
|
if widget.Sort == "absolute-change" {
|
||||||
|
stocks.SortByAbsChange()
|
||||||
|
}
|
||||||
|
|
||||||
widget.Stocks = stocks
|
widget.Stocks = stocks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Videos struct {
|
type Videos struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
Videos feed.Videos `yaml:"-"`
|
Videos feed.Videos `yaml:"-"`
|
||||||
Channels []string `yaml:"channels"`
|
VideoUrlTemplate string `yaml:"video-url-template"`
|
||||||
Limit int `yaml:"limit"`
|
Style string `yaml:"style"`
|
||||||
|
Channels []string `yaml:"channels"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Videos) Initialize() error {
|
func (widget *Videos) Initialize() error {
|
||||||
@@ -27,7 +29,7 @@ func (widget *Videos) Initialize() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Videos) Update(ctx context.Context) {
|
func (widget *Videos) Update(ctx context.Context) {
|
||||||
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels)
|
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate)
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
@@ -41,5 +43,9 @@ func (widget *Videos) Update(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Videos) Render() template.HTML {
|
func (widget *Videos) Render() template.HTML {
|
||||||
|
if widget.Style == "grid-cards" {
|
||||||
|
return widget.render(widget, assets.VideosGridTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
return widget.render(widget, assets.VideosTemplate)
|
return widget.render(widget, assets.VideosTemplate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import (
|
|||||||
type Weather struct {
|
type Weather struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
Location string `yaml:"location"`
|
Location string `yaml:"location"`
|
||||||
|
ShowAreaName bool `yaml:"show-area-name"`
|
||||||
HideLocation bool `yaml:"hide-location"`
|
HideLocation bool `yaml:"hide-location"`
|
||||||
|
Units string `yaml:"units"`
|
||||||
Place *feed.PlaceJson `yaml:"-"`
|
Place *feed.PlaceJson `yaml:"-"`
|
||||||
Weather *feed.Weather `yaml:"-"`
|
Weather *feed.Weather `yaml:"-"`
|
||||||
TimeLabels [12]string `yaml:"-"`
|
TimeLabels [12]string `yaml:"-"`
|
||||||
@@ -24,6 +26,12 @@ func (widget *Weather) Initialize() error {
|
|||||||
widget.withTitle("Weather").withCacheOnTheHour()
|
widget.withTitle("Weather").withCacheOnTheHour()
|
||||||
widget.TimeLabels = timeLabels
|
widget.TimeLabels = timeLabels
|
||||||
|
|
||||||
|
if widget.Units == "" {
|
||||||
|
widget.Units = "metric"
|
||||||
|
} else if widget.Units != "metric" && widget.Units != "imperial" {
|
||||||
|
return fmt.Errorf("invalid units '%s' for weather, must be either metric or imperial", widget.Units)
|
||||||
|
}
|
||||||
|
|
||||||
place, err := feed.FetchPlaceFromName(widget.Location)
|
place, err := feed.FetchPlaceFromName(widget.Location)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -36,7 +44,7 @@ func (widget *Weather) Initialize() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Weather) Update(ctx context.Context) {
|
func (widget *Weather) Update(ctx context.Context) {
|
||||||
weather, err := feed.FetchWeatherForPlace(widget.Place)
|
weather, err := feed.FetchWeatherForPlace(widget.Place, widget.Units)
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ func New(widgetType string) (Widget, error) {
|
|||||||
return &TwitchGames{}, nil
|
return &TwitchGames{}, nil
|
||||||
case "twitch-channels":
|
case "twitch-channels":
|
||||||
return &TwitchChannels{}, nil
|
return &TwitchChannels{}, nil
|
||||||
|
case "repository":
|
||||||
|
return &Repository{}, nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ var buildTargets = []buildTarget{
|
|||||||
arch: "arm",
|
arch: "arm",
|
||||||
armV: 7,
|
armV: 7,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
os: "openbsd",
|
||||||
|
arch: "amd64",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
os: "openbsd",
|
||||||
|
arch: "386",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -113,7 +121,7 @@ func main() {
|
|||||||
|
|
||||||
output, err := exec.Command(
|
output, err := exec.Command(
|
||||||
"sudo", "docker", "build",
|
"sudo", "docker", "build",
|
||||||
"--platform=linux/arm64,linux/amd64",
|
"--platform=linux/amd64,linux/arm64,linux/arm/v7",
|
||||||
"-t", versionTag,
|
"-t", versionTag,
|
||||||
"-t", latestTag,
|
"-t", latestTag,
|
||||||
".",
|
".",
|
||||||
|
|||||||