Merge pull request #35 from glanceapp/v0.3.0

V0.3.0
This commit is contained in:
Svilen Markov 2024-05-03 05:46:39 +01:00 committed by GitHub
commit 7b444b88e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 423 additions and 65 deletions

View File

@ -179,7 +179,7 @@ If you don't want to spend time configuring your own theme, there are [several a
### 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` |
@ -434,6 +434,7 @@ Preview:
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| channels | array | yes | | | channels | array | yes | |
| limit | integer | no | 25 | | limit | integer | no | 25 |
| 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:
@ -447,6 +448,17 @@ 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.
##### `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/).
@ -466,6 +478,18 @@ 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`
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
### Reddit ### Reddit
Display a list of posts from a specific subreddit. Display a list of posts from a specific subreddit.
@ -486,8 +510,11 @@ 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 | |
##### `subreddit` ##### `subreddit`
The subreddit for which to fetch the posts from. The subreddit for which to fetch the posts from.
@ -507,12 +534,52 @@ Used to change the appearance of the widget. Possible values are `vertical-list`
![](images/reddit-widget-vertical-cards-preview.png) ![](images/reddit-widget-vertical-cards-preview.png)
##### `show-thumbnails`
Shows or hides thumbnails next to the post. This only works if the `style` is `vertical-list`. Preview:
![](images/reddit-widget-vertical-list-thumbnails.png)
> [!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}
```
### 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/.
@ -524,6 +591,15 @@ Example:
location: London, United Kingdom location: London, United Kingdom
``` ```
> [!NOTE]
>
> US cities which have common names can have their state specified as the second parameter like such:
>
> * Greenville, North Carolina, United States
> * Greenville, South Carolina, United States
> * Greenville, Mississippi, United States
Preview: Preview:
![](images/weather-widget-preview.png) ![](images/weather-widget-preview.png)
@ -537,6 +613,7 @@ Each bar represents a 2 hour interval. The yellow background represents sunrise
| location | string | yes | | | location | string | yes | |
| units | string | no | metric | | 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.
@ -547,6 +624,19 @@ Whether to show the temperature in celsius or fahrenheit, possible values are `m
##### `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.
@ -591,11 +681,12 @@ You can hover over the "ERROR" text to view more information.
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`
@ -609,6 +700,10 @@ The URL which will be requested and its response will determine the status of th
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.
@ -725,14 +820,39 @@ An array of groups which can optionally have a title and a custom color.
| 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.
@ -786,6 +906,7 @@ Preview:
| Name | Type | Required | | Name | Type | Required |
| ---- | ---- | -------- | | ---- | ---- | -------- |
| stocks | array | yes | | stocks | array | yes |
| sort-by | string | no |
##### `stocks` ##### `stocks`
An array of stocks for which to display information about. An array of stocks for which to display information about.
@ -804,6 +925,9 @@ 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`
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.
### 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: 82 KiB

View File

@ -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;
@ -567,6 +568,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 +591,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;
@ -975,6 +1012,14 @@ body {
--widget-content-horizontal-padding: 10px; --widget-content-horizontal-padding: 10px;
--content-bounds-padding: 10px; --content-bounds-padding: 10px;
} }
.forum-post-list-item {
flex-flow: row-reverse;
}
.hide-on-mobile {
display: none
}
} }
.size-h1 { font-size: var(--font-size-h1); } .size-h1 { font-size: var(--font-size-h1); }
@ -1011,7 +1056,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; }

View File

@ -7,7 +7,14 @@
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }} {{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
<ul class="list list-gap-2"> <ul class="list list-gap-2">
{{ range .Links }} {{ range .Links }}
<li><a href="{{ .URL }}" class="bookmarks-link color-highlight size-h4" target="_blank" rel="noreferrer">{{ .Title }}</a></li> <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 }} {{ end }}
</ul> </ul>
</li> </li>

View File

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

View File

@ -8,7 +8,7 @@
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy"> <img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
{{ end }} {{ end }}
<div> <div>
<a class="size-h3 color-highlight" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a> <a class="size-h3 color-highlight" href="{{ .Url }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">
{{ if not .Status.Error }} {{ if not .Status.Error }}
<li>{{ .StatusText }}</li> <li>{{ .StatusText }}</li>

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@
<div class="stock-values shrink-0"> <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="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 class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
</div> </div>
</li> </li>
{{ end }} {{ end }}

View File

@ -8,7 +8,7 @@
{{ 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=""> <img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
<div class="margin-top-10 margin-bottom-widget flex flex-column flex-grow padding-inline-widget"> <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> <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"> <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-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>

View File

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

View File

@ -5,6 +5,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
) )
@ -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,7 +83,7 @@ func getHackerNewsPostsFromIds(postIds []int) (ForumPosts, error) {
return posts, nil return posts, nil
} }
func FetchHackerNewsTopPosts(limit int) (ForumPosts, error) { func FetchHackerNewsTopPosts(limit int, commentsUrlTemplate string) (ForumPosts, error) {
postIds, err := getHackerNewsTopPostIds() postIds, err := getHackerNewsTopPostIds()
if err != nil { if err != nil {
@ -85,5 +94,5 @@ func FetchHackerNewsTopPosts(limit int) (ForumPosts, error) {
postIds = postIds[:limit] postIds = postIds[:limit]
} }
return getHackerNewsPostsFromIds(postIds) return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
} }

View File

@ -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)
@ -94,7 +146,7 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
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) query.Add("temperature_unit", temperatureUnit)

View File

@ -59,9 +59,35 @@ 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
Symbol string Symbol string
Currency string
Price float64 Price float64
PercentChange float64 PercentChange float64
SvgChartPoints string SvgChartPoints string

View File

@ -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,12 @@ type subredditResponseJson struct {
} `json:"data"` } `json:"data"`
} }
func FetchSubredditPosts(subreddit string) (ForumPosts, error) { func FetchSubredditPosts(subreddit string, commentsUrlTemplate string, requestUrlTemplate string) (ForumPosts, error) {
requestUrl := fmt.Sprintf("https://www.reddit.com/r/%s/hot.json", url.QueryEscape(subreddit)) subreddit = url.QueryEscape(subreddit)
requestUrl := fmt.Sprintf("https://www.reddit.com/r/%s/hot.json", subreddit)
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 +63,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,

View File

@ -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"`
@ -78,10 +79,17 @@ 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, 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

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

View File

@ -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,33 @@ 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"`
} }
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

View File

@ -10,10 +10,12 @@ 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"` CollapseAfter int `yaml:"collapse-after"`
CommentsUrlTemplate string `yaml:"comments-url-template"`
ShowThumbnails bool `yaml:"-"`
} }
func (widget *HackerNews) Initialize() error { func (widget *HackerNews) Initialize() error {
@ -31,7 +33,7 @@ func (widget *HackerNews) Initialize() error {
} }
func (widget *HackerNews) Update(ctx context.Context) { func (widget *HackerNews) Update(ctx context.Context) {
posts, err := feed.FetchHackerNewsTopPosts(40) posts, err := feed.FetchHackerNewsTopPosts(40, widget.CommentsUrlTemplate)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return

View File

@ -49,6 +49,7 @@ type Monitor struct {
Title string `yaml:"title"` Title string `yaml:"title"`
Url string `yaml:"url"` Url string `yaml:"url"`
IconUrl string `yaml:"icon"` IconUrl string `yaml:"icon"`
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:"-"`

View File

@ -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,15 @@ 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"` 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 +36,19 @@ func (widget *Reddit) Initialize() error {
widget.CollapseAfter = 5 widget.CollapseAfter = 5
} }
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 (widget *Reddit) Update(ctx context.Context) { func (widget *Reddit) Update(ctx context.Context) {
posts, err := feed.FetchSubredditPosts(widget.Subreddit) posts, err := feed.FetchSubredditPosts(widget.Subreddit, widget.CommentsUrlTemplate, widget.RequestUrlTemplate)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return

View File

@ -12,6 +12,7 @@ import (
type Stocks struct { type Stocks struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
Stocks feed.Stocks `yaml:"-"` Stocks feed.Stocks `yaml:"-"`
Sort string `yaml:"sort-by"`
Tickers []feed.StockRequest `yaml:"stocks"` Tickers []feed.StockRequest `yaml:"stocks"`
} }
@ -28,7 +29,10 @@ func (widget *Stocks) Update(ctx context.Context) {
return return
} }
stocks.SortByAbsChange() if widget.Sort == "absolute-change" {
stocks.SortByAbsChange()
}
widget.Stocks = stocks widget.Stocks = stocks
} }

View File

@ -10,10 +10,11 @@ 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"` Channels []string `yaml:"channels"`
Limit int `yaml:"limit"`
} }
func (widget *Videos) Initialize() error { func (widget *Videos) Initialize() error {
@ -27,7 +28,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

View File

@ -12,6 +12,7 @@ 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"` Units string `yaml:"units"`
Place *feed.PlaceJson `yaml:"-"` Place *feed.PlaceJson `yaml:"-"`