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
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| light | bool | no | false |
| light | boolean | no | false |
| background-color | HSL | no | 240 8 9 |
| primary-color | HSL | no | 43 50 70 |
| positive-color | HSL | no | same as `primary-color` |
@ -434,6 +434,7 @@ Preview:
| ---- | ---- | -------- | ------- |
| channels | array | yes | |
| limit | integer | no | 25 |
| video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
##### `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:
@ -447,6 +448,17 @@ Then scroll down and click on "Share channel", then "Copy channel ID":
##### `limit`
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
Display a list of posts from [Hacker News](https://news.ycombinator.com/).
@ -466,6 +478,18 @@ Preview:
| ---- | ---- | -------- | ------- |
| limit | integer | no | 15 |
| 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
Display a list of posts from a specific subreddit.
@ -486,8 +510,11 @@ Example:
| ---- | ---- | -------- | ------- |
| subreddit | string | yes | |
| style | string | no | vertical-list |
| show-thumbnails | boolean | no | false |
| limit | integer | no | 15 |
| collapse-after | integer | no | 5 |
| comments-url-template | string | no | https://www.reddit.com/{POST-PATH} |
| request-url-template | string | no | |
##### `subreddit`
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)
##### `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`
The maximum number of posts to show.
##### `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.
##### `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
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
```
> [!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:
![](images/weather-widget-preview.png)
@ -537,6 +613,7 @@ Each bar represents a 2 hour interval. The yellow background represents sunrise
| location | string | yes | |
| units | string | no | metric |
| hide-location | boolean | no | false |
| show-area-name | boolean | no | false |
##### `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.
@ -547,6 +624,19 @@ Whether to show the temperature in celsius or fahrenheit, possible values are `m
##### `hide-location`
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
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:
| Name | Type | Required |
| ---- | ---- | -------- |
| title | string | yes |
| url | string | yes |
| icon | string | no |
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| title | string | yes | |
| url | string | yes | |
| icon | string | no | |
| same-tab | boolean | no | false |
`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).
`same-tab`
Whether to open the link in the same or a new tab.
### Releases
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 |
| ---- | ---- | -------- | ------- |
| title | string | no | |
| color | HSL | no | the primary theme color |
| color | HSL | no | the primary color of the theme |
| links | array | yes | |
###### Properties for each link
| Name | Type | Required |
| ---- | ---- | -------- |
| title | string | yes |
| url | string | yes |
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| title | 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
Display a calendar.
@ -786,6 +906,7 @@ Preview:
| Name | Type | Required |
| ---- | ---- | -------- |
| stocks | array | yes |
| sort-by | string | no |
##### `stocks`
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.
##### `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
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-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm))));
--color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
--color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
@ -80,7 +81,7 @@
.visited-indicator:not(.text-truncate)::after,
.visited-indicator.text-truncate::before,
.bookmarks-link::after {
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
content: '↗';
margin-left: 0.5em;
display: inline-block;
@ -567,6 +568,21 @@ body {
-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-color: var(--color-primary);
}
@ -575,10 +591,31 @@ body {
color: var(--bookmarks-group-color);
}
.bookmarks-link::after {
.bookmarks-group .bookmarks-link::after {
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 {
width: calc(100% / 7);
text-align: center;
@ -975,6 +1012,14 @@ body {
--widget-content-horizontal-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); }
@ -1011,7 +1056,7 @@ body {
.justify-center { justify-content: center; }
.justify-end { justify-content: end; }
.uppercase { text-transform: uppercase; }
.flex-grow { flex-grow: 1; }
.grow { flex-grow: 1; }
.flex-column { flex-direction: column; }
.items-center { align-items: center; }
.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 }}
<ul class="list list-gap-2">
{{ range .Links }}
<li><a href="{{ .URL }}" class="bookmarks-link color-highlight size-h4" target="_blank" rel="noreferrer">{{ .Title }}</a></li>
<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>

View File

@ -4,15 +4,32 @@
<ul class="list list-gap-14 list-collapsible">
{{ range $i, $post := .Posts }}
<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>
<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>
<div class="forum-post-list-item thumbnail-container">
{{ if $.ShowThumbnails }}
{{ if ne $post.ThumbnailUrl "" }}
<img class="forum-post-list-thumbnail thumbnail" src="{{ $post.ThumbnailUrl }}" alt="" loading="lazy">
{{ else 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)">
<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 }}
</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>
{{ end }}
</ul>

View File

@ -8,7 +8,7 @@
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
{{ end }}
<div>
<a class="size-h3 color-highlight" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<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>

View File

@ -29,7 +29,7 @@
<div class="header flex padding-inline-widget widget-content-frame">
<!-- TODO: Replace G with actual logo, first need an actual logo -->
<div class="logo">G</div>
<div class="nav flex flex-grow">
<div class="nav flex grow">
{{ template "navigation-links" . }}
</div>
</div>

View File

@ -12,7 +12,7 @@
<img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
</div>
{{ end }}
<div class="padding-widget flex flex-column flex-grow relative">
<div class="padding-widget flex flex-column grow relative">
{{ if ne "" .TargetUrl }}
<a class="color-highlight size-h5 text-truncate visited-indicator" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
{{ 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" />
</svg>
{{ 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>
<ul class="list-horizontal-text flex-nowrap margin-top-7">
<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="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>
</li>
{{ end }}

View File

@ -8,7 +8,7 @@
{{ range .Videos }}
<div class="card widget-content-frame thumbnail-container">
<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>
<ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>

View File

@ -23,7 +23,7 @@
{{ if not .HideLocation }}
<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
<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>
{{ end }}
{{ end }}

View File

@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)
@ -28,7 +29,7 @@ func getHackerNewsTopPostIds() ([]int, error) {
return response, nil
}
func getHackerNewsPostsFromIds(postIds []int) (ForumPosts, error) {
func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (ForumPosts, error) {
requests := make([]*http.Request, len(postIds))
for i, id := range postIds {
@ -52,9 +53,17 @@ func getHackerNewsPostsFromIds(postIds []int) (ForumPosts, error) {
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{
Title: results[i].Title,
DiscussionUrl: "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id),
DiscussionUrl: commentsUrl,
TargetUrl: results[i].TargetUrl,
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
CommentCount: results[i].CommentCount,
@ -74,7 +83,7 @@ func getHackerNewsPostsFromIds(postIds []int) (ForumPosts, error) {
return posts, nil
}
func FetchHackerNewsTopPosts(limit int) (ForumPosts, error) {
func FetchHackerNewsTopPosts(limit int, commentsUrlTemplate string) (ForumPosts, error) {
postIds, err := getHackerNewsTopPostIds()
if err != nil {
@ -85,5 +94,5 @@ func FetchHackerNewsTopPosts(limit int) (ForumPosts, error) {
postIds = postIds[:limit]
}
return getHackerNewsPostsFromIds(postIds)
return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
}

View File

@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"slices"
"strings"
"time"
_ "time/tzdata"
@ -17,6 +18,7 @@ type PlacesResponseJson struct {
type PlaceJson struct {
Name string
Area string `json:"admin1"`
Latitude float64
Longitude float64
Timezone string
@ -48,8 +50,41 @@ type weatherColumn struct {
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) {
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)
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)
}
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)
@ -94,7 +146,7 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
query.Add("timeformat", "unixtime")
query.Add("timezone", place.Timezone)
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("daily", "sunrise,sunset")
query.Add("temperature_unit", temperatureUnit)

View File

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

View File

@ -5,6 +5,7 @@ import (
"html"
"net/http"
"net/url"
"strings"
"time"
)
@ -12,6 +13,7 @@ type subredditResponseJson struct {
Data struct {
Children []struct {
Data struct {
Id string `json:"id"`
Title string `json:"title"`
Upvotes int `json:"ups"`
Url string `json:"url"`
@ -28,8 +30,12 @@ type subredditResponseJson struct {
} `json:"data"`
}
func FetchSubredditPosts(subreddit string) (ForumPosts, error) {
requestUrl := fmt.Sprintf("https://www.reddit.com/r/%s/hot.json", url.QueryEscape(subreddit))
func FetchSubredditPosts(subreddit string, commentsUrlTemplate string, requestUrlTemplate string) (ForumPosts, error) {
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)
if err != nil {
@ -57,9 +63,19 @@ func FetchSubredditPosts(subreddit string) (ForumPosts, error) {
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{
Title: html.UnescapeString(post.Title),
DiscussionUrl: "https://www.reddit.com" + post.Permalink,
DiscussionUrl: commentsUrl,
TargetUrlDomain: post.Domain,
CommentCount: post.CommentsCount,
Score: post.Upvotes,

View File

@ -10,6 +10,7 @@ type stockResponseJson struct {
Chart struct {
Result []struct {
Meta struct {
Currency string `json:"currency"`
Symbol string `json:"symbol"`
RegularMarketPrice float64 `json:"regularMarketPrice"`
ChartPreviousClose float64 `json:"chartPreviousClose"`
@ -78,10 +79,17 @@ func FetchStocksDataFromYahoo(stockRequests []StockRequest) (Stocks, error) {
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{
Name: stockRequests[i].Name,
Symbol: response.Chart.Result[0].Meta.Symbol,
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Name: stockRequests[i].Name,
Symbol: response.Chart.Result[0].Meta.Symbol,
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Currency: currency,
PercentChange: percentChange(
response.Chart.Result[0].Meta.RegularMarketPrice,
previous,

View File

@ -4,6 +4,7 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
@ -38,7 +39,7 @@ func parseYoutubeFeedTime(t string) time.Time {
return parsedTime
}
func FetchYoutubeChannelUploads(channelIds []string) (Videos, error) {
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (Videos, error) {
requests := make([]*http.Request, 0, len(channelIds))
for i := range channelIds {
@ -75,10 +76,24 @@ func FetchYoutubeChannelUploads(channelIds []string) (Videos, error) {
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{
ThumbnailUrl: video.Group.Thumbnail.Url,
Title: video.Title,
Url: video.Link.Href,
Url: videoUrl,
Author: response.Channel,
AuthorUrl: response.ChannelLink.Href + "/videos",
TimePosted: parseYoutubeFeedTime(video.Published),

View File

@ -2,6 +2,7 @@ package widget
import (
"html/template"
"strings"
"github.com/glanceapp/glance/internal/assets"
)
@ -13,14 +14,33 @@ type Bookmarks struct {
Title string `yaml:"title"`
Color *HSLColorField `yaml:"color"`
Links []struct {
Title string `yaml:"title"`
URL string `yaml:"url"`
Title string `yaml:"title"`
URL string `yaml:"url"`
Icon string `yaml:"icon"`
IsSimpleIcon bool `yaml:"-"`
SameTab bool `yaml:"same-tab"`
HideArrow bool `yaml:"hide-arrow"`
} `yaml:"links"`
} `yaml:"groups"`
}
func (widget *Bookmarks) Initialize() error {
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)
return nil

View File

@ -10,10 +10,12 @@ import (
)
type HackerNews struct {
widgetBase `yaml:",inline"`
Posts feed.ForumPosts `yaml:"-"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
widgetBase `yaml:",inline"`
Posts feed.ForumPosts `yaml:"-"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
CommentsUrlTemplate string `yaml:"comments-url-template"`
ShowThumbnails bool `yaml:"-"`
}
func (widget *HackerNews) Initialize() error {
@ -31,7 +33,7 @@ func (widget *HackerNews) Initialize() error {
}
func (widget *HackerNews) Update(ctx context.Context) {
posts, err := feed.FetchHackerNewsTopPosts(40)
posts, err := feed.FetchHackerNewsTopPosts(40, widget.CommentsUrlTemplate)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return

View File

@ -49,6 +49,7 @@ type Monitor struct {
Title string `yaml:"title"`
Url string `yaml:"url"`
IconUrl string `yaml:"icon"`
SameTab bool `yaml:"same-tab"`
Status *feed.SiteStatus `yaml:"-"`
StatusText string `yaml:"-"`
StatusStyle string `yaml:"-"`

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"html/template"
"strings"
"time"
"github.com/glanceapp/glance/internal/assets"
@ -11,12 +12,15 @@ import (
)
type Reddit struct {
widgetBase `yaml:",inline"`
Posts feed.ForumPosts `yaml:"-"`
Subreddit string `yaml:"subreddit"`
Style string `yaml:"style"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
widgetBase `yaml:",inline"`
Posts feed.ForumPosts `yaml:"-"`
Subreddit string `yaml:"subreddit"`
Style string `yaml:"style"`
ShowThumbnails bool `yaml:"show-thumbnails"`
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 {
@ -32,13 +36,19 @@ func (widget *Reddit) Initialize() error {
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)
return nil
}
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) {
return

View File

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

View File

@ -10,10 +10,11 @@ import (
)
type Videos struct {
widgetBase `yaml:",inline"`
Videos feed.Videos `yaml:"-"`
Channels []string `yaml:"channels"`
Limit int `yaml:"limit"`
widgetBase `yaml:",inline"`
Videos feed.Videos `yaml:"-"`
VideoUrlTemplate string `yaml:"video-url-template"`
Channels []string `yaml:"channels"`
Limit int `yaml:"limit"`
}
func (widget *Videos) Initialize() error {
@ -27,7 +28,7 @@ func (widget *Videos) Initialize() error {
}
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) {
return

View File

@ -12,6 +12,7 @@ import (
type Weather struct {
widgetBase `yaml:",inline"`
Location string `yaml:"location"`
ShowAreaName bool `yaml:"show-area-name"`
HideLocation bool `yaml:"hide-location"`
Units string `yaml:"units"`
Place *feed.PlaceJson `yaml:"-"`