Merge pull request #57 from glanceapp/release/v0.4.0

Release v0.4.0
This commit is contained in:
Svilen Markov 2024-05-12 22:12:00 +01:00 committed by GitHub
commit 7743664527
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 835 additions and 161 deletions

View File

@ -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
@ -48,7 +49,7 @@ Checkout the [releases page](https://github.com/glanceapp/glance/releases) for a
#### 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.
```bash ```bash
docker run -d -p 8080:8080 \ docker run -d -p 8080:8080 \

View File

@ -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)
@ -250,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.
@ -262,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:
![](images/mobile-header-preview.png)
### 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:
@ -384,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 |
@ -398,6 +409,16 @@ Used to change the appearance of the widget. Possible values are `vertical-list`
![preview of horizontal-cards style for RSS widget](images/rss-feed-horizontal-cards-preview.png) ![preview of horizontal-cards style for RSS widget](images/rss-feed-horizontal-cards-preview.png)
`horizontal-cards-2`
![preview of horizontal-cards-2 style for RSS widget](images/rss-widget-horizontal-cards-2-preview.png)
##### `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.
@ -434,6 +455,7 @@ 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} | | video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
##### `channels` ##### `channels`
@ -448,6 +470,13 @@ 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`:
![](images/videos-widget-grid-cards-preview.png)
##### `video-url-template` ##### `video-url-template`
Used to replace the default link for videos. Useful when you're running your own YouTube front-end. Example: Used to replace the default link for videos. Useful when you're running your own YouTube front-end. Example:
@ -479,6 +508,8 @@ 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} | | 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` ##### `comments-url-template`
Used to replace the default link for post comments. Useful if you want to use an alternative front-end. Example: Used to replace the default link for post comments. Useful if you want to use an alternative front-end. Example:
@ -491,12 +522,20 @@ Placeholders:
`{POST-ID}` - the ID of the post `{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] > [!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. > 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:
@ -515,6 +554,10 @@ Example:
| collapse-after | integer | no | 5 | | collapse-after | integer | no | 5 |
| comments-url-template | string | no | https://www.reddit.com/{POST-PATH} | | comments-url-template | string | no | https://www.reddit.com/{POST-PATH} |
| request-url-template | string | no | | | 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.
@ -580,6 +623,22 @@ https://proxy/{REQUEST-URL}
https://your.proxy/?url={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**:
![](images/reddit-field-search.png)
##### `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/.
@ -593,7 +652,7 @@ Example:
> [!NOTE] > [!NOTE]
> >
> US cities which have common names can have their state specified as the second parameter like such: > US cities which have common names can have their state specified as the second parameter as such:
> >
> * Greenville, North Carolina, United States > * Greenville, North Carolina, United States
> * Greenville, South Carolina, United States > * Greenville, South Carolina, United States
@ -675,7 +734,11 @@ 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`
@ -694,7 +757,7 @@ 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`
@ -763,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:
![](images/repository-preview.png)
#### 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.
@ -812,10 +912,14 @@ 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 |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
@ -883,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:
@ -907,15 +1005,24 @@ Preview:
| ---- | ---- | -------- | | ---- | ---- | -------- |
| stocks | array | yes | | stocks | array | yes |
| sort-by | string | no | | 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`
@ -925,8 +1032,11 @@ 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.
##### `sort-by` `symbol-link`
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. 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

View File

@ -102,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) {
@ -205,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;
@ -314,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;
} }
@ -322,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; } }
@ -353,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;
@ -490,7 +556,7 @@ body {
animation-delay: 150ms; animation-delay: 150ms;
} }
.mobile-navigation { .mobile-navigation, .mobile-reachability-header {
display: none; display: none;
} }
@ -517,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;
} }
@ -553,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;
} }
@ -788,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;
@ -805,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);
@ -1013,6 +1121,8 @@ body {
--content-bounds-padding: 10px; --content-bounds-padding: 10px;
} }
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
.forum-post-list-item { .forum-post-list-item {
flex-flow: row-reverse; flex-flow: row-reverse;
} }
@ -1020,6 +1130,15 @@ body {
.hide-on-mobile { .hide-on-mobile {
display: none 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); }
@ -1041,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; }
@ -1069,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; }

View File

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

View File

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

View File

@ -1,23 +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 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>
</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 }}

View File

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

View File

@ -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 }}" {{ 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 }}
</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 }}

View File

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

View File

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

View File

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

View File

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

View File

@ -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">{{ .Currency }}{{ .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 }}

View File

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

View File

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

View File

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

View File

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

View File

@ -18,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 {
@ -83,8 +83,8 @@ func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (Forum
return posts, nil return posts, nil
} }
func FetchHackerNewsTopPosts(limit int, commentsUrlTemplate string) (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

View File

@ -85,12 +85,14 @@ var currencyToSymbol = map[string]string{
} }
type Stock struct { type Stock struct {
Name string Name string `yaml:"name"`
Symbol string Symbol string `yaml:"symbol"`
Currency string ChartLink string `yaml:"chart-link"`
Price float64 SymbolLink string `yaml:"symbol-link"`
PercentChange float64 Currency string `yaml:"-"`
SvgChartPoints string Price float64 `yaml:"-"`
PercentChange float64 `yaml:"-"`
SvgChartPoints string `yaml:"-"`
} }
type Stocks []Stock type Stocks []Stock

View File

@ -30,12 +30,29 @@ type subredditResponseJson struct {
} `json:"data"` } `json:"data"`
} }
func FetchSubredditPosts(subreddit string, commentsUrlTemplate string, requestUrlTemplate string) (ForumPosts, error) { func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string) (ForumPosts, error) {
subreddit = url.QueryEscape(subreddit) query := url.Values{}
requestUrl := fmt.Sprintf("https://www.reddit.com/r/%s/hot.json", subreddit) 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 != "" { if requestUrlTemplate != "" {
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl) 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 {
@ -93,7 +110,5 @@ func FetchSubredditPosts(subreddit string, commentsUrlTemplate string, requestUr
posts = append(posts, forumPost) posts = append(posts, forumPost)
} }
posts.CalculateEngagement()
return posts, nil return posts, nil
} }

View File

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

View File

@ -24,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 {
@ -86,10 +81,12 @@ func FetchStocksDataFromYahoo(stockRequests []StockRequest) (Stocks, error) {
} }
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,
Currency: currency, 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,

View File

@ -55,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() {

View File

@ -22,6 +22,7 @@ type Bookmarks struct {
HideArrow bool `yaml:"hide-arrow"` 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 {

View File

@ -13,6 +13,8 @@ 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"`
SortBy string `yaml:"sort-by"`
ExtraSortBy string `yaml:"extra-sort-by"`
CollapseAfter int `yaml:"collapse-after"` CollapseAfter int `yaml:"collapse-after"`
CommentsUrlTemplate string `yaml:"comments-url-template"` CommentsUrlTemplate string `yaml:"comments-url-template"`
ShowThumbnails bool `yaml:"-"` ShowThumbnails bool `yaml:"-"`
@ -29,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, widget.CommentsUrlTemplate) 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]

View File

@ -46,14 +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"`
SameTab bool `yaml:"same-tab"` SameTab bool `yaml:"same-tab"`
Status *feed.SiteStatus `yaml:"-"` Status *feed.SiteStatus `yaml:"-"`
StatusText string `yaml:"-"` StatusText string `yaml:"-"`
StatusStyle string `yaml:"-"` StatusStyle string `yaml:"-"`
} `yaml:"sites"` } `yaml:"sites"`
Style string `yaml:"style"`
} }
func (widget *Monitor) Initialize() error { func (widget *Monitor) Initialize() error {
@ -66,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("GET", 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)

View File

@ -17,6 +17,10 @@ type Reddit struct {
Subreddit string `yaml:"subreddit"` Subreddit string `yaml:"subreddit"`
Style string `yaml:"style"` Style string `yaml:"style"`
ShowThumbnails bool `yaml:"show-thumbnails"` ShowThumbnails bool `yaml:"show-thumbnails"`
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"` CommentsUrlTemplate string `yaml:"comments-url-template"`
Limit int `yaml:"limit"` Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"` CollapseAfter int `yaml:"collapse-after"`
@ -36,6 +40,14 @@ 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 widget.RequestUrlTemplate != "" {
if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") { if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") {
return errors.New("no `{REQUEST-URL}` placeholder specified") return errors.New("no `{REQUEST-URL}` placeholder specified")
@ -47,8 +59,32 @@ func (widget *Reddit) Initialize() error {
return nil return nil
} }
func isValidRedditSortType(sortBy string) bool {
return sortBy == "hot" ||
sortBy == "new" ||
sortBy == "top" ||
sortBy == "rising"
}
func isValidRedditTopPeriod(period string) bool {
return period == "hour" ||
period == "day" ||
period == "week" ||
period == "month" ||
period == "year" ||
period == "all"
}
func (widget *Reddit) Update(ctx context.Context) { func (widget *Reddit) Update(ctx context.Context) {
posts, err := feed.FetchSubredditPosts(widget.Subreddit, widget.CommentsUrlTemplate, widget.RequestUrlTemplate) // 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
@ -58,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
} }

View File

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

View File

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

View File

@ -9,11 +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"`
Sort string `yaml:"sort-by"` Sort string `yaml:"sort-by"`
Tickers []feed.StockRequest `yaml:"stocks"` Style string `yaml:"style"`
} }
func (widget *Stocks) Initialize() error { func (widget *Stocks) Initialize() error {
@ -23,7 +24,7 @@ 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

View File

@ -13,6 +13,7 @@ type Videos struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
Videos feed.Videos `yaml:"-"` Videos feed.Videos `yaml:"-"`
VideoUrlTemplate string `yaml:"video-url-template"` VideoUrlTemplate string `yaml:"video-url-template"`
Style string `yaml:"style"`
Channels []string `yaml:"channels"` Channels []string `yaml:"channels"`
Limit int `yaml:"limit"` Limit int `yaml:"limit"`
} }
@ -42,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)
} }

View File

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