浏览代码

Merge remote-tracking branch 'origin' into change-detection

Shashank S 1 年之前
父节点
当前提交
6cad5a8efb
共有 40 个文件被更改,包括 882 次插入170 次删除
  1. 2 1
      Dockerfile
  2. 7 0
      Dockerfile.single-platform
  3. 29 6
      README.md
  4. 130 20
      docs/configuration.md
  5. 二进制
      docs/images/mobile-header-preview.png
  6. 二进制
      docs/images/reddit-field-search.png
  7. 二进制
      docs/images/repository-preview.png
  8. 二进制
      docs/images/rss-widget-horizontal-cards-2-preview.png
  9. 二进制
      docs/images/videos-widget-grid-cards-preview.png
  10. 143 17
      internal/assets/static/main.css
  11. 28 0
      internal/assets/static/main.js
  12. 6 3
      internal/assets/templates.go
  13. 27 13
      internal/assets/templates/bookmarks.html
  14. 4 0
      internal/assets/templates/content.html
  15. 43 29
      internal/assets/templates/monitor.html
  16. 1 1
      internal/assets/templates/releases.html
  17. 44 0
      internal/assets/templates/repository.html
  18. 28 0
      internal/assets/templates/rss-horizontal-cards-2.html
  19. 1 1
      internal/assets/templates/rss-horizontal-cards.html
  20. 29 13
      internal/assets/templates/stocks.html
  21. 12 0
      internal/assets/templates/video-card-contents.html
  22. 13 0
      internal/assets/templates/videos-grid.html
  23. 2 11
      internal/assets/templates/videos.html
  24. 131 0
      internal/feed/github.go
  25. 4 4
      internal/feed/hacker-news.go
  26. 8 6
      internal/feed/primitives.go
  27. 20 5
      internal/feed/reddit.go
  28. 1 1
      internal/feed/utils.go
  29. 7 10
      internal/feed/yahoo.go
  30. 5 4
      internal/glance/glance.go
  31. 1 0
      internal/widget/bookmarks.go
  32. 11 3
      internal/widget/hacker-news.go
  33. 9 8
      internal/widget/monitor.go
  34. 42 2
      internal/widget/reddit.go
  35. 52 0
      internal/widget/repository-overview.go
  36. 21 7
      internal/widget/rss.go
  37. 5 4
      internal/widget/stocks.go
  38. 5 0
      internal/widget/videos.go
  39. 2 0
      internal/widget/widget.go
  40. 9 1
      scripts/build-and-ship/main.go

+ 2 - 1
Dockerfile

@@ -2,9 +2,10 @@ FROM alpine:3.19
 
 ARG TARGETOS
 ARG TARGETARCH
+ARG TARGETVARIANT
 
 WORKDIR /app
-COPY build/glance-$TARGETOS-$TARGETARCH /app/glance
+COPY build/glance-$TARGETOS-$TARGETARCH${TARGETVARIANT} /app/glance
 
 EXPOSE 8080/tcp
 ENTRYPOINT ["/app/glance"]

+ 7 - 0
Dockerfile.single-platform

@@ -0,0 +1,7 @@
+FROM alpine:3.19
+
+WORKDIR /app
+COPY build/glance /app/glance
+
+EXPOSE 8080/tcp
+ENTRYPOINT ["/app/glance"]

+ 29 - 6
README.md

@@ -16,6 +16,7 @@
 * iframe
 * Twitch channels & top games
 * GitHub releases
+* Repository overview
 * Site monitor
 
 #### Themeable
@@ -41,16 +42,16 @@ Checkout the [configuration docs](docs/configuration.md) to learn more. A [preco
 #### Manual
 Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
 
-```
+```bash
 /opt/glance/glance --config /etc/glance.yml
 ```
 
 #### Docker
 > [!IMPORTANT]
 >
-> Make sure you have a valid `glance.yml` file before running the container.
+> Make sure you have a valid `glance.yml` file in the same directory before running the container.
 
-```console
+```bash
 docker run -d -p 8080:8080 \
   -v ./glance.yml:/app/glance.yml \
   -v /etc/timezone:/etc/timezone:ro \
@@ -79,12 +80,34 @@ Requirements: [Go](https://go.dev/dl/) >= v1.22
 
 To build:
 
-```
-go build .
+```bash
+go build -o build/glance .
 ```
 
 To run:
 
-```
+```bash
 go run .
 ```
+
+### Building Docker image
+
+Build Glance with CGO disabled:
+
+```bash
+CGO_ENABLED=0 go build -o build/glance .
+```
+
+Build the image:
+
+**Make sure to replace "owner" with your name or organization.**
+
+```bash
+docker build -t owner/glance:latest -f Dockerfile.single-platform .
+```
+
+Push the image to your registry:
+
+```bash
+docker push owner/glance:latest
+```

+ 130 - 20
docs/configuration.md

@@ -14,6 +14,7 @@
   - [Weather](#weather)
   - [Monitor](#monitor)
   - [Releases](#releases)
+  - [Repository](#repository)
   - [Bookmarks](#bookmarks)
   - [Calendar](#calendar)
   - [Stocks](#stocks)
@@ -155,7 +156,7 @@ Say you have a directory `glance-assets` with a file `gitea-icon.png` in it and
 assets-path: /home/user/glance-assets
 ```
 
-To be able to point to an asset from your assets path, use the the `/assets/` path like such:
+To be able to point to an asset from your assets path, use the `/assets/` path like such:
 
 ```yaml
 icon: /assets/gitea-icon.png
@@ -250,11 +251,12 @@ pages:
 ```
 
 ### Properties
-| Name | Type | Required |
-| ---- | ---- | -------- |
-| title | string | yes |
-| slug | string | no |
-| columns | array | yes |
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| title | string | yes | |
+| slug | string | no | |
+| show-mobile-header | boolean | no | false |
+| columns | array | yes | |
 
 #### `title`
 The name of the page which gets shown in the navigation bar.
@@ -262,6 +264,13 @@ The name of the page which gets shown in the navigation bar.
 #### `slug`
 The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title.
 
+#### `show-mobile-header`
+Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices.
+
+Preview:
+
+![](images/mobile-header-preview.png)
+
 ### Columns
 Columns are defined for each page using a `columns` property. There are two types of columns - `full` and `small`, which refers to their width. A small column takes up a fixed amount of width (300px) and a full column takes up the all of the remaining width. You can have up to 3 columns per page and you must have either 1 or 2 full columns. Example:
 
@@ -384,6 +393,8 @@ Example:
 | ---- | ---- | -------- | ------- |
 | style | string | no | vertical-list |
 | feeds | array | yes |
+| thumbnail-height | float | no | 10 |
+| card-height | float | no | 27 |
 | limit | integer | no | 25 |
 | collapse-after | integer | no | 5 |
 
@@ -398,6 +409,16 @@ Used to change the appearance of the widget. Possible values are `vertical-list`
 
 ![preview of horizontal-cards style for RSS widget](images/rss-feed-horizontal-cards-preview.png)
 
+`horizontal-cards-2`
+
+![preview of horizontal-cards-2 style for RSS widget](images/rss-widget-horizontal-cards-2-preview.png)
+
+##### `thumbnail-height`
+Used to modify the height of the thumbnails. Works only when the style is set to `horizontal-cards`. The default value is `10` and the units are `rem`, if you want to for example double the height of the thumbnails you can set it to `20`.
+
+##### `card-height`
+Used to modify the height of cards when using the `horizontal-cards-2` style. The default value is `27` and the units are `rem`.
+
 ##### `feeds`
 An array of RSS/atom feeds. The title can optionally be changed.
 
@@ -434,6 +455,7 @@ Preview:
 | ---- | ---- | -------- | ------- |
 | channels | array | yes | |
 | limit | integer | no | 25 |
+| style | string | no | horizontal-cards |
 | video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
 
 ##### `channels`
@@ -448,6 +470,13 @@ Then scroll down and click on "Share channel", then "Copy channel ID":
 ##### `limit`
 The maximum number of videos to show.
 
+##### `style`
+Used to change the appearance of the widget. Possible values are `horizontal-cards` and `grid-cards`.
+
+Preview of `grid-cards`:
+
+![](images/videos-widget-grid-cards-preview.png)
+
 ##### `video-url-template`
 Used to replace the default link for videos. Useful when you're running your own YouTube front-end. Example:
 
@@ -479,6 +508,8 @@ Preview:
 | limit | integer | no | 15 |
 | collapse-after | integer | no | 5 |
 | comments-url-template | string | no | https://news.ycombinator.com/item?id={POST-ID} |
+| sort-by | string | no | top |
+| extra-sort-by | string | no | |
 
 ##### `comments-url-template`
 Used to replace the default link for post comments. Useful if you want to use an alternative front-end. Example:
@@ -491,12 +522,20 @@ Placeholders:
 
 `{POST-ID}` - the ID of the post
 
+##### `sort-by`
+Used to specify the order in which the posts should get returned. Possible values are `top`, `new`, and `best`.
+
+##### `extra-sort-by`
+Can be used to specify an additional sort which will be applied on top of the already sorted posts. By default does not apply any extra sorting and the only available option is `engagement`.
+
+The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
+
 ### Reddit
 Display a list of posts from a specific subreddit.
 
 > [!WARNING]
 >
-> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403 response. As a workaround you can route the traffic from Glance through a VPN.
+> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403 response. As a workaround you can route the traffic from Glance through a VPN or your own HTTP proxy using the `request-url-template` property.
 
 Example:
 
@@ -515,6 +554,10 @@ Example:
 | collapse-after | integer | no | 5 |
 | comments-url-template | string | no | https://www.reddit.com/{POST-PATH} |
 | request-url-template | string | no |  |
+| sort-by | string | no | hot |
+| top-period | string | no | day |
+| search | string | no | |
+| extra-sort-by | string | no | |
 
 ##### `subreddit`
 The subreddit for which to fetch the posts from.
@@ -580,6 +623,22 @@ https://proxy/{REQUEST-URL}
 https://your.proxy/?url={REQUEST-URL}
 ```
 
+##### `sort-by`
+Can be used to specify the order in which the posts should get returned. Possible values are `hot`, `new`, `top` and `rising`.
+
+##### `top-perid`
+Available only when `sort-by` is set to `top`. Possible values are `hour`, `day`, `week`, `month`, `year` and `all`.
+
+##### `search`
+Keywords to search for. Searching within specific fields is also possible, **though keep in mind that Reddit may remove the ability to use any of these at any time**:
+
+![](images/reddit-field-search.png)
+
+##### `extra-sort-by`
+Can be used to specify an additional sort which will be applied on top of the already sorted posts. By default does not apply any extra sorting and the only available option is `engagement`.
+
+The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
+
 ### Weather
 Display weather information for a specific location. The data is provided by https://open-meteo.com/.
 
@@ -593,7 +652,7 @@ Example:
 
 > [!NOTE]
 >
-> US cities which have common names can have their state specified as the second parameter like such:
+> US cities which have common names can have their state specified as the second parameter as such:
 >
 > * Greenville, North Carolina, United States
 > * Greenville, South Carolina, United States
@@ -675,7 +734,11 @@ You can hover over the "ERROR" text to view more information.
 
 | Name | Type | Required |
 | ---- | ---- | -------- |
-| sites | array | yes |  |
+| sites | array | yes |
+| style | string | no |
+
+##### `style`
+To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
 
 ##### `sites`
 
@@ -694,7 +757,7 @@ The title used to indicate the site.
 
 `url`
 
-The URL which will be requested and its response will determine the status of the site.
+The URL which will be requested and its response will determine the status of the site. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`.
 
 `icon`
 
@@ -763,6 +826,43 @@ The maximum number of releases to show.
 #### `collapse-after`
 How many releases are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
 
+### Repository
+Display general information about a repository as well as a list of the latest open pull requests and issues.
+
+Example:
+
+```yaml
+- type: repository
+  repository: glanceapp/glance
+  pull-requests-limit: 5
+  issues-limit: 3
+```
+
+Preview:
+
+![](images/repository-preview.png)
+
+#### Properties
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| repository | string | yes |  |
+| token | string | no | |
+| pull-requests-limit | integer | no | 3 |
+| issues-limit | integer | no | 3 |
+
+##### `repository`
+The owner and repository name that will have their information displayed.
+
+##### `token`
+Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if your cache time is low or you have many instances of this widget. To circumvent this you can [create a read only token from your Github account](https://github.com/settings/personal-access-tokens/new) and provide it here.
+
+##### `pull-requests-limit`
+The maximum number of latest open pull requests to show. Set to `-1` to not show any.
+
+##### `issues-limit`
+The maximum number of latest open issues to show. Set to `-1` to not show any.
+
 ### Bookmarks
 Display a list of links which can be grouped.
 
@@ -812,10 +912,14 @@ Preview:
 | Name | Type | Required |
 | ---- | ---- | -------- |
 | groups | array | yes |
+| style | string | no |
 
 ##### `groups`
 An array of groups which can optionally have a title and a custom color.
 
+##### `style`
+To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
+
 ###### Properties for each group
 | Name | Type | Required | Default |
 | ---- | ---- | -------- | ------- |
@@ -883,18 +987,12 @@ Example:
       name: S&P 500
     - symbol: BTC-USD
       name: Bitcoin
+      chart-link: https://www.tradingview.com/chart/?symbol=INDEX:BTCUSD
     - symbol: NVDA
       name: NVIDIA
     - symbol: AAPL
+      symbol-link: https://www.google.com/search?tbm=nws&q=apple
       name: Apple
-    - symbol: MSFT
-      name: Microsoft
-    - symbol: GOOGL
-      name: Google
-    - symbol: AMD
-      name: AMD
-    - symbol: RDDT
-      name: Reddit
 ```
 
 Preview:
@@ -907,15 +1005,24 @@ Preview:
 | ---- | ---- | -------- |
 | stocks | array | yes |
 | sort-by | string | no |
+| style | string | no |
 
 ##### `stocks`
 An array of stocks for which to display information about.
 
+##### `sort-by`
+By default the stocks are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change.
+
+##### `style`
+To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
+
 ###### Properties for each stock
 | Name | Type | Required |
 | ---- | ---- | -------- |
 | symbol | string | yes |
 | name | string | no |
+| symbol-link | string | no |
+| chart-link | string | no |
 
 `symbol`
 
@@ -925,8 +1032,11 @@ 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.
+`symbol-link`
+The link to go to when clicking on the symbol.
+
+`chart-link`
+The link to go to when clicking on the chart.
 
 ### Twitch Channels
 Display a list of channels from Twitch.

二进制
docs/images/mobile-header-preview.png


二进制
docs/images/reddit-field-search.png


二进制
docs/images/repository-preview.png


二进制
docs/images/rss-widget-horizontal-cards-2-preview.png


二进制
docs/images/videos-widget-grid-cards-preview.png


+ 143 - 17
internal/assets/static/main.css

@@ -102,12 +102,13 @@
 .list        { --list-half-gap: 0rem; }
 .list-gap-2  { --list-half-gap: 0.1rem; }
 .list-gap-4  { --list-half-gap: 0.2rem; }
+.list-gap-10 { --list-half-gap: 0.5rem; }
 .list-gap-14 { --list-half-gap: 0.7rem; }
 .list-gap-20 { --list-half-gap: 1rem; }
 .list-gap-24 { --list-half-gap: 1.2rem; }
 
 .list > *:not(:first-child) {
-    margin-top: calc(var(--list-half-gap) * 2 + 1px);
+    margin-top: calc(var(--list-half-gap) * 2);
 }
 
 .list-with-separator > *:not(:first-child) {
@@ -205,11 +206,29 @@
     margin: 0;
 }
 
+hr {
+    border: 0;
+    height: 1px;
+    background-color: var(--color-separator);
+}
+
 img, svg {
     display: block;
     max-width: 100%;
 }
 
+img[loading=lazy].loaded:not(.finished-transition) {
+    transition: opacity .4s;
+}
+
+img[loading=lazy].cached:not(.finished-transition) {
+    transition: none;
+}
+
+img[loading=lazy]:not(.loaded, .cached) {
+    opacity: 0;
+}
+
 html {
     scrollbar-color: var(--color-text-subdue) transparent;
     scroll-behavior: smooth;
@@ -314,6 +333,44 @@ body {
     padding: 0 var(--content-bounds-padding);
 }
 
+.dynamic-columns {
+    gap: calc(var(--widget-content-vertical-padding) / 2);
+    display: grid;
+    grid-template-columns: repeat(var(--columns-per-row), 1fr);
+    margin: calc(0px - var(--widget-content-vertical-padding) / 2) calc(0px - var(--widget-content-horizontal-padding) / 2);
+}
+
+.dynamic-columns > * {
+    padding: calc(var(--widget-content-vertical-padding) / 2) calc(var(--widget-content-horizontal-padding) / 1.5);
+    background-color: var(--color-background);
+    border-radius: var(--border-radius);
+}
+
+.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
+.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
+.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
+.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
+.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
+
+@container widget (max-width: 1500px) {
+    .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
+    .dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
+    .dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
+    .dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
+}
+@container widget (max-width: 1250px) {
+    .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
+    .dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
+    .dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
+}
+@container widget (max-width: 850px) {
+    .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
+    .dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
+}
+@container widget (max-width: 550px) {
+    .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
+}
+
 .cards-vertical {
     flex-direction: column;
 }
@@ -322,30 +379,44 @@ body {
     --cards-per-row: 6.5;
 }
 
-.cards-grid {
-    --cards-per-row: 6;
-}
-
-.cards-horizontal, .cards-vertical, .cards-grid {
+.cards-horizontal, .cards-vertical {
     --cards-gap: calc(var(--widget-content-vertical-padding) * 0.7);
     display: flex;
     gap: var(--cards-gap);
 }
 
+.card {
+    display: flex;
+    flex-direction: column;
+}
+
+.cards-horizontal .card {
+    flex-shrink: 0;
+    width: calc(100% / var(--cards-per-row) - var(--cards-gap) * (var(--cards-per-row) - 1) / var(--cards-per-row));
+}
+
+.cards-grid .card {
+    min-width: 0;
+}
+
 .cards-horizontal {
     overflow-x: auto;
     padding-bottom: 1rem;
 }
 
 .cards-grid {
-    flex-wrap: wrap;
+    --cards-per-row: 6;
+    display: grid;
+    grid-template-columns: repeat(var(--cards-per-row), 1fr);
+    gap: calc(var(--widget-content-vertical-padding) * 0.7);
 }
 
 @container widget (max-width: 1300px) { .cards-horizontal { --cards-per-row: 5.5; } }
 @container widget (max-width: 1100px) { .cards-horizontal { --cards-per-row: 4.5; } }
 @container widget (max-width: 850px) { .cards-horizontal { --cards-per-row: 3.5; } }
 @container widget (max-width: 750px) { .cards-horizontal { --cards-per-row: 3.5; } }
-@container widget (max-width: 650px) { .cards-horizontal { --cards-per-row: 2.2; } }
+@container widget (max-width: 650px) { .cards-horizontal { --cards-per-row: 2.5; } }
+@container widget (max-width: 450px) { .cards-horizontal { --cards-per-row: 2.3; } }
 
 @container widget (max-width: 1300px) { .cards-grid { --cards-per-row: 5; } }
 @container widget (max-width: 1100px) { .cards-grid { --cards-per-row: 4; } }
@@ -353,12 +424,7 @@ body {
 @container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
 @container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
 
-.card {
-    flex-shrink: 0;
-    width: calc(100% / var(--cards-per-row) - var(--cards-gap) * (var(--cards-per-row) - 1) / var(--cards-per-row));
-    display: flex;
-    flex-direction: column;
-}
+
 
 .widget-error-header {
     display: flex;
@@ -490,7 +556,7 @@ body {
     animation-delay: 150ms;
 }
 
-.mobile-navigation {
+.mobile-navigation, .mobile-reachability-header {
     display: none;
 }
 
@@ -517,6 +583,10 @@ body {
     width: 6.5rem;
 }
 
+.stock-chart svg {
+    width: 100%;
+}
+
 .stock-values {
     min-width: 8rem;
 }
@@ -553,7 +623,7 @@ body {
 
 .video-thumbnail {
     width: 100%;
-    aspect-ratio: 16 / 9;
+    aspect-ratio: 16 / 8.9;
     object-fit: cover;
     border-radius: var(--border-radius) var(--border-radius) 0 0;
 }
@@ -788,6 +858,7 @@ body {
 }
 
 .monitor-site-status-icon {
+    flex-shrink: 0;
     margin-left: auto;
     width: 2rem;
     height: 2rem;
@@ -805,11 +876,48 @@ body {
 }
 
 .rss-card-image {
-    height: 10rem;
+    height: var(--rss-thumbnail-height, 10rem);
     object-fit: cover;
     border-radius: var(--border-radius) var(--border-radius) 0 0;
 }
 
+.rss-card-2 {
+    position: relative;
+    height: var(--rss-card-height, 27rem);
+    overflow: hidden;
+}
+
+.rss-card-2::before {
+    content: '';
+    position: absolute;
+    inset: 0;
+    pointer-events: none;
+    background-image: linear-gradient(
+        0deg,
+        var(--color-widget-background),
+        hsla(var(--color-widget-background-hsl-values), 0.8) 6rem, transparent 14rem
+    );
+    z-index: 2;
+}
+
+.rss-card-2-image {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    /* +1px is required to fix some weird graphical bug where the image overflows on the bottom in firefox */
+    border-radius: calc(var(--border-radius) + 1px);
+    opacity: 0.9;
+    z-index: 1;
+}
+
+.rss-card-2-content {
+    position: absolute;
+    inset-inline: 0;
+    bottom: var(--widget-content-vertical-padding);
+    z-index: 3;
+}
+
 .twitch-category-thumbnail {
     width: 5rem;
     border-radius: var(--border-radius);
@@ -1013,6 +1121,8 @@ body {
         --content-bounds-padding: 10px;
     }
 
+    .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
+
     .forum-post-list-item {
         flex-flow: row-reverse;
     }
@@ -1020,6 +1130,15 @@ body {
     .hide-on-mobile {
         display: none
     }
+
+    .mobile-reachability-header {
+        display: block;
+        font-size: 3rem;
+        padding: 10dvh 1rem;
+        text-align: center;
+        color: var(--color-text-highlight);
+        animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
+    }
 }
 
 .size-h1   { font-size: var(--font-size-h1); }
@@ -1041,6 +1160,8 @@ body {
 .text-left          { text-align: left; }
 .text-right         { text-align: right; }
 .text-center        { text-align: center; }
+.text-elevate       { margin-top: -0.2em; }
+.text-compact       { word-spacing: -0.18em; }
 .rtl                { direction: rtl; }
 .shrink             { flex-shrink: 1; }
 .shrink-0           { flex-shrink: 0; }
@@ -1069,6 +1190,11 @@ body {
 .margin-top-7       { margin-top: 0.7rem; }
 .margin-top-10      { margin-top: 1rem; }
 .margin-top-15      { margin-top: 1.5rem; }
+.margin-block-3     { margin-block: 0.3rem; }
+.margin-block-5     { margin-block: 0.5rem; }
+.margin-block-7     { margin-block: 0.7rem; }
+.margin-block-10    { margin-block: 1rem; }
+.margin-block-15    { margin-block: 1.5rem; }
 .margin-bottom-3    { margin-bottom: 0.3rem; }
 .margin-bottom-5    { margin-bottom: 0.5rem; }
 .margin-bottom-7    { margin-bottom: 0.7rem; }

+ 28 - 0
internal/assets/static/main.js

@@ -142,6 +142,33 @@ function setupDynamicRelativeTime() {
     });
 }
 
+function setupLazyImages() {
+    const images = document.querySelectorAll("img[loading=lazy]");
+
+    if (images.length == 0) {
+        return;
+    }
+
+    function imageFinishedTransition(image) {
+        image.classList.add("finished-transition");
+    }
+
+    for (let i = 0; i < images.length; i++) {
+        const image = images[i];
+
+        if (image.complete) {
+            image.classList.add("cached");
+            setTimeout(() => imageFinishedTransition(image), 5);
+        } else {
+            // TODO: also handle error event
+            image.addEventListener("load", () => {
+                image.classList.add("loaded");
+                setTimeout(() => imageFinishedTransition(image), 500);
+            });
+        }
+    }
+}
+
 async function setupPage() {
     const pageElement = document.getElementById("page");
     const pageContents = await fetchPageContents(pageData.slug);
@@ -152,6 +179,7 @@ async function setupPage() {
         document.body.classList.add("animate-element-transition");
     }, 150);
 
+    setTimeout(setupLazyImages, 5);
     setupCarousels();
     setupDynamicRelativeTime();
 }

+ 6 - 3
internal/assets/templates.go

@@ -22,14 +22,17 @@ var (
 	RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
 	RedditCardsVerticalTemplate   = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
 	ReleasesTemplate              = compileTemplate("releases.html", "widget-base.html")
-	ChangesTemplate				  = compileTemplate("changes.html", "widget-base.html")
-	VideosTemplate                = compileTemplate("videos.html", "widget-base.html")
+	ChangesTemplate               = compileTemplate("changes.html", "widget-base.html")
+	VideosTemplate                = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
+	VideosGridTemplate            = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
 	StocksTemplate                = compileTemplate("stocks.html", "widget-base.html")
 	RSSListTemplate               = compileTemplate("rss-list.html", "widget-base.html")
-	RSSCardsTemplate              = compileTemplate("rss-cards.html", "widget-base.html")
+	RSSHorizontalCardsTemplate    = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
+	RSSHorizontalCards2Template   = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
 	MonitorTemplate               = compileTemplate("monitor.html", "widget-base.html")
 	TwitchGamesListTemplate       = compileTemplate("twitch-games-list.html", "widget-base.html")
 	TwitchChannelsTemplate        = compileTemplate("twitch-channels.html", "widget-base.html")
+	RepositoryTemplate            = compileTemplate("repository.html", "widget-base.html")
 )
 
 var globalTemplateFunctions = template.FuncMap{

+ 27 - 13
internal/assets/templates/bookmarks.html

@@ -1,23 +1,37 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
+{{ if ne .Style "dynamic-columns-experimental" }}
 <ul class="list list-gap-24 list-with-separator">
     {{ range .Groups }}
     <li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
-        {{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
-        <ul class="list list-gap-2">
-        {{ range .Links }}
-            <li class="flex items-center gap-10">
-                {{ if ne "" .Icon }}
-                <div class="bookmarks-icon-container">
-                    <img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
-                </div>
-                {{ end }}
-                <a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
-            </li>
-        {{ end }}
-        </ul>
+        {{ template "group" . }}
     </li>
     {{ end }}
 </ul>
+{{ else }}
+<div class="dynamic-columns">
+    {{ range .Groups }}
+    <div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
+        {{ template "group" . }}
+    </div>
+    {{ end }}
+</div>
+{{ end }}
+{{ end }}
+
+{{ define "group" }}
+{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
+<ul class="list list-gap-2">
+{{ range .Links }}
+<li class="flex items-center gap-10">
+    {{ if ne "" .Icon }}
+    <div class="bookmarks-icon-container">
+        <img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
+    </div>
+    {{ end }}
+    <a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
+</li>
+{{ end }}
+</ul>
 {{ end }}

+ 4 - 0
internal/assets/templates/content.html

@@ -1,3 +1,7 @@
+{{ if .Page.ShowMobileHeader }}
+<div class="mobile-reachability-header">{{ .Page.Title }}</div>
+{{ end }}
+
 <div class="page-columns">
 {{ range .Page.Columns }}
     <div class="page-column page-column-{{ .Size }}">

+ 43 - 29
internal/assets/templates/monitor.html

@@ -1,39 +1,53 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
+{{ if ne .Style "dynamic-columns-experimental" }}
 <ul class="list list-gap-20 list-with-separator">
     {{ range .Sites }}
     <li class="monitor-site flex items-center gap-15">
-        {{ if .IconUrl }}
-        <img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
-        {{ end }}
-        <div>
-            <a class="size-h3 color-highlight" href="{{ .Url }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
-            <ul class="list-horizontal-text">
-                {{ if not .Status.Error }}
-                <li>{{ .StatusText }}</li>
-                <li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
-                {{ else if .Status.TimedOut }}
-                <li class="color-negative">Timed Out</li>
-                {{ else }}
-                <li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
-                {{ end }}
-            </ul>
-        </div>
-        {{ if eq .StatusStyle "good" }}
-        <div class="monitor-site-status-icon">
-            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
-                <path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
-            </svg>
-        </div>
-        {{ else }}
-        <div class="monitor-site-status-icon">
-            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
-                <path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
-            </svg>
-        </div>
-        {{ end }}
+        {{ template "site" . }}
     </li>
     {{ end }}
 </ul>
+{{ else }}
+<ul class="dynamic-columns">
+    {{ range .Sites }}
+    <div class="flex items-center gap-15">
+        {{ template "site" . }}
+    </div>
+    {{ end }}
+</ul>
+{{ end }}
+{{ end }}
+
+{{ define "site" }}
+{{ if .IconUrl }}
+<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
+{{ end }}
+<div>
+    <a class="size-h3 color-highlight" href="{{ .Url }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
+    <ul class="list-horizontal-text">
+        {{ if not .Status.Error }}
+        <li>{{ .StatusText }}</li>
+        <li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
+        {{ else if .Status.TimedOut }}
+        <li class="color-negative">Timed Out</li>
+        {{ else }}
+        <li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
+        {{ end }}
+    </ul>
+</div>
+{{ if eq .StatusStyle "good" }}
+<div class="monitor-site-status-icon">
+    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
+        <path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
+    </svg>
+</div>
+{{ else }}
+<div class="monitor-site-status-icon">
+    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
+        <path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
+    </svg>
+</div>
+{{ end }}
 {{ end }}

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

@@ -1,7 +1,7 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
-<ul class="list list-gap-14 list-collapsible">
+<ul class="list list-gap-10 list-collapsible">
     {{ range $i, $release := .Releases }}
     <li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
         <a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>

+ 44 - 0
internal/assets/templates/repository.html

@@ -0,0 +1,44 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a>
+<ul class="list-horizontal-text">
+    <li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
+    <li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
+</ul>
+
+{{ if gt (len .RepositoryDetails.PullRequests) 0 }}
+<hr class="margin-block-10">
+<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>
+<div class="flex gap-7 size-h5 margin-top-3">
+    <ul class="list list-gap-2">
+        {{ range .RepositoryDetails.PullRequests }}
+        <li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li>
+        {{ end }}
+    </ul>
+    <ul class="list list-gap-2 min-width-0">
+        {{ range .RepositoryDetails.PullRequests }}
+        <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
+        {{ end }}
+    </ul>
+</div>
+{{ end }}
+
+{{ if gt (len .RepositoryDetails.Issues) 0 }}
+<hr class="margin-block-10">
+<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total)</a>
+<div class="flex gap-7 size-h5 margin-top-3">
+    <ul class="list list-gap-2">
+        {{ range .RepositoryDetails.Issues }}
+        <li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li>
+        {{ end }}
+    </ul>
+    <ul class="list list-gap-2 min-width-0">
+        {{ range .RepositoryDetails.Issues }}
+        <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
+        {{ end }}
+    </ul>
+</div>
+{{ end }}
+
+{{ end }}

+ 28 - 0
internal/assets/templates/rss-horizontal-cards-2.html

@@ -0,0 +1,28 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
+
+{{ define "widget-content" }}
+<div class="carousel-container">
+    <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
+        {{ range .Items }}
+        <div class="card rss-card-2 widget-content-frame thumbnail-container">
+            {{ if ne "" .ImageURL }}
+            <img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
+            {{ else }}
+            <svg class="rss-card-2-image" style="transform: scale(0.35) translateY(-25%)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="var(--color-text-subdue)">
+                <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
+            </svg>
+            {{ end }}
+            <div class="rss-card-2-content padding-inline-widget">
+                <a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
+                <ul class="list-horizontal-text flex-nowrap margin-top-5">
+                    <li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
+                    <li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
+                </ul>
+            </div>
+        </div>
+        {{ end }}
+    </div>
+</div>
+{{ end }}

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

@@ -4,7 +4,7 @@
 
 {{ define "widget-content" }}
 <div class="carousel-container">
-    <div class="cards-horizontal carousel-items-container">
+    <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
         {{ range .Items }}
         <div class="card widget-content-frame thumbnail-container">
             {{ if ne "" .ImageURL }}

+ 29 - 13
internal/assets/templates/stocks.html

@@ -1,23 +1,39 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
+{{ if ne .Style "dynamic-columns-experimental" }}
 <ul class="list list-gap-20 list-with-separator">
     {{ range .Stocks }}
     <li class="flex items-center gap-15">
-        <div class="shrink min-width-0">
-            <div class="color-highlight size-h3 text-truncate">{{ .Symbol }}</div>
-            <div class="text-truncate">{{ .Name }}</div>
-        </div>
-
-        <svg class="stock-chart shrink-0" viewBox="0 0 100 50">
-            <polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
-        </svg>
-
-        <div class="stock-values shrink-0">
-            <div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
-            <div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
-        </div>
+        {{ template "stock" . }}
     </li>
     {{ end }}
 </ul>
+{{ else }}
+<div class="dynamic-columns">
+    {{ range .Stocks }}
+    <div class="flex items-center gap-15">
+        {{ template "stock" . }}
+    </div>
+    {{ end }}
+</div>
+{{ end }}
+{{ end }}
+
+{{ define "stock" }}
+<div class="shrink min-width-0">
+    <a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
+    <div class="text-truncate">{{ .Name }}</div>
+</div>
+
+<a class="stock-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
+    <svg class="stock-chart shrink-0" viewBox="0 0 100 50">
+        <polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
+    </svg>
+</a>
+
+<div class="stock-values shrink-0">
+    <div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
+    <div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
+</div>
 {{ end }}

+ 12 - 0
internal/assets/templates/video-card-contents.html

@@ -0,0 +1,12 @@
+{{ define "video-card-contents" }}
+<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
+<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
+    <a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
+    <ul class="list-horizontal-text flex-nowrap margin-top-7">
+        <li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
+        <li class="shrink min-width-0">
+            <a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
+        </li>
+    </ul>
+</div>
+{{ end }}

+ 13 - 0
internal/assets/templates/videos-grid.html

@@ -0,0 +1,13 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
+
+{{ define "widget-content" }}
+<div class="cards-grid">
+    {{ range .Videos }}
+    <div class="card widget-content-frame thumbnail-container">
+        {{ template "video-card-contents" . }}
+    </div>
+    {{ end }}
+</div>
+{{ end }}

+ 2 - 11
internal/assets/templates/videos.html

@@ -4,19 +4,10 @@
 
 {{ define "widget-content" }}
 <div class="carousel-container">
-    <div class="videos cards-horizontal carousel-items-container">
+    <div class="cards-horizontal carousel-items-container">
         {{ 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 grow padding-inline-widget">
-                <a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
-                <ul class="list-horizontal-text flex-nowrap margin-top-7">
-                    <li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
-                    <li class="shrink min-width-0">
-                        <a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
-                    </li>
-                </ul>
-            </div>
+            {{ template "video-card-contents" . }}
         </div>
         {{ end }}
     </div>

+ 131 - 0
internal/feed/github.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"log/slog"
 	"net/http"
+	"sync"
 	"time"
 )
 
@@ -115,3 +116,133 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
 
 	return appReleases, nil
 }
+
+type GithubTicket struct {
+	Number    int
+	CreatedAt time.Time
+	Title     string
+}
+
+type RepositoryDetails struct {
+	Name             string
+	Stars            int
+	Forks            int
+	OpenPullRequests int
+	PullRequests     []GithubTicket
+	OpenIssues       int
+	Issues           []GithubTicket
+}
+
+type githubRepositoryDetailsResponseJson struct {
+	Name  string `json:"full_name"`
+	Stars int    `json:"stargazers_count"`
+	Forks int    `json:"forks_count"`
+}
+
+type githubTicketResponseJson struct {
+	Count   int `json:"total_count"`
+	Tickets []struct {
+		Number    int    `json:"number"`
+		CreatedAt string `json:"created_at"`
+		Title     string `json:"title"`
+	} `json:"items"`
+}
+
+func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) {
+	repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
+
+	if err != nil {
+		return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
+	}
+
+	PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
+	issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
+
+	if token != "" {
+		token = fmt.Sprintf("Bearer %s", token)
+		repositoryRequest.Header.Add("Authorization", token)
+		PRsRequest.Header.Add("Authorization", token)
+		issuesRequest.Header.Add("Authorization", token)
+	}
+
+	var detailsResponse githubRepositoryDetailsResponseJson
+	var detailsErr error
+	var PRsResponse githubTicketResponseJson
+	var PRsErr error
+	var issuesResponse githubTicketResponseJson
+	var issuesErr error
+	var wg sync.WaitGroup
+
+	wg.Add(1)
+	go (func() {
+		defer wg.Done()
+		detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
+	})()
+
+	if maxPRs > 0 {
+		wg.Add(1)
+		go (func() {
+			defer wg.Done()
+			PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
+		})()
+	}
+
+	if maxIssues > 0 {
+		wg.Add(1)
+		go (func() {
+			defer wg.Done()
+			issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
+		})()
+	}
+
+	wg.Wait()
+
+	if detailsErr != nil {
+		return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
+	}
+
+	details := RepositoryDetails{
+		Name:         detailsResponse.Name,
+		Stars:        detailsResponse.Stars,
+		Forks:        detailsResponse.Forks,
+		PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
+		Issues:       make([]GithubTicket, 0, len(issuesResponse.Tickets)),
+	}
+
+	err = nil
+
+	if maxPRs > 0 {
+		if PRsErr != nil {
+			err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
+		} else {
+			details.OpenPullRequests = PRsResponse.Count
+
+			for i := range PRsResponse.Tickets {
+				details.PullRequests = append(details.PullRequests, GithubTicket{
+					Number:    PRsResponse.Tickets[i].Number,
+					CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
+					Title:     PRsResponse.Tickets[i].Title,
+				})
+			}
+		}
+	}
+
+	if maxIssues > 0 {
+		if issuesErr != nil {
+			// TODO: fix, overwriting the previous error
+			err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
+		} else {
+			details.OpenIssues = issuesResponse.Count
+
+			for i := range issuesResponse.Tickets {
+				details.Issues = append(details.Issues, GithubTicket{
+					Number:    issuesResponse.Tickets[i].Number,
+					CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
+					Title:     issuesResponse.Tickets[i].Title,
+				})
+			}
+		}
+	}
+
+	return details, err
+}

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

@@ -18,8 +18,8 @@ type hackerNewsPostResponseJson struct {
 	TimePosted   int64  `json:"time"`
 }
 
-func getHackerNewsTopPostIds() ([]int, error) {
-	request, _ := http.NewRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", nil)
+func getHackerNewsPostIds(sort string) ([]int, error) {
+	request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
 	response, err := decodeJsonFromRequest[[]int](defaultClient, request)
 
 	if err != nil {
@@ -83,8 +83,8 @@ func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (Forum
 	return posts, nil
 }
 
-func FetchHackerNewsTopPosts(limit int, commentsUrlTemplate string) (ForumPosts, error) {
-	postIds, err := getHackerNewsTopPostIds()
+func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) {
+	postIds, err := getHackerNewsPostIds(sort)
 
 	if err != nil {
 		return nil, err

+ 8 - 6
internal/feed/primitives.go

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

+ 20 - 5
internal/feed/reddit.go

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

+ 1 - 1
internal/feed/utils.go

@@ -28,7 +28,7 @@ func extractDomainFromUrl(u string) string {
 		return ""
 	}
 
-	return strings.TrimPrefix(parsed.Host, "www.")
+	return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
 }
 
 func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {

+ 7 - 10
internal/feed/yahoo.go

@@ -24,15 +24,10 @@ type stockResponseJson struct {
 	} `json:"chart"`
 }
 
-type StockRequest struct {
-	Symbol string
-	Name   string
-}
-
 // TODO: allow changing chart time frame
 const stockChartDays = 21
 
-func FetchStocksDataFromYahoo(stockRequests []StockRequest) (Stocks, error) {
+func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
 	requests := make([]*http.Request, 0, len(stockRequests))
 
 	for i := range stockRequests {
@@ -86,10 +81,12 @@ func FetchStocksDataFromYahoo(stockRequests []StockRequest) (Stocks, error) {
 		}
 
 		stocks = append(stocks, Stock{
-			Name:     stockRequests[i].Name,
-			Symbol:   response.Chart.Result[0].Meta.Symbol,
-			Price:    response.Chart.Result[0].Meta.RegularMarketPrice,
-			Currency: currency,
+			Name:       stockRequests[i].Name,
+			Symbol:     response.Chart.Result[0].Meta.Symbol,
+			SymbolLink: stockRequests[i].SymbolLink,
+			ChartLink:  stockRequests[i].ChartLink,
+			Price:      response.Chart.Result[0].Meta.RegularMarketPrice,
+			Currency:   currency,
 			PercentChange: percentChange(
 				response.Chart.Result[0].Meta.RegularMarketPrice,
 				previous,

+ 5 - 4
internal/glance/glance.go

@@ -55,10 +55,11 @@ type templateData struct {
 }
 
 type Page struct {
-	Title   string   `yaml:"name"`
-	Slug    string   `yaml:"slug"`
-	Columns []Column `yaml:"columns"`
-	mu      sync.Mutex
+	Title            string   `yaml:"name"`
+	Slug             string   `yaml:"slug"`
+	ShowMobileHeader bool     `yaml:"show-mobile-header"`
+	Columns          []Column `yaml:"columns"`
+	mu               sync.Mutex
 }
 
 func (p *Page) UpdateOutdatedWidgets() {

+ 1 - 0
internal/widget/bookmarks.go

@@ -22,6 +22,7 @@ type Bookmarks struct {
 			HideArrow    bool   `yaml:"hide-arrow"`
 		} `yaml:"links"`
 	} `yaml:"groups"`
+	Style string `yaml:"style"`
 }
 
 func (widget *Bookmarks) Initialize() error {

+ 11 - 3
internal/widget/hacker-news.go

@@ -13,6 +13,8 @@ type HackerNews struct {
 	widgetBase          `yaml:",inline"`
 	Posts               feed.ForumPosts `yaml:"-"`
 	Limit               int             `yaml:"limit"`
+	SortBy              string          `yaml:"sort-by"`
+	ExtraSortBy         string          `yaml:"extra-sort-by"`
 	CollapseAfter       int             `yaml:"collapse-after"`
 	CommentsUrlTemplate string          `yaml:"comments-url-template"`
 	ShowThumbnails      bool            `yaml:"-"`
@@ -29,18 +31,24 @@ func (widget *HackerNews) Initialize() error {
 		widget.CollapseAfter = 5
 	}
 
+	if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" {
+		widget.SortBy = "top"
+	}
+
 	return nil
 }
 
 func (widget *HackerNews) Update(ctx context.Context) {
-	posts, err := feed.FetchHackerNewsTopPosts(40, widget.CommentsUrlTemplate)
+	posts, err := feed.FetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate)
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 		return
 	}
 
-	posts.CalculateEngagement()
-	posts.SortByEngagement()
+	if widget.ExtraSortBy == "engagement" {
+		posts.CalculateEngagement()
+		posts.SortByEngagement()
+	}
 
 	if widget.Limit < len(posts) {
 		posts = posts[:widget.Limit]

+ 9 - 8
internal/widget/monitor.go

@@ -46,14 +46,15 @@ func statusCodeToStyle(status int) string {
 type Monitor struct {
 	widgetBase `yaml:",inline"`
 	Sites      []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:"-"`
+		Title       string            `yaml:"title"`
+		Url         OptionalEnvString `yaml:"url"`
+		IconUrl     string            `yaml:"icon"`
+		SameTab     bool              `yaml:"same-tab"`
+		Status      *feed.SiteStatus  `yaml:"-"`
+		StatusText  string            `yaml:"-"`
+		StatusStyle string            `yaml:"-"`
 	} `yaml:"sites"`
+	Style string `yaml:"style"`
 }
 
 func (widget *Monitor) Initialize() error {
@@ -66,7 +67,7 @@ func (widget *Monitor) Update(ctx context.Context) {
 	requests := make([]*http.Request, len(widget.Sites))
 
 	for i := range widget.Sites {
-		request, err := http.NewRequest("GET", widget.Sites[i].Url, nil)
+		request, err := http.NewRequest("GET", string(widget.Sites[i].Url), nil)
 
 		if err != nil {
 			message := fmt.Errorf("failed to create http request for %s: %s", widget.Sites[i].Url, err)

+ 42 - 2
internal/widget/reddit.go

@@ -17,6 +17,10 @@ type Reddit struct {
 	Subreddit           string          `yaml:"subreddit"`
 	Style               string          `yaml:"style"`
 	ShowThumbnails      bool            `yaml:"show-thumbnails"`
+	SortBy              string          `yaml:"sort-by"`
+	TopPeriod           string          `yaml:"top-period"`
+	Search              string          `yaml:"search"`
+	ExtraSortBy         string          `yaml:"extra-sort-by"`
 	CommentsUrlTemplate string          `yaml:"comments-url-template"`
 	Limit               int             `yaml:"limit"`
 	CollapseAfter       int             `yaml:"collapse-after"`
@@ -36,6 +40,14 @@ func (widget *Reddit) Initialize() error {
 		widget.CollapseAfter = 5
 	}
 
+	if !isValidRedditSortType(widget.SortBy) {
+		widget.SortBy = "hot"
+	}
+
+	if !isValidRedditTopPeriod(widget.TopPeriod) {
+		widget.TopPeriod = "day"
+	}
+
 	if widget.RequestUrlTemplate != "" {
 		if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") {
 			return errors.New("no `{REQUEST-URL}` placeholder specified")
@@ -47,8 +59,32 @@ func (widget *Reddit) Initialize() error {
 	return nil
 }
 
+func isValidRedditSortType(sortBy string) bool {
+	return sortBy == "hot" ||
+		sortBy == "new" ||
+		sortBy == "top" ||
+		sortBy == "rising"
+}
+
+func isValidRedditTopPeriod(period string) bool {
+	return period == "hour" ||
+		period == "day" ||
+		period == "week" ||
+		period == "month" ||
+		period == "year" ||
+		period == "all"
+}
+
 func (widget *Reddit) Update(ctx context.Context) {
-	posts, err := feed.FetchSubredditPosts(widget.Subreddit, widget.CommentsUrlTemplate, widget.RequestUrlTemplate)
+	// TODO: refactor, use a struct to pass all of these
+	posts, err := feed.FetchSubredditPosts(
+		widget.Subreddit,
+		widget.SortBy,
+		widget.TopPeriod,
+		widget.Search,
+		widget.CommentsUrlTemplate,
+		widget.RequestUrlTemplate,
+	)
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 		return
@@ -58,7 +94,11 @@ func (widget *Reddit) Update(ctx context.Context) {
 		posts = posts[:widget.Limit]
 	}
 
-	posts.SortByEngagement()
+	if widget.ExtraSortBy == "engagement" {
+		posts.CalculateEngagement()
+		posts.SortByEngagement()
+	}
+
 	widget.Posts = posts
 }
 

+ 52 - 0
internal/widget/repository-overview.go

@@ -0,0 +1,52 @@
+package widget
+
+import (
+	"context"
+	"html/template"
+	"time"
+
+	"github.com/glanceapp/glance/internal/assets"
+	"github.com/glanceapp/glance/internal/feed"
+)
+
+type Repository struct {
+	widgetBase          `yaml:",inline"`
+	RequestedRepository string            `yaml:"repository"`
+	Token               OptionalEnvString `yaml:"token"`
+	PullRequestsLimit   int               `yaml:"pull-requests-limit"`
+	IssuesLimit         int               `yaml:"issues-limit"`
+	RepositoryDetails   feed.RepositoryDetails
+}
+
+func (widget *Repository) Initialize() error {
+	widget.withTitle("Repository").withCacheDuration(1 * time.Hour)
+
+	if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 {
+		widget.PullRequestsLimit = 3
+	}
+
+	if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 {
+		widget.IssuesLimit = 3
+	}
+
+	return nil
+}
+
+func (widget *Repository) Update(ctx context.Context) {
+	details, err := feed.FetchRepositoryDetailsFromGithub(
+		widget.RequestedRepository,
+		string(widget.Token),
+		widget.PullRequestsLimit,
+		widget.IssuesLimit,
+	)
+
+	if !widget.canContinueUpdateAfterHandlingErr(err) {
+		return
+	}
+
+	widget.RepositoryDetails = details
+}
+
+func (widget *Repository) Render() template.HTML {
+	return widget.render(widget, assets.RepositoryTemplate)
+}

+ 21 - 7
internal/widget/rss.go

@@ -10,12 +10,14 @@ import (
 )
 
 type RSS struct {
-	widgetBase    `yaml:",inline"`
-	FeedRequests  []feed.RSSFeedRequest `yaml:"feeds"`
-	Style         string                `yaml:"style"`
-	Items         feed.RSSFeedItems     `yaml:"-"`
-	Limit         int                   `yaml:"limit"`
-	CollapseAfter int                   `yaml:"collapse-after"`
+	widgetBase      `yaml:",inline"`
+	FeedRequests    []feed.RSSFeedRequest `yaml:"feeds"`
+	Style           string                `yaml:"style"`
+	ThumbnailHeight float64               `yaml:"thumbnail-height"`
+	CardHeight      float64               `yaml:"card-height"`
+	Items           feed.RSSFeedItems     `yaml:"-"`
+	Limit           int                   `yaml:"limit"`
+	CollapseAfter   int                   `yaml:"collapse-after"`
 }
 
 func (widget *RSS) Initialize() error {
@@ -29,6 +31,14 @@ func (widget *RSS) Initialize() error {
 		widget.CollapseAfter = 5
 	}
 
+	if widget.ThumbnailHeight < 0 {
+		widget.ThumbnailHeight = 0
+	}
+
+	if widget.CardHeight < 0 {
+		widget.CardHeight = 0
+	}
+
 	return nil
 }
 
@@ -48,7 +58,11 @@ func (widget *RSS) Update(ctx context.Context) {
 
 func (widget *RSS) Render() template.HTML {
 	if widget.Style == "horizontal-cards" {
-		return widget.render(widget, assets.RSSCardsTemplate)
+		return widget.render(widget, assets.RSSHorizontalCardsTemplate)
+	}
+
+	if widget.Style == "horizontal-cards-2" {
+		return widget.render(widget, assets.RSSHorizontalCards2Template)
 	}
 
 	return widget.render(widget, assets.RSSListTemplate)

+ 5 - 4
internal/widget/stocks.go

@@ -9,11 +9,12 @@ import (
 	"github.com/glanceapp/glance/internal/feed"
 )
 
+// TODO: rename to Markets at some point
 type Stocks struct {
 	widgetBase `yaml:",inline"`
-	Stocks     feed.Stocks         `yaml:"-"`
-	Sort       string              `yaml:"sort-by"`
-	Tickers    []feed.StockRequest `yaml:"stocks"`
+	Stocks     feed.Stocks `yaml:"stocks"`
+	Sort       string      `yaml:"sort-by"`
+	Style      string      `yaml:"style"`
 }
 
 func (widget *Stocks) Initialize() error {
@@ -23,7 +24,7 @@ func (widget *Stocks) Initialize() error {
 }
 
 func (widget *Stocks) Update(ctx context.Context) {
-	stocks, err := feed.FetchStocksDataFromYahoo(widget.Tickers)
+	stocks, err := feed.FetchStocksDataFromYahoo(widget.Stocks)
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 		return

+ 5 - 0
internal/widget/videos.go

@@ -13,6 +13,7 @@ type Videos struct {
 	widgetBase       `yaml:",inline"`
 	Videos           feed.Videos `yaml:"-"`
 	VideoUrlTemplate string      `yaml:"video-url-template"`
+	Style            string      `yaml:"style"`
 	Channels         []string    `yaml:"channels"`
 	Limit            int         `yaml:"limit"`
 }
@@ -42,5 +43,9 @@ func (widget *Videos) Update(ctx context.Context) {
 }
 
 func (widget *Videos) Render() template.HTML {
+	if widget.Style == "grid-cards" {
+		return widget.render(widget, assets.VideosGridTemplate)
+	}
+
 	return widget.render(widget, assets.VideosTemplate)
 }

+ 2 - 0
internal/widget/widget.go

@@ -45,6 +45,8 @@ func New(widgetType string) (Widget, error) {
 		return &TwitchChannels{}, nil
 	case "changes":
 		return &ChangeDetections{}, nil
+	case "repository":
+		return &Repository{}, nil
 	default:
 		return nil, fmt.Errorf("unknown widget type: %s found", widgetType)
 	}

+ 9 - 1
scripts/build-and-ship/main.go

@@ -67,6 +67,14 @@ var buildTargets = []buildTarget{
 		arch: "arm",
 		armV: 7,
 	},
+	{
+		os:   "openbsd",
+		arch: "amd64",
+	},
+	{
+		os:   "openbsd",
+		arch: "386",
+	},
 }
 
 func main() {
@@ -113,7 +121,7 @@ func main() {
 
 	output, err := exec.Command(
 		"sudo", "docker", "build",
-		"--platform=linux/arm64,linux/amd64",
+		"--platform=linux/amd64,linux/arm64,linux/arm/v7",
 		"-t", versionTag,
 		"-t", latestTag,
 		".",