소스 검색

Merge pull request #35 from glanceapp/v0.3.0

V0.3.0
Svilen Markov 1 년 전
부모
커밋
7b444b88e3

+ 135 - 11
docs/configuration.md

@@ -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 |
-| ---- | ---- | -------- |
-| 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`
 `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 |
-| ---- | ---- | -------- |
-| 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
 ### 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.
 
 

BIN
docs/images/reddit-widget-vertical-list-thumbnails.png


+ 48 - 3
internal/assets/static/main.css

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

+ 8 - 1
internal/assets/templates/bookmarks.html

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

+ 25 - 8
internal/assets/templates/forum-posts.html

@@ -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>
-        <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 }}
             {{ end }}
-        </ul>
+            <div class="grow">
+                <a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
+                <ul class="list-horizontal-text">
+                    <li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li>
+                    <li>{{ $post.Score | formatNumber }} points</li>
+                    <li>{{ $post.CommentCount | formatNumber }} comments</li>
+                    {{ if $post.HasTargetUrl }}
+                    <li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li>
+                    {{ end }}
+                </ul>
+            </div>
+        </div>
     </li>
     </li>
     {{ end }}
     {{ end }}
 </ul>
 </ul>

+ 1 - 1
internal/assets/templates/monitor.html

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

+ 1 - 1
internal/assets/templates/page.html

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

+ 1 - 1
internal/assets/templates/reddit-horizontal-cards.html

@@ -12,7 +12,7 @@
                 <img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
                 <img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
             </div>
             </div>
             {{ end }}
             {{ end }}
-            <div class="padding-widget flex flex-column flex-grow relative">
+            <div class="padding-widget flex flex-column grow relative">
                 {{ if ne "" .TargetUrl }}
                 {{ if ne "" .TargetUrl }}
                 <a class="color-highlight size-h5 text-truncate visited-indicator" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
                 <a class="color-highlight size-h5 text-truncate visited-indicator" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
                 {{ else }}
                 {{ else }}

+ 1 - 1
internal/assets/templates/rss-cards.html

@@ -14,7 +14,7 @@
                 <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
                 <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
             </svg>
             </svg>
             {{ end }}
             {{ end }}
-            <div class="margin-bottom-widget padding-inline-widget flex flex-column flex-grow">
+            <div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
                 <a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <ul class="list-horizontal-text flex-nowrap margin-top-7">
                 <ul class="list-horizontal-text flex-nowrap margin-top-7">
                     <li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
                     <li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>

+ 1 - 1
internal/assets/templates/stocks.html

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

+ 1 - 1
internal/assets/templates/videos.html

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

+ 1 - 1
internal/assets/templates/weather.html

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

+ 13 - 4
internal/feed/hacker-news.go

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

+ 55 - 3
internal/feed/openmeteo.go

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

+ 26 - 0
internal/feed/primitives.go

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

+ 19 - 3
internal/feed/reddit.go

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

+ 11 - 3
internal/feed/yahoo.go

@@ -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,
-			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(
 			PercentChange: percentChange(
 				response.Chart.Result[0].Meta.RegularMarketPrice,
 				response.Chart.Result[0].Meta.RegularMarketPrice,
 				previous,
 				previous,

+ 17 - 2
internal/feed/youtube.go

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

+ 22 - 2
internal/widget/bookmarks.go

@@ -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"`
-			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:"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

+ 7 - 5
internal/widget/hacker-news.go

@@ -10,10 +10,12 @@ import (
 )
 )
 
 
 type HackerNews struct {
 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 {
 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

+ 1 - 0
internal/widget/monitor.go

@@ -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:"-"`

+ 17 - 7
internal/widget/reddit.go

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

+ 5 - 1
internal/widget/stocks.go

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

+ 6 - 5
internal/widget/videos.go

@@ -10,10 +10,11 @@ import (
 )
 )
 
 
 type Videos struct {
 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 {
 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

+ 1 - 0
internal/widget/weather.go

@@ -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:"-"`