Browse Source

Merge branch 'release/v0.7.0' into main

Svilen Markov 7 months ago
parent
commit
e5106c0704
100 changed files with 3679 additions and 1943 deletions
  1. 1 0
      .gitignore
  2. 2 2
      Dockerfile
  3. 1 1
      Dockerfile.goreleaser
  4. 3 0
      README.md
  5. 199 7
      docs/configuration.md
  6. BIN
      docs/images/docker-widget-preview.png
  7. BIN
      docs/images/monitor-widget-compact-preview.png
  8. BIN
      docs/images/split-column-widget-preview.png
  9. 9 4
      go.mod
  10. 18 7
      go.sum
  11. 0 56
      internal/assets/files.go
  12. 0 109
      internal/assets/templates.go
  13. 0 14
      internal/assets/templates/page-style-overrides.gotmpl
  14. 0 120
      internal/feed/adguard.go
  15. 0 53
      internal/feed/calendar.go
  16. 0 39
      internal/feed/codeberg.go
  17. 0 102
      internal/feed/dockerhub.go
  18. 0 97
      internal/feed/extension.go
  19. 0 48
      internal/feed/gitlab.go
  20. 0 98
      internal/feed/hacker-news.go
  21. 0 91
      internal/feed/lobsters.go
  22. 0 77
      internal/feed/monitor.go
  23. 0 136
      internal/feed/pihole.go
  24. 0 241
      internal/feed/primitives.go
  25. 0 72
      internal/feed/releases.go
  26. 0 104
      internal/feed/yahoo.go
  27. 0 115
      internal/feed/youtube.go
  28. 41 16
      internal/glance/cli.go
  29. 232 0
      internal/glance/config-fields.go
  30. 255 30
      internal/glance/config.go
  31. 205 0
      internal/glance/diagnose.go
  32. 62 0
      internal/glance/embed.go
  33. 123 166
      internal/glance/glance.go
  34. 147 18
      internal/glance/main.go
  35. 0 0
      internal/glance/static/app-icon.png
  36. 0 0
      internal/glance/static/favicon.png
  37. 0 0
      internal/glance/static/fonts/JetBrainsMono-Regular.woff2
  38. 0 0
      internal/glance/static/icons/codeberg.svg
  39. 0 0
      internal/glance/static/icons/dockerhub.svg
  40. 0 0
      internal/glance/static/icons/github.svg
  41. 0 0
      internal/glance/static/icons/gitlab.svg
  42. 59 5
      internal/glance/static/js/main.js
  43. 53 0
      internal/glance/static/js/masonry.js
  44. 7 4
      internal/glance/static/js/popover.js
  45. 13 0
      internal/glance/static/js/utils.js
  46. 70 15
      internal/glance/static/main.css
  47. 0 0
      internal/glance/static/manifest.json
  48. 56 0
      internal/glance/templates.go
  49. 2 2
      internal/glance/templates/bookmarks.html
  50. 6 1
      internal/glance/templates/calendar.html
  51. 0 0
      internal/glance/templates/change-detection.html
  52. 0 0
      internal/glance/templates/clock.html
  53. 7 0
      internal/glance/templates/custom-api.html
  54. 1 1
      internal/glance/templates/dns-stats.html
  55. 64 0
      internal/glance/templates/docker-containers.html
  56. 0 0
      internal/glance/templates/document.html
  57. 0 0
      internal/glance/templates/extension.html
  58. 2 2
      internal/glance/templates/forum-posts.html
  59. 1 1
      internal/glance/templates/group.html
  60. 0 0
      internal/glance/templates/iframe.html
  61. 1 1
      internal/glance/templates/markets.html
  62. 39 0
      internal/glance/templates/monitor-compact.html
  63. 6 6
      internal/glance/templates/monitor.html
  64. 0 0
      internal/glance/templates/page-content.html
  65. 6 3
      internal/glance/templates/page.html
  66. 0 0
      internal/glance/templates/reddit-horizontal-cards.html
  67. 0 0
      internal/glance/templates/reddit-vertical-cards.html
  68. 2 2
      internal/glance/templates/releases.html
  69. 20 20
      internal/glance/templates/repository.html
  70. 0 0
      internal/glance/templates/rss-detailed-list.html
  71. 0 0
      internal/glance/templates/rss-horizontal-cards-2.html
  72. 0 0
      internal/glance/templates/rss-horizontal-cards.html
  73. 0 0
      internal/glance/templates/rss-list.html
  74. 0 0
      internal/glance/templates/search.html
  75. 11 0
      internal/glance/templates/split-column.html
  76. 14 0
      internal/glance/templates/theme-style.gotmpl
  77. 8 2
      internal/glance/templates/twitch-channels.html
  78. 0 0
      internal/glance/templates/twitch-games-list.html
  79. 44 0
      internal/glance/templates/v0.7-update-notice-page.html
  80. 0 0
      internal/glance/templates/video-card-contents.html
  81. 0 0
      internal/glance/templates/videos-grid.html
  82. 0 0
      internal/glance/templates/videos.html
  83. 0 0
      internal/glance/templates/weather.html
  84. 3 1
      internal/glance/templates/widget-base.html
  85. 72 9
      internal/glance/utils.go
  86. 34 0
      internal/glance/widget-bookmarks.go
  87. 86 0
      internal/glance/widget-calendar.go
  88. 74 16
      internal/glance/widget-changedetection.go
  89. 11 13
      internal/glance/widget-clock.go
  90. 58 0
      internal/glance/widget-container.go
  91. 208 0
      internal/glance/widget-custom-api.go
  92. 352 0
      internal/glance/widget-dns-stats.go
  93. 272 0
      internal/glance/widget-docker-containers.go
  94. 152 0
      internal/glance/widget-extension.go
  95. 52 0
      internal/glance/widget-group.go
  96. 152 0
      internal/glance/widget-hacker-news.go
  97. 4 4
      internal/glance/widget-html.go
  98. 10 12
      internal/glance/widget-iframe.go
  99. 144 0
      internal/glance/widget-lobsters.go
  100. 205 0
      internal/glance/widget-markets.go

+ 1 - 0
.gitignore

@@ -1,4 +1,5 @@
 /assets
 /build
 /playground
+/.idea
 glance*.yml

+ 2 - 2
Dockerfile

@@ -1,4 +1,4 @@
-FROM golang:1.22.5-alpine3.20 AS builder
+FROM golang:1.23.1-alpine3.20 AS builder
 
 WORKDIR /app
 COPY . /app
@@ -13,4 +13,4 @@ HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
   CMD wget --spider -q http://localhost:8080/api/healthz
 
 EXPOSE 8080/tcp
-ENTRYPOINT ["/app/glance"]
+ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]

+ 1 - 1
Dockerfile.goreleaser

@@ -7,4 +7,4 @@ HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
   CMD wget --spider -q http://localhost:8080/api/healthz
 
 EXPOSE 8080/tcp
-ENTRYPOINT ["/app/glance"]
+ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]

+ 3 - 0
README.md

@@ -20,6 +20,7 @@
 * Twitch channels & top games
 * GitHub releases
 * Repository overview
+* Docker containers
 * Site monitor
 * Search box
 
@@ -51,6 +52,8 @@ Checkout the [releases page](https://github.com/glanceapp/glance/releases) for a
 ```
 
 #### Docker
+<!-- TODO: update -->
+
 > [!IMPORTANT]
 >
 > Make sure you have a valid `glance.yml` file in the same directory before running the container.

+ 199 - 7
docs/configuration.md

@@ -15,6 +15,8 @@
   - [Reddit](#reddit)
   - [Search](#search-widget)
   - [Group](#group)
+  - [Split Column](#split-column)
+  - [Custom API](#custom-api)
   - [Extension](#extension)
   - [Weather](#weather)
   - [Monitor](#monitor)
@@ -30,8 +32,10 @@
   - [Twitch Top Games](#twitch-top-games)
   - [iframe](#iframe)
   - [HTML](#html)
+  - [Docker](#docker)
 
 ## Intro
+<!-- TODO: update -->
 Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
 
 ## Preconfigured page
@@ -111,6 +115,8 @@ This will give you a page that looks like the following:
 
 Configure the widgets, add more of them, add extra pages, etc. Make it your own!
 
+<!-- TODO: update - add information about top level document key -->
+
 ## Server
 Server configuration is done through a top level `server` property. Example:
 
@@ -313,6 +319,7 @@ pages:
 | width | string | no | |
 | center-vertically | boolean | no | false |
 | hide-desktop-navigation | boolean | no | false |
+| expand-mobile-page-navigation | boolean | no | false |
 | show-mobile-header | boolean | no | false |
 | columns | array | yes | |
 
@@ -339,6 +346,9 @@ When set to `true`, vertically centers the content on the page. Has no effect if
 #### `hide-desktop-navigation`
 Whether to show the navigation links at the top of the page on desktop.
 
+#### `expand-mobile-page-navigation`
+Whether the mobile page navigation should be expanded by default.
+
 #### `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.
 
@@ -525,10 +535,22 @@ An array of RSS/atom feeds. The title can optionally be changed.
 | hide-categories | boolean | no | false | Only applicable for `detailed-list` style |
 | hide-description | boolean | no | false | Only applicable for `detailed-list` style |
 | item-link-prefix | string | no | | |
+| headers | key (string) & value (string) | no | | |
 
 ###### `item-link-prefix`
 If an RSS feed isn't returning item links with a base domain and Glance has failed to automatically detect the correct domain you can manually add a prefix to each link with this property.
 
+###### `headers`
+Optionally specify the headers that will be sent with the request. Example:
+
+```yaml
+- type: rss
+  feeds:
+    - url: https://domain.com/rss
+      headers:
+        User-Agent: Custom User Agent
+```
+
 ##### `limit`
 The maximum number of articles to show.
 
@@ -828,6 +850,7 @@ Preview:
 | <kbd>Enter</kbd> | Perform search in the same tab | Search input is focused and not empty |
 | <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Perform search in a new tab | Search input is focused and not empty |
 | <kbd>Escape</kbd> | Leave focus | Search input is focused |
+| <kbd>Up</kbd> | Insert the last search query since the page was opened into the input field | Search input is focused |
 
 > [!TIP]
 >
@@ -890,7 +913,7 @@ url: https://www.amazon.com/s?k={QUERY}
 ```
 
 ### Group
-Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget within a group widget.
+Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget or a split column widget within a group widget.
 
 Example:
 
@@ -933,6 +956,67 @@ Example:
       <<: *shared-properties
 ```
 
+### Split Column
+<!-- TODO: update -->
+Splits a full sized column in half, allowing you to place widgets side by side. This is converted to a single column on mobile devices or if not enough width is available. Widgets are defined using a `widgets` property exactly as you would on a page column.
+
+Example of a full page with an effective 4 column layout using two split column widgets inside of two full sized columns:
+
+<details>
+<summary>View config</summary>
+
+```yaml
+shared:
+  - &reddit-props
+    type: reddit
+    collapse-after: 4
+    show-thumbnails: true
+
+pages:
+  - name: Split Column Demo
+    width: wide
+    columns:
+      - size: full
+        widgets:
+          - type: split-column
+            widgets:
+              - subreddit: gaming
+                <<: *reddit-props
+              - subreddit: worldnews
+                <<: *reddit-props
+              - subreddit: lifeprotips
+                <<: *reddit-props
+                show-thumbnails: false
+              - subreddit: askreddit
+                <<: *reddit-props
+                show-thumbnails: false
+
+      - size: full
+        widgets:
+          - type: split-column
+            widgets:
+              - subreddit: todayilearned
+                <<: *reddit-props
+                collapse-after: 2
+              - subreddit: aww
+                <<: *reddit-props
+              - subreddit: science
+                <<: *reddit-props
+              - subreddit: showerthoughts
+                <<: *reddit-props
+                show-thumbnails: false
+```
+</details>
+
+<br>
+
+Preview:
+
+![](images/split-column-widget-preview.png)
+
+### Custom API
+<!-- TODO -->
+
 ### Extension
 Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP).
 
@@ -948,12 +1032,16 @@ Display a widget provided by an external source (3rd party). If you want to lear
 | Name | Type | Required | Default |
 | ---- | ---- | -------- | ------- |
 | url | string | yes | |
+| fallback-content-type | string | no | |
 | allow-potentially-dangerous-html | boolean | no | false |
 | parameters | key & value | no | |
 
 ##### `url`
 The URL of the extension.
 
+##### `fallback-content-type`
+Optionally specify the fallback content type of the extension if the URL does not return a valid `Widget-Content-Type` header. Currently the only supported value for this property is `html`.
+
 ##### `allow-potentially-dangerous-html`
 Whether to allow the extension to display HTML.
 
@@ -1065,11 +1153,19 @@ You can hover over the "ERROR" text to view more information.
 | Name | Type | Required | Default |
 | ---- | ---- | -------- | ------- |
 | sites | array | yes | |
+| style | string | no | |
 | show-failing-only | boolean | no | false |
 
 ##### `show-failing-only`
 Shows only a list of failing sites when set to `true`.
 
+##### `style`
+Used to change the appearance of the widget. Possible values are `compact`.
+
+Preview of `compact`:
+
+![](images/monitor-widget-compact-preview.png)
+
 ##### `sites`
 
 Properties for each site:
@@ -1082,6 +1178,7 @@ Properties for each site:
 | icon | string | no | |
 | allow-insecure | boolean | no | false |
 | same-tab | boolean | no | false |
+| alt-status-codes | array | no | |
 
 `title`
 
@@ -1097,7 +1194,7 @@ The URL which will be requested and its response will determine the status of th
 
 `icon`
 
-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). You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix:
+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). You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix:
 
 ```yaml
 icon: si:jellyfin
@@ -1107,7 +1204,7 @@ icon: si:adguard
 
 > [!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.
+> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
 
 `allow-insecure`
 
@@ -1117,6 +1214,15 @@ Whether to ignore invalid/self-signed certificates.
 
 Whether to open the link in the same or a new tab.
 
+`alt-status-codes`
+
+Status codes other than 200 that you want to return "OK".
+
+```yaml
+alt-status-codes:
+  - 403
+```
+
 ### Releases
 Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
 
@@ -1238,6 +1344,7 @@ Preview:
 | Name | Type | Required | Default |
 | ---- | ---- | -------- | ------- |
 | service | string | no | pihole |
+| allow-insecure | bool | no | false |
 | url | string | yes |  |
 | username | string | when service is `adguard` |  |
 | password | string | when service is `adguard` |  |
@@ -1247,6 +1354,9 @@ Preview:
 ##### `service`
 Either `adguard` or `pihole`.
 
+##### `allow-insecure`
+Whether to allow invalid/self-signed certificates when making the request to the service.
+
 ##### `url`
 The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
 
@@ -1375,7 +1485,7 @@ An array of groups which can optionally have a title and a custom color.
 
 `icon`
 
-URL pointing to an image. You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix:
+URL pointing to an image. You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix:
 
 ```yaml
 icon: si:gmail
@@ -1385,7 +1495,7 @@ 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.
+> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, 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`
 
@@ -1494,15 +1604,25 @@ Example:
 
 ```yaml
 - type: calendar
+  start-sunday: false
 ```
 
 Preview:
 
 ![](images/calendar-widget-preview.png)
 
+#### Properties
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| start-sunday | boolean | no | false |
+
+##### `start-sunday`
+Whether calendar weeks start on Sunday or Monday.
+
 > [!NOTE]
 >
-> There is currently no customizability available for the calendar. Extra features will be added in the future.
+> There is currently little customizability available for the calendar. Extra features will be added in the future.
 
 ### Markets
 Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
@@ -1539,7 +1659,7 @@ Preview:
 An array of markets for which to display information about.
 
 ##### `sort-by`
-By default the markets 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.
+By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `change` for descending order based on the stock's percentage change (e.g. 1% would be sorted higher than -1%) or `absolute-change` for descending order based on the stock's absolute price change (e.g. -1% would be sorted higher than +0.5%).
 
 ###### Properties for each stock
 | Name | Type | Required |
@@ -1674,3 +1794,75 @@ Example:
 ```
 
 Note the use of `|` after `source:`, this allows you to insert a multi-line string.
+
+### Docker Containers
+<!-- TODO: update -->
+The Docker widget allows you to monitor your Docker containers.
+To enable this feature, ensure that your setup provides access to the **docker.sock** file (also you may use a TCP connection).
+
+Add the following to your `docker-compose` or `docker run` command to enable the Docker widget:
+
+**Docker Example:**
+```bash
+docker run -d -p 8080:8080 \
+  -v ./glance.yml:/app/glance.yml \
+  -v /etc/timezone:/etc/timezone:ro \
+  -v /etc/localtime:/etc/localtime:ro \
+  -v /var/run/docker.sock:/var/run/docker.sock:ro \
+  glanceapp/glance
+```
+
+**Docker Compose Example:**
+```yaml
+services:
+  glance:
+    image: glanceapp/glance
+    volumes:
+      - ./glance.yml:/app/glance.yml
+      - /etc/timezone:/etc/timezone:ro
+      - /etc/localtime:/etc/localtime:ro
+      - /var/run/docker.sock:/var/run/docker.sock:ro
+    ports:
+      - 8080:8080
+    restart: unless-stopped
+```
+
+#### Configuration
+To integrate the Docker widget into your dashboard, include the following snippet in your `glance.yml` file:
+
+```yaml
+- type: docker
+  host-url: tcp://localhost:2375
+  cache: 1m
+```
+
+#### Properties
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| host-url | string | no | `unix:///var/run/docker.sock` |
+
+#### Leveraging Container Labels
+You can use container labels to control visibility, URLs, icons, and titles within the Docker widget. Add the following labels to your container configuration for enhanced customization:
+
+```yaml
+labels:
+  - "glance.enable=true"       # Enable or disable visibility of the container (default: true)
+  - "glance.title=Glance"      # Optional friendly name (defaults to container name)
+  - "glance.url=https://app.example.com"  # Optional URL associated with the container
+  - "glance.iconUrl=si:docker" # Optional URL to an image which will be used as the icon for the site
+
+```
+
+**Default Values:**
+
+| Name           | Default    |
+|----------------|------------|
+| glance.enable  | true       |
+| glance.title   | Container name |
+| glance.url     | (none)     |
+| glance.iconUrl | si:docker  |
+
+Preview:
+
+![](images/docker-widget-preview.png)

BIN
docs/images/docker-widget-preview.png


BIN
docs/images/monitor-widget-compact-preview.png


BIN
docs/images/split-column-widget-preview.png


+ 9 - 4
go.mod

@@ -1,19 +1,24 @@
 module github.com/glanceapp/glance
 
-go 1.22.5
+go 1.23.1
 
 require (
+	github.com/fsnotify/fsnotify v1.8.0
 	github.com/mmcdole/gofeed v1.3.0
-	golang.org/x/text v0.16.0
+	github.com/tidwall/gjson v1.18.0
+	golang.org/x/text v0.20.0
 	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
-	github.com/PuerkitoBio/goquery v1.9.2 // indirect
+	github.com/PuerkitoBio/goquery v1.10.0 // indirect
 	github.com/andybalholm/cascadia v1.3.2 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/mmcdole/goxpp v1.1.1 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
-	golang.org/x/net v0.27.0 // indirect
+	github.com/tidwall/match v1.1.1 // indirect
+	github.com/tidwall/pretty v1.2.1 // indirect
+	golang.org/x/net v0.31.0 // indirect
+	golang.org/x/sys v0.27.0 // indirect
 )

+ 18 - 7
go.sum

@@ -1,10 +1,12 @@
-github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
-github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
+github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
+github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
 github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
+github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@@ -23,6 +25,13 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -33,8 +42,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
-golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
-golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
+golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
+golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -45,6 +54,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
+golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -54,8 +65,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -64,4 +75,4 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 0 - 56
internal/assets/files.go

@@ -1,56 +0,0 @@
-package assets
-
-import (
-	"crypto/md5"
-	"embed"
-	"encoding/hex"
-	"io"
-	"io/fs"
-	"log/slog"
-	"strconv"
-	"time"
-)
-
-//go:embed static
-var _publicFS embed.FS
-
-//go:embed templates
-var _templateFS embed.FS
-
-var PublicFS, _ = fs.Sub(_publicFS, "static")
-var TemplateFS, _ = fs.Sub(_templateFS, "templates")
-
-func getFSHash(files fs.FS) string {
-	hash := md5.New()
-
-	err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
-		if err != nil {
-			return err
-		}
-
-		if d.IsDir() {
-			return nil
-		}
-
-		file, err := files.Open(path)
-
-		if err != nil {
-			return err
-		}
-
-		if _, err := io.Copy(hash, file); err != nil {
-			return err
-		}
-
-		return nil
-	})
-
-	if err == nil {
-		return hex.EncodeToString(hash.Sum(nil))[:10]
-	}
-
-	slog.Warn("Could not compute assets cache", "err", err)
-	return strconv.FormatInt(time.Now().Unix(), 10)
-}
-
-var PublicFSHash = getFSHash(PublicFS)

+ 0 - 109
internal/assets/templates.go

@@ -1,109 +0,0 @@
-package assets
-
-import (
-	"fmt"
-	"html/template"
-	"math"
-	"strconv"
-	"time"
-
-	"golang.org/x/text/language"
-	"golang.org/x/text/message"
-)
-
-var (
-	PageTemplate                  = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
-	PageContentTemplate           = compileTemplate("content.html")
-	CalendarTemplate              = compileTemplate("calendar.html", "widget-base.html")
-	ClockTemplate                 = compileTemplate("clock.html", "widget-base.html")
-	BookmarksTemplate             = compileTemplate("bookmarks.html", "widget-base.html")
-	IFrameTemplate                = compileTemplate("iframe.html", "widget-base.html")
-	WeatherTemplate               = compileTemplate("weather.html", "widget-base.html")
-	ForumPostsTemplate            = compileTemplate("forum-posts.html", "widget-base.html")
-	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")
-	ChangeDetectionTemplate       = compileTemplate("change-detection.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")
-	MarketsTemplate               = compileTemplate("markets.html", "widget-base.html")
-	RSSListTemplate               = compileTemplate("rss-list.html", "widget-base.html")
-	RSSDetailedListTemplate       = compileTemplate("rss-detailed-list.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")
-	SearchTemplate                = compileTemplate("search.html", "widget-base.html")
-	ExtensionTemplate             = compileTemplate("extension.html", "widget-base.html")
-	GroupTemplate                 = compileTemplate("group.html", "widget-base.html")
-	DNSStatsTemplate              = compileTemplate("dns-stats.html", "widget-base.html")
-)
-
-var globalTemplateFunctions = template.FuncMap{
-	"relativeTime":      relativeTimeSince,
-	"formatViewerCount": formatViewerCount,
-	"formatNumber":      intl.Sprint,
-	"absInt": func(i int) int {
-		return int(math.Abs(float64(i)))
-	},
-	"formatPrice": func(price float64) string {
-		return intl.Sprintf("%.2f", price)
-	},
-	"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
-		return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
-	},
-}
-
-func compileTemplate(primary string, dependencies ...string) *template.Template {
-	t, err := template.New(primary).
-		Funcs(globalTemplateFunctions).
-		ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
-
-	if err != nil {
-		panic(err)
-	}
-
-	return t
-}
-
-var intl = message.NewPrinter(language.English)
-
-func formatViewerCount(count int) string {
-	if count < 1_000 {
-		return strconv.Itoa(count)
-	}
-
-	if count < 10_000 {
-		return fmt.Sprintf("%.1fk", float64(count)/1_000)
-	}
-
-	if count < 1_000_000 {
-		return fmt.Sprintf("%dk", count/1_000)
-	}
-
-	return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
-}
-
-func relativeTimeSince(t time.Time) string {
-	delta := time.Since(t)
-
-	if delta < time.Minute {
-		return "1m"
-	}
-	if delta < time.Hour {
-		return fmt.Sprintf("%dm", delta/time.Minute)
-	}
-	if delta < 24*time.Hour {
-		return fmt.Sprintf("%dh", delta/time.Hour)
-	}
-	if delta < 30*24*time.Hour {
-		return fmt.Sprintf("%dd", delta/(24*time.Hour))
-	}
-	if delta < 12*30*24*time.Hour {
-		return fmt.Sprintf("%dmo", delta/(30*24*time.Hour))
-	}
-
-	return fmt.Sprintf("%dy", delta/(365*24*time.Hour))
-}

+ 0 - 14
internal/assets/templates/page-style-overrides.gotmpl

@@ -1,14 +0,0 @@
-<style>
-:root {
-    {{ if .App.Config.Theme.BackgroundColor }}
-    --bgh: {{ .App.Config.Theme.BackgroundColor.Hue }};
-    --bgs: {{ .App.Config.Theme.BackgroundColor.Saturation }}%;
-    --bgl: {{ .App.Config.Theme.BackgroundColor.Lightness }}%;
-    {{ end }}
-    {{ if ne 0.0 .App.Config.Theme.ContrastMultiplier }}--cm: {{ .App.Config.Theme.ContrastMultiplier }};{{ end }}
-    {{ if ne 0.0 .App.Config.Theme.TextSaturationMultiplier }}--tsm: {{ .App.Config.Theme.TextSaturationMultiplier }};{{ end }}
-    {{ if .App.Config.Theme.PrimaryColor }}--color-primary: {{ .App.Config.Theme.PrimaryColor.AsCSSValue }};{{ end }}
-    {{ if .App.Config.Theme.PositiveColor }}--color-positive: {{ .App.Config.Theme.PositiveColor.AsCSSValue }};{{ end }}
-    {{ if .App.Config.Theme.NegativeColor }}--color-negative: {{ .App.Config.Theme.NegativeColor.AsCSSValue }};{{ end }}
-}
-</style>

+ 0 - 120
internal/feed/adguard.go

@@ -1,120 +0,0 @@
-package feed
-
-import (
-	"net/http"
-	"strings"
-)
-
-type adguardStatsResponse struct {
-	TotalQueries      int              `json:"num_dns_queries"`
-	QueriesSeries     []int            `json:"dns_queries"`
-	BlockedQueries    int              `json:"num_blocked_filtering"`
-	BlockedSeries     []int            `json:"blocked_filtering"`
-	ResponseTime      float64          `json:"avg_processing_time"`
-	TopBlockedDomains []map[string]int `json:"top_blocked_domains"`
-}
-
-func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error) {
-	requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats"
-
-	request, err := http.NewRequest("GET", requestURL, nil)
-
-	if err != nil {
-		return nil, err
-	}
-
-	request.SetBasicAuth(username, password)
-
-	responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request)
-
-	if err != nil {
-		return nil, err
-	}
-
-	var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
-
-	stats := &DNSStats{
-		TotalQueries:      responseJson.TotalQueries,
-		BlockedQueries:    responseJson.BlockedQueries,
-		ResponseTime:      int(responseJson.ResponseTime * 1000),
-		TopBlockedDomains: make([]DNSStatsBlockedDomain, 0, topBlockedDomainsCount),
-	}
-
-	if stats.TotalQueries <= 0 {
-		return stats, nil
-	}
-
-	stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
-
-	for i := 0; i < topBlockedDomainsCount; i++ {
-		domain := responseJson.TopBlockedDomains[i]
-		var firstDomain string
-
-		for k := range domain {
-			firstDomain = k
-			break
-		}
-
-		if firstDomain == "" {
-			continue
-		}
-
-		stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{
-			Domain: firstDomain,
-		})
-
-		if stats.BlockedQueries > 0 {
-			stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100)
-		}
-	}
-
-	queriesSeries := responseJson.QueriesSeries
-	blockedSeries := responseJson.BlockedSeries
-
-	const bars = 8
-	const hoursSpan = 24
-	const hoursPerBar int = hoursSpan / bars
-
-	if len(queriesSeries) > hoursSpan {
-		queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
-	} else if len(queriesSeries) < hoursSpan {
-		queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
-	}
-
-	if len(blockedSeries) > hoursSpan {
-		blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
-	} else if len(blockedSeries) < hoursSpan {
-		blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
-	}
-
-	maxQueriesInSeries := 0
-
-	for i := 0; i < bars; i++ {
-		queries := 0
-		blocked := 0
-
-		for j := 0; j < hoursPerBar; j++ {
-			queries += queriesSeries[i*hoursPerBar+j]
-			blocked += blockedSeries[i*hoursPerBar+j]
-		}
-
-		stats.Series[i] = DNSStatsSeries{
-			Queries: queries,
-			Blocked: blocked,
-		}
-
-		if queries > 0 {
-			stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
-		}
-
-		if queries > maxQueriesInSeries {
-			maxQueriesInSeries = queries
-		}
-	}
-
-	for i := 0; i < bars; i++ {
-		stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
-	}
-
-	return stats, nil
-}

+ 0 - 53
internal/feed/calendar.go

@@ -1,53 +0,0 @@
-package feed
-
-import "time"
-
-// TODO: very inflexible, refactor to allow more customizability
-// TODO: allow changing first day of week
-// TODO: allow changing between showing the previous and next week and the entire month
-func NewCalendar(now time.Time) *Calendar {
-	year, week := now.ISOWeek()
-	weekday := now.Weekday()
-
-	if weekday == 0 {
-		weekday = 7
-	}
-
-	currentMonthDays := daysInMonth(now.Month(), year)
-
-	var previousMonthDays int
-
-	if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
-		previousMonthDays = daysInMonth(12, year-1)
-	} else {
-		previousMonthDays = daysInMonth(previousMonthNumber, year)
-	}
-
-	startDaysFrom := now.Day() - int(weekday+6)
-
-	days := make([]int, 21)
-
-	for i := 0; i < 21; i++ {
-		day := startDaysFrom + i
-
-		if day < 1 {
-			day = previousMonthDays + day
-		} else if day > currentMonthDays {
-			day = day - currentMonthDays
-		}
-
-		days[i] = day
-	}
-
-	return &Calendar{
-		CurrentDay:        now.Day(),
-		CurrentWeekNumber: week,
-		CurrentMonthName:  now.Month().String(),
-		CurrentYear:       year,
-		Days:              days,
-	}
-}
-
-func daysInMonth(m time.Month, year int) int {
-	return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
-}

+ 0 - 39
internal/feed/codeberg.go

@@ -1,39 +0,0 @@
-package feed
-
-import (
-	"fmt"
-	"net/http"
-)
-
-type codebergReleaseResponseJson struct {
-	TagName     string `json:"tag_name"`
-	PublishedAt string `json:"published_at"`
-	HtmlUrl     string `json:"html_url"`
-}
-
-func fetchLatestCodebergRelease(request *ReleaseRequest) (*AppRelease, error) {
-	httpRequest, err := http.NewRequest(
-		"GET",
-		fmt.Sprintf(
-			"https://codeberg.org/api/v1/repos/%s/releases/latest",
-			request.Repository,
-		),
-		nil,
-	)
-	if err != nil {
-		return nil, err
-	}
-
-	response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest)
-
-	if err != nil {
-		return nil, err
-	}
-	return &AppRelease{
-		Source:       ReleaseSourceCodeberg,
-		Name:         request.Repository,
-		Version:      normalizeVersionFormat(response.TagName),
-		NotesUrl:     response.HtmlUrl,
-		TimeReleased: parseRFC3339Time(response.PublishedAt),
-	}, nil
-}

+ 0 - 102
internal/feed/dockerhub.go

@@ -1,102 +0,0 @@
-package feed
-
-import (
-	"fmt"
-	"net/http"
-	"strings"
-)
-
-type dockerHubRepositoryTagsResponse struct {
-	Results []dockerHubRepositoryTagResponse `json:"results"`
-}
-
-type dockerHubRepositoryTagResponse struct {
-	Name       string `json:"name"`
-	LastPushed string `json:"tag_last_pushed"`
-}
-
-const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s"
-const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s"
-const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags"
-const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s"
-
-func fetchLatestDockerHubRelease(request *ReleaseRequest) (*AppRelease, error) {
-
-	nameParts := strings.Split(request.Repository, "/")
-
-	if len(nameParts) > 2 {
-		return nil, fmt.Errorf("invalid repository name: %s", request.Repository)
-	} else if len(nameParts) == 1 {
-		nameParts = []string{"library", nameParts[0]}
-	}
-
-	tagParts := strings.SplitN(nameParts[1], ":", 2)
-
-	var requestURL string
-
-	if len(tagParts) == 2 {
-		requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1])
-	} else {
-		requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1])
-	}
-
-	httpRequest, err := http.NewRequest("GET", requestURL, nil)
-
-	if err != nil {
-		return nil, err
-	}
-
-	if request.Token != nil {
-		httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
-	}
-
-	var tag *dockerHubRepositoryTagResponse
-
-	if len(tagParts) == 1 {
-		response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest)
-
-		if err != nil {
-			return nil, err
-		}
-
-		if len(response.Results) == 0 {
-			return nil, fmt.Errorf("no tags found for repository: %s", request.Repository)
-		}
-
-		tag = &response.Results[0]
-	} else {
-		response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest)
-
-		if err != nil {
-			return nil, err
-		}
-
-		tag = &response
-	}
-
-	var repo string
-	var displayName string
-	var notesURL string
-
-	if len(tagParts) == 1 {
-		repo = nameParts[1]
-	} else {
-		repo = tagParts[0]
-	}
-
-	if nameParts[0] == "library" {
-		displayName = repo
-		notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name)
-	} else {
-		displayName = nameParts[0] + "/" + repo
-		notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name)
-	}
-
-	return &AppRelease{
-		Source:       ReleaseSourceDockerHub,
-		NotesUrl:     notesURL,
-		Name:         displayName,
-		Version:      tag.Name,
-		TimeReleased: parseRFC3339Time(tag.LastPushed),
-	}, nil
-}

+ 0 - 97
internal/feed/extension.go

@@ -1,97 +0,0 @@
-package feed
-
-import (
-	"fmt"
-	"html"
-	"html/template"
-	"io"
-	"log/slog"
-	"net/http"
-	"net/url"
-)
-
-type ExtensionType int
-
-const (
-	ExtensionContentHTML    ExtensionType = iota
-	ExtensionContentUnknown               = iota
-)
-
-var ExtensionStringToType = map[string]ExtensionType{
-	"html": ExtensionContentHTML,
-}
-
-const (
-	ExtensionHeaderTitle       = "Widget-Title"
-	ExtensionHeaderContentType = "Widget-Content-Type"
-)
-
-type ExtensionRequestOptions struct {
-	URL        string            `yaml:"url"`
-	Parameters map[string]string `yaml:"parameters"`
-	AllowHtml  bool              `yaml:"allow-potentially-dangerous-html"`
-}
-
-type Extension struct {
-	Title   string
-	Content template.HTML
-}
-
-func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML {
-	switch contentType {
-	case ExtensionContentHTML:
-		if options.AllowHtml {
-			return template.HTML(content)
-		}
-
-		fallthrough
-	default:
-		return template.HTML(html.EscapeString(string(content)))
-	}
-}
-
-func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
-	request, _ := http.NewRequest("GET", options.URL, nil)
-
-	query := url.Values{}
-
-	for key, value := range options.Parameters {
-		query.Set(key, value)
-	}
-
-	request.URL.RawQuery = query.Encode()
-
-	response, err := http.DefaultClient.Do(request)
-
-	if err != nil {
-		slog.Error("failed fetching extension", "error", err, "url", options.URL)
-		return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err)
-	}
-
-	defer response.Body.Close()
-
-	body, err := io.ReadAll(response.Body)
-
-	if err != nil {
-		slog.Error("failed reading response body of extension", "error", err, "url", options.URL)
-		return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err)
-	}
-
-	extension := Extension{}
-
-	if response.Header.Get(ExtensionHeaderTitle) == "" {
-		extension.Title = "Extension"
-	} else {
-		extension.Title = response.Header.Get(ExtensionHeaderTitle)
-	}
-
-	contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
-
-	if !ok {
-		contentType = ExtensionContentUnknown
-	}
-
-	extension.Content = convertExtensionContent(options, body, contentType)
-
-	return extension, nil
-}

+ 0 - 48
internal/feed/gitlab.go

@@ -1,48 +0,0 @@
-package feed
-
-import (
-	"fmt"
-	"net/http"
-	"net/url"
-)
-
-type gitlabReleaseResponseJson struct {
-	TagName    string `json:"tag_name"`
-	ReleasedAt string `json:"released_at"`
-	Links      struct {
-		Self string `json:"self"`
-	} `json:"_links"`
-}
-
-func fetchLatestGitLabRelease(request *ReleaseRequest) (*AppRelease, error) {
-	httpRequest, err := http.NewRequest(
-		"GET",
-		fmt.Sprintf(
-			"https://gitlab.com/api/v4/projects/%s/releases/permalink/latest",
-			url.QueryEscape(request.Repository),
-		),
-		nil,
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	if request.Token != nil {
-		httpRequest.Header.Add("PRIVATE-TOKEN", *request.Token)
-	}
-
-	response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return &AppRelease{
-		Source:       ReleaseSourceGitlab,
-		Name:         request.Repository,
-		Version:      normalizeVersionFormat(response.TagName),
-		NotesUrl:     response.Links.Self,
-		TimeReleased: parseRFC3339Time(response.ReleasedAt),
-	}, nil
-}

+ 0 - 98
internal/feed/hacker-news.go

@@ -1,98 +0,0 @@
-package feed
-
-import (
-	"fmt"
-	"log/slog"
-	"net/http"
-	"strconv"
-	"strings"
-	"time"
-)
-
-type hackerNewsPostResponseJson struct {
-	Id           int    `json:"id"`
-	Score        int    `json:"score"`
-	Title        string `json:"title"`
-	TargetUrl    string `json:"url,omitempty"`
-	CommentCount int    `json:"descendants"`
-	TimePosted   int64  `json:"time"`
-}
-
-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 {
-		return nil, fmt.Errorf("%w: could not fetch list of post IDs", ErrNoContent)
-	}
-
-	return response, nil
-}
-
-func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (ForumPosts, error) {
-	requests := make([]*http.Request, len(postIds))
-
-	for i, id := range postIds {
-		request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
-		requests[i] = request
-	}
-
-	task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient)
-	job := newJob(task, requests).withWorkers(30)
-	results, errs, err := workerPoolDo(job)
-
-	if err != nil {
-		return nil, err
-	}
-
-	posts := make(ForumPosts, 0, len(postIds))
-
-	for i := range results {
-		if errs[i] != nil {
-			slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
-			continue
-		}
-
-		var commentsUrl string
-
-		if commentsUrlTemplate == "" {
-			commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
-		} else {
-			commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
-		}
-
-		posts = append(posts, ForumPost{
-			Title:           results[i].Title,
-			DiscussionUrl:   commentsUrl,
-			TargetUrl:       results[i].TargetUrl,
-			TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
-			CommentCount:    results[i].CommentCount,
-			Score:           results[i].Score,
-			TimePosted:      time.Unix(results[i].TimePosted, 0),
-		})
-	}
-
-	if len(posts) == 0 {
-		return nil, ErrNoContent
-	}
-
-	if len(posts) != len(postIds) {
-		return posts, fmt.Errorf("%w could not fetch some hacker news posts", ErrPartialContent)
-	}
-
-	return posts, nil
-}
-
-func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) {
-	postIds, err := getHackerNewsPostIds(sort)
-
-	if err != nil {
-		return nil, err
-	}
-
-	if len(postIds) > limit {
-		postIds = postIds[:limit]
-	}
-
-	return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
-}

+ 0 - 91
internal/feed/lobsters.go

@@ -1,91 +0,0 @@
-package feed
-
-import (
-	"net/http"
-	"strings"
-	"time"
-)
-
-type lobstersPostResponseJson struct {
-	CreatedAt    string   `json:"created_at"`
-	Title        string   `json:"title"`
-	URL          string   `json:"url"`
-	Score        int      `json:"score"`
-	CommentCount int      `json:"comment_count"`
-	CommentsURL  string   `json:"comments_url"`
-	Tags         []string `json:"tags"`
-}
-
-type lobstersFeedResponseJson []lobstersPostResponseJson
-
-func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
-	request, err := http.NewRequest("GET", feedUrl, nil)
-
-	if err != nil {
-		return nil, err
-	}
-
-	feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request)
-
-	if err != nil {
-		return nil, err
-	}
-
-	posts := make(ForumPosts, 0, len(feed))
-
-	for i := range feed {
-		createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
-
-		posts = append(posts, ForumPost{
-			Title:           feed[i].Title,
-			DiscussionUrl:   feed[i].CommentsURL,
-			TargetUrl:       feed[i].URL,
-			TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
-			CommentCount:    feed[i].CommentCount,
-			Score:           feed[i].Score,
-			TimePosted:      createdAt,
-			Tags:            feed[i].Tags,
-		})
-	}
-
-	if len(posts) == 0 {
-		return nil, ErrNoContent
-	}
-
-	return posts, nil
-}
-
-func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
-	var feedUrl string
-
-	if customURL != "" {
-		feedUrl = customURL
-	} else {
-		if instanceURL != "" {
-			instanceURL = strings.TrimRight(instanceURL, "/") + "/"
-		} else {
-			instanceURL = "https://lobste.rs/"
-		}
-
-		if sortBy == "hot" {
-			sortBy = "hottest"
-		} else if sortBy == "new" {
-			sortBy = "newest"
-		}
-
-		if len(tags) == 0 {
-			feedUrl = instanceURL + sortBy + ".json"
-		} else {
-			tags := strings.Join(tags, ",")
-			feedUrl = instanceURL + "t/" + tags + ".json"
-		}
-	}
-
-	posts, err := getLobstersPostsFromFeed(feedUrl)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return posts, nil
-}

+ 0 - 77
internal/feed/monitor.go

@@ -1,77 +0,0 @@
-package feed
-
-import (
-	"context"
-	"errors"
-	"net/http"
-	"time"
-)
-
-type SiteStatusRequest struct {
-	URL           string `yaml:"url"`
-	CheckURL      string `yaml:"check-url"`
-	AllowInsecure bool   `yaml:"allow-insecure"`
-}
-
-type SiteStatus struct {
-	Code         int
-	TimedOut     bool
-	ResponseTime time.Duration
-	Error        error
-}
-
-func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
-	var url string
-	if statusRequest.CheckURL != "" {
-		url = statusRequest.CheckURL
-	} else {
-		url = statusRequest.URL
-	}
-	request, err := http.NewRequest(http.MethodGet, url, nil)
-
-	if err != nil {
-		return SiteStatus{
-			Error: err,
-		}, nil
-	}
-
-	ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
-	defer cancel()
-	request = request.WithContext(ctx)
-	requestSentAt := time.Now()
-	var response *http.Response
-
-	if !statusRequest.AllowInsecure {
-		response, err = defaultClient.Do(request)
-	} else {
-		response, err = defaultInsecureClient.Do(request)
-	}
-
-	status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
-
-	if err != nil {
-		if errors.Is(err, context.DeadlineExceeded) {
-			status.TimedOut = true
-		}
-
-		status.Error = err
-		return status, nil
-	}
-
-	defer response.Body.Close()
-
-	status.Code = response.StatusCode
-
-	return status, nil
-}
-
-func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
-	job := newJob(getSiteStatusTask, requests).withWorkers(20)
-	results, _, err := workerPoolDo(job)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return results, nil
-}

+ 0 - 136
internal/feed/pihole.go

@@ -1,136 +0,0 @@
-package feed
-
-import (
-	"encoding/json"
-	"errors"
-	"log/slog"
-	"net/http"
-	"sort"
-	"strings"
-)
-
-type piholeStatsResponse struct {
-	TotalQueries      int                     `json:"dns_queries_today"`
-	QueriesSeries     map[int64]int           `json:"domains_over_time"`
-	BlockedQueries    int                     `json:"ads_blocked_today"`
-	BlockedSeries     map[int64]int           `json:"ads_over_time"`
-	BlockedPercentage float64                 `json:"ads_percentage_today"`
-	TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
-	DomainsBlocked    int                     `json:"domains_being_blocked"`
-}
-
-// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
-// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
-type piholeTopBlockedDomains map[string]int
-
-func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
-	// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
-	// because of the UnmarshalJSON method getting called recursively
-	temp := make(map[string]int)
-
-	err := json.Unmarshal(data, &temp)
-
-	if err != nil {
-		*p = make(piholeTopBlockedDomains)
-	} else {
-		*p = temp
-	}
-
-	return nil
-}
-
-func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) {
-	if token == "" {
-		return nil, errors.New("missing API token")
-	}
-
-	requestURL := strings.TrimRight(instanceURL, "/") +
-		"/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
-
-	request, err := http.NewRequest("GET", requestURL, nil)
-
-	if err != nil {
-		return nil, err
-	}
-
-	responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request)
-
-	if err != nil {
-		return nil, err
-	}
-
-	stats := &DNSStats{
-		TotalQueries:   responseJson.TotalQueries,
-		BlockedQueries: responseJson.BlockedQueries,
-		BlockedPercent: int(responseJson.BlockedPercentage),
-		DomainsBlocked: responseJson.DomainsBlocked,
-	}
-
-	if len(responseJson.TopBlockedDomains) > 0 {
-		domains := make([]DNSStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains))
-
-		for domain, count := range responseJson.TopBlockedDomains {
-			domains = append(domains, DNSStatsBlockedDomain{
-				Domain:         domain,
-				PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100),
-			})
-		}
-
-		sort.Slice(domains, func(a, b int) bool {
-			return domains[a].PercentBlocked > domains[b].PercentBlocked
-		})
-
-		stats.TopBlockedDomains = domains[:min(len(domains), 5)]
-	}
-
-	// Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
-	if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 {
-		slog.Warn(
-			"DNS stats for pihole: did not get expected 144 data points",
-			"len(queries)", len(responseJson.QueriesSeries),
-			"len(blocked)", len(responseJson.BlockedSeries),
-		)
-		return stats, nil
-	}
-
-	var lowestTimestamp int64 = 0
-
-	for timestamp := range responseJson.QueriesSeries {
-		if lowestTimestamp == 0 || timestamp < lowestTimestamp {
-			lowestTimestamp = timestamp
-		}
-	}
-
-	maxQueriesInSeries := 0
-
-	for i := 0; i < 8; i++ {
-		queries := 0
-		blocked := 0
-
-		for j := 0; j < 18; j++ {
-			index := lowestTimestamp + int64(i*10800+j*600)
-
-			queries += responseJson.QueriesSeries[index]
-			blocked += responseJson.BlockedSeries[index]
-		}
-
-		if queries > maxQueriesInSeries {
-			maxQueriesInSeries = queries
-		}
-
-		stats.Series[i] = DNSStatsSeries{
-			Queries: queries,
-			Blocked: blocked,
-		}
-
-		if queries > 0 {
-			stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
-		}
-	}
-
-	for i := 0; i < 8; i++ {
-		stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
-	}
-
-	return stats, nil
-}

+ 0 - 241
internal/feed/primitives.go

@@ -1,241 +0,0 @@
-package feed
-
-import (
-	"math"
-	"sort"
-	"time"
-)
-
-type ForumPost struct {
-	Title           string
-	DiscussionUrl   string
-	TargetUrl       string
-	TargetUrlDomain string
-	ThumbnailUrl    string
-	CommentCount    int
-	Score           int
-	Engagement      float64
-	TimePosted      time.Time
-	Tags            []string
-	IsCrosspost     bool
-}
-
-type ForumPosts []ForumPost
-
-type Calendar struct {
-	CurrentDay        int
-	CurrentWeekNumber int
-	CurrentMonthName  string
-	CurrentYear       int
-	Days              []int
-}
-
-type Weather struct {
-	Temperature         int
-	ApparentTemperature int
-	WeatherCode         int
-	CurrentColumn       int
-	SunriseColumn       int
-	SunsetColumn        int
-	Columns             []weatherColumn
-}
-
-type AppRelease struct {
-	Source        ReleaseSource
-	SourceIconURL string
-	Name          string
-	Version       string
-	NotesUrl      string
-	TimeReleased  time.Time
-	Downvotes     int
-}
-
-type AppReleases []AppRelease
-
-type Video struct {
-	ThumbnailUrl string
-	Title        string
-	Url          string
-	Author       string
-	AuthorUrl    string
-	TimePosted   time.Time
-}
-
-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 DNSStats struct {
-	TotalQueries      int
-	BlockedQueries    int
-	BlockedPercent    int
-	ResponseTime      int
-	DomainsBlocked    int
-	Series            [8]DNSStatsSeries
-	TopBlockedDomains []DNSStatsBlockedDomain
-}
-
-type DNSStatsSeries struct {
-	Queries        int
-	Blocked        int
-	PercentTotal   int
-	PercentBlocked int
-}
-
-type DNSStatsBlockedDomain struct {
-	Domain         string
-	PercentBlocked int
-}
-
-type MarketRequest struct {
-	Name       string `yaml:"name"`
-	Symbol     string `yaml:"symbol"`
-	ChartLink  string `yaml:"chart-link"`
-	SymbolLink string `yaml:"symbol-link"`
-}
-
-type Market struct {
-	MarketRequest
-	Currency       string  `yaml:"-"`
-	Price          float64 `yaml:"-"`
-	PercentChange  float64 `yaml:"-"`
-	SvgChartPoints string  `yaml:"-"`
-}
-
-type Markets []Market
-
-func (t Markets) SortByAbsChange() {
-	sort.Slice(t, func(i, j int) bool {
-		return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
-	})
-}
-
-var weatherCodeTable = map[int]string{
-	0:  "Clear Sky",
-	1:  "Mainly Clear",
-	2:  "Partly Cloudy",
-	3:  "Overcast",
-	45: "Fog",
-	48: "Rime Fog",
-	51: "Drizzle",
-	53: "Drizzle",
-	55: "Drizzle",
-	56: "Drizzle",
-	57: "Drizzle",
-	61: "Rain",
-	63: "Moderate Rain",
-	65: "Heavy Rain",
-	66: "Freezing Rain",
-	67: "Freezing Rain",
-	71: "Snow",
-	73: "Moderate Snow",
-	75: "Heavy Snow",
-	77: "Snow Grains",
-	80: "Rain",
-	81: "Moderate Rain",
-	82: "Heavy Rain",
-	85: "Snow",
-	86: "Snow",
-	95: "Thunderstorm",
-	96: "Thunderstorm",
-	99: "Thunderstorm",
-}
-
-func (w *Weather) WeatherCodeAsString() string {
-	if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok {
-		return weatherCode
-	}
-
-	return ""
-}
-
-const depreciatePostsOlderThanHours = 7
-const maxDepreciation = 0.9
-const maxDepreciationAfterHours = 24
-
-func (p ForumPosts) CalculateEngagement() {
-	var totalComments int
-	var totalScore int
-
-	for i := range p {
-		totalComments += p[i].CommentCount
-		totalScore += p[i].Score
-	}
-
-	numberOfPosts := float64(len(p))
-	averageComments := float64(totalComments) / numberOfPosts
-	averageScore := float64(totalScore) / numberOfPosts
-
-	for i := range p {
-		p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2
-
-		elapsed := time.Since(p[i].TimePosted)
-
-		if elapsed < time.Hour*depreciatePostsOlderThanHours {
-			continue
-		}
-
-		p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation
-	}
-}
-
-func (p ForumPosts) SortByEngagement() {
-	sort.Slice(p, func(i, j int) bool {
-		return p[i].Engagement > p[j].Engagement
-	})
-}
-
-func (s *ForumPost) HasTargetUrl() bool {
-	return s.TargetUrl != ""
-}
-
-func (p ForumPosts) FilterPostedBefore(postedBefore time.Duration) []ForumPost {
-	recent := make([]ForumPost, 0, len(p))
-
-	for i := range p {
-		if time.Since(p[i].TimePosted) < postedBefore {
-			recent = append(recent, p[i])
-		}
-	}
-
-	return recent
-}
-
-func (r AppReleases) SortByNewest() AppReleases {
-	sort.Slice(r, func(i, j int) bool {
-		return r[i].TimeReleased.After(r[j].TimeReleased)
-	})
-
-	return r
-}
-
-func (v Videos) SortByNewest() Videos {
-	sort.Slice(v, func(i, j int) bool {
-		return v[i].TimePosted.After(v[j].TimePosted)
-	})
-
-	return v
-}

+ 0 - 72
internal/feed/releases.go

@@ -1,72 +0,0 @@
-package feed
-
-import (
-	"errors"
-	"fmt"
-	"log/slog"
-)
-
-type ReleaseSource string
-
-const (
-	ReleaseSourceCodeberg  ReleaseSource = "codeberg"
-	ReleaseSourceGithub    ReleaseSource = "github"
-	ReleaseSourceGitlab    ReleaseSource = "gitlab"
-	ReleaseSourceDockerHub ReleaseSource = "dockerhub"
-)
-
-type ReleaseRequest struct {
-	Source     ReleaseSource
-	Repository string
-	Token      *string
-}
-
-func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) {
-	job := newJob(fetchLatestReleaseTask, requests).withWorkers(20)
-	results, errs, err := workerPoolDo(job)
-
-	if err != nil {
-		return nil, err
-	}
-
-	var failed int
-
-	releases := make(AppReleases, 0, len(requests))
-
-	for i := range results {
-		if errs[i] != nil {
-			failed++
-			slog.Error("Failed to fetch release", "source", requests[i].Source, "repository", requests[i].Repository, "error", errs[i])
-			continue
-		}
-
-		releases = append(releases, *results[i])
-	}
-
-	if failed == len(requests) {
-		return nil, ErrNoContent
-	}
-
-	releases.SortByNewest()
-
-	if failed > 0 {
-		return releases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
-	}
-
-	return releases, nil
-}
-
-func fetchLatestReleaseTask(request *ReleaseRequest) (*AppRelease, error) {
-	switch request.Source {
-	case ReleaseSourceCodeberg:
-		return fetchLatestCodebergRelease(request)
-	case ReleaseSourceGithub:
-		return fetchLatestGithubRelease(request)
-	case ReleaseSourceGitlab:
-		return fetchLatestGitLabRelease(request)
-	case ReleaseSourceDockerHub:
-		return fetchLatestDockerHubRelease(request)
-	}
-
-	return nil, errors.New("unsupported source")
-}

+ 0 - 104
internal/feed/yahoo.go

@@ -1,104 +0,0 @@
-package feed
-
-import (
-	"fmt"
-	"log/slog"
-	"net/http"
-)
-
-type marketResponseJson struct {
-	Chart struct {
-		Result []struct {
-			Meta struct {
-				Currency           string  `json:"currency"`
-				Symbol             string  `json:"symbol"`
-				RegularMarketPrice float64 `json:"regularMarketPrice"`
-				ChartPreviousClose float64 `json:"chartPreviousClose"`
-			} `json:"meta"`
-			Indicators struct {
-				Quote []struct {
-					Close []float64 `json:"close,omitempty"`
-				} `json:"quote"`
-			} `json:"indicators"`
-		} `json:"result"`
-	} `json:"chart"`
-}
-
-// TODO: allow changing chart time frame
-const marketChartDays = 21
-
-func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
-	requests := make([]*http.Request, 0, len(marketRequests))
-
-	for i := range marketRequests {
-		request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
-		requests = append(requests, request)
-	}
-
-	job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
-	responses, errs, err := workerPoolDo(job)
-
-	if err != nil {
-		return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
-	}
-
-	markets := make(Markets, 0, len(responses))
-	var failed int
-
-	for i := range responses {
-		if errs[i] != nil {
-			failed++
-			slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
-			continue
-		}
-
-		response := responses[i]
-
-		if len(response.Chart.Result) == 0 {
-			failed++
-			slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
-			continue
-		}
-
-		prices := response.Chart.Result[0].Indicators.Quote[0].Close
-
-		if len(prices) > marketChartDays {
-			prices = prices[len(prices)-marketChartDays:]
-		}
-
-		previous := response.Chart.Result[0].Meta.RegularMarketPrice
-
-		if len(prices) >= 2 && prices[len(prices)-2] != 0 {
-			previous = prices[len(prices)-2]
-		}
-
-		points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
-
-		currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
-
-		if !exists {
-			currency = response.Chart.Result[0].Meta.Currency
-		}
-
-		markets = append(markets, Market{
-			MarketRequest: marketRequests[i],
-			Price:         response.Chart.Result[0].Meta.RegularMarketPrice,
-			Currency:      currency,
-			PercentChange: percentChange(
-				response.Chart.Result[0].Meta.RegularMarketPrice,
-				previous,
-			),
-			SvgChartPoints: points,
-		})
-	}
-
-	if len(markets) == 0 {
-		return nil, ErrNoContent
-	}
-
-	if failed > 0 {
-		return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed)
-	}
-
-	return markets, nil
-}

+ 0 - 115
internal/feed/youtube.go

@@ -1,115 +0,0 @@
-package feed
-
-import (
-	"fmt"
-	"log/slog"
-	"net/http"
-	"net/url"
-	"strings"
-	"time"
-)
-
-type youtubeFeedResponseXml struct {
-	Channel     string `xml:"author>name"`
-	ChannelLink string `xml:"author>uri"`
-	Videos      []struct {
-		Title     string `xml:"title"`
-		Published string `xml:"published"`
-		Link      struct {
-			Href string `xml:"href,attr"`
-		} `xml:"link"`
-
-		Group struct {
-			Thumbnail struct {
-				Url string `xml:"url,attr"`
-			} `xml:"http://search.yahoo.com/mrss/ thumbnail"`
-		} `xml:"http://search.yahoo.com/mrss/ group"`
-	} `xml:"entry"`
-}
-
-func parseYoutubeFeedTime(t string) time.Time {
-	parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t)
-
-	if err != nil {
-		return time.Now()
-	}
-
-	return parsedTime
-}
-
-func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) {
-	requests := make([]*http.Request, 0, len(channelIds))
-
-	for i := range channelIds {
-		var feedUrl string
-		if !includeShorts && strings.HasPrefix(channelIds[i], "UC") {
-			playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1)
-			feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId
-		} else {
-			feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i]
-		}
-
-		request, _ := http.NewRequest("GET", feedUrl, nil)
-		requests = append(requests, request)
-	}
-
-	job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30)
-
-	responses, errs, err := workerPoolDo(job)
-
-	if err != nil {
-		return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
-	}
-
-	videos := make(Videos, 0, len(channelIds)*15)
-
-	var failed int
-
-	for i := range responses {
-		if errs[i] != nil {
-			failed++
-			slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i])
-			continue
-		}
-
-		response := responses[i]
-
-		for j := range response.Videos {
-			video := &response.Videos[j]
-			var videoUrl string
-
-			if videoUrlTemplate == "" {
-				videoUrl = video.Link.Href
-			} else {
-				parsedUrl, err := url.Parse(video.Link.Href)
-
-				if err == nil {
-					videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v"))
-				} else {
-					videoUrl = "#"
-				}
-			}
-
-			videos = append(videos, Video{
-				ThumbnailUrl: video.Group.Thumbnail.Url,
-				Title:        video.Title,
-				Url:          videoUrl,
-				Author:       response.Channel,
-				AuthorUrl:    response.ChannelLink + "/videos",
-				TimePosted:   parseYoutubeFeedTime(video.Published),
-			})
-		}
-	}
-
-	if len(videos) == 0 {
-		return nil, ErrNoContent
-	}
-
-	videos.SortByNewest()
-
-	if failed > 0 {
-		return videos, fmt.Errorf("%w: missing videos from %d channels", ErrPartialContent, failed)
-	}
-
-	return videos, nil
-}

+ 41 - 16
internal/glance/cli.go

@@ -2,41 +2,66 @@ package glance
 
 import (
 	"flag"
+	"fmt"
 	"os"
+	"strings"
 )
 
-type CliIntent uint8
+type cliIntent uint8
 
 const (
-	CliIntentServe       CliIntent = iota
-	CliIntentCheckConfig           = iota
+	cliIntentServe          cliIntent = iota
+	cliIntentConfigValidate           = iota
+	cliIntentConfigPrint              = iota
+	cliIntentDiagnose                 = iota
 )
 
-type CliOptions struct {
-	Intent     CliIntent
-	ConfigPath string
+type cliOptions struct {
+	intent     cliIntent
+	configPath string
 }
 
-func ParseCliOptions() (*CliOptions, error) {
+func parseCliOptions() (*cliOptions, error) {
 	flags := flag.NewFlagSet("", flag.ExitOnError)
+	flags.Usage = func() {
+		fmt.Println("Usage: glance [options] command")
 
-	checkConfig := flags.Bool("check-config", false, "Check whether the config is valid")
-	configPath := flags.String("config", "glance.yml", "Set config path")
+		fmt.Println("\nOptions:")
+		flags.PrintDefaults()
 
+		fmt.Println("\nCommands:")
+		fmt.Println("  config:validate     Validate the config file")
+		fmt.Println("  config:print        Print the parsed config file with embedded includes")
+		fmt.Println("  diagnose            Run diagnostic checks")
+	}
+	configPath := flags.String("config", "glance.yml", "Set config path")
 	err := flags.Parse(os.Args[1:])
-
 	if err != nil {
 		return nil, err
 	}
 
-	intent := CliIntentServe
+	var intent cliIntent
+	var args = flags.Args()
+	unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
 
-	if *checkConfig {
-		intent = CliIntentCheckConfig
+	if len(args) == 0 {
+		intent = cliIntentServe
+	} else if len(args) == 1 {
+		if args[0] == "config:validate" {
+			intent = cliIntentConfigValidate
+		} else if args[0] == "config:print" {
+			intent = cliIntentConfigPrint
+		} else if args[0] == "diagnose" {
+			intent = cliIntentDiagnose
+		} else {
+			return nil, unknownCommandErr
+		}
+	} else {
+		return nil, unknownCommandErr
 	}
 
-	return &CliOptions{
-		Intent:     intent,
-		ConfigPath: *configPath,
+	return &cliOptions{
+		intent:     intent,
+		configPath: *configPath,
 	}, nil
 }

+ 232 - 0
internal/glance/config-fields.go

@@ -0,0 +1,232 @@
+package glance
+
+import (
+	"fmt"
+	"html/template"
+	"os"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+
+	"gopkg.in/yaml.v3"
+)
+
+var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
+
+const (
+	hslHueMax        = 360
+	hslSaturationMax = 100
+	hslLightnessMax  = 100
+)
+
+type hslColorField struct {
+	Hue        uint16
+	Saturation uint8
+	Lightness  uint8
+}
+
+func (c *hslColorField) String() string {
+	return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
+}
+
+func (c *hslColorField) AsCSSValue() template.CSS {
+	return template.CSS(c.String())
+}
+
+func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
+	var value string
+
+	if err := node.Decode(&value); err != nil {
+		return err
+	}
+
+	matches := hslColorFieldPattern.FindStringSubmatch(value)
+
+	if len(matches) != 4 {
+		return fmt.Errorf("invalid HSL color format: %s", value)
+	}
+
+	hue, err := strconv.ParseUint(matches[1], 10, 16)
+	if err != nil {
+		return err
+	}
+
+	if hue > hslHueMax {
+		return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
+	}
+
+	saturation, err := strconv.ParseUint(matches[2], 10, 8)
+	if err != nil {
+		return err
+	}
+
+	if saturation > hslSaturationMax {
+		return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
+	}
+
+	lightness, err := strconv.ParseUint(matches[3], 10, 8)
+	if err != nil {
+		return err
+	}
+
+	if lightness > hslLightnessMax {
+		return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
+	}
+
+	c.Hue = uint16(hue)
+	c.Saturation = uint8(saturation)
+	c.Lightness = uint8(lightness)
+
+	return nil
+}
+
+var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
+
+type durationField time.Duration
+
+func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
+	var value string
+
+	if err := node.Decode(&value); err != nil {
+		return err
+	}
+
+	matches := durationFieldPattern.FindStringSubmatch(value)
+
+	if len(matches) != 3 {
+		return fmt.Errorf("invalid duration format: %s", value)
+	}
+
+	duration, err := strconv.Atoi(matches[1])
+	if err != nil {
+		return err
+	}
+
+	switch matches[2] {
+	case "s":
+		*d = durationField(time.Duration(duration) * time.Second)
+	case "m":
+		*d = durationField(time.Duration(duration) * time.Minute)
+	case "h":
+		*d = durationField(time.Duration(duration) * time.Hour)
+	case "d":
+		*d = durationField(time.Duration(duration) * 24 * time.Hour)
+	}
+
+	return nil
+}
+
+var optionalEnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`)
+
+type optionalEnvField string
+
+func (f *optionalEnvField) UnmarshalYAML(node *yaml.Node) error {
+	var value string
+
+	err := node.Decode(&value)
+	if err != nil {
+		return err
+	}
+
+	replaced := optionalEnvFieldPattern.ReplaceAllStringFunc(value, func(match string) string {
+		if err != nil {
+			return ""
+		}
+
+		groups := optionalEnvFieldPattern.FindStringSubmatch(match)
+
+		if len(groups) != 3 {
+			return match
+		}
+
+		prefix, key := groups[1], groups[2]
+
+		if prefix == `\` {
+			if len(match) >= 2 {
+				return match[1:]
+			} else {
+				return ""
+			}
+		}
+
+		value, found := os.LookupEnv(key)
+		if !found {
+			err = fmt.Errorf("environment variable %s not found", key)
+			return ""
+		}
+
+		return prefix + value
+	})
+
+	if err != nil {
+		return err
+	}
+
+	*f = optionalEnvField(replaced)
+
+	return nil
+}
+
+func (f *optionalEnvField) String() string {
+	return string(*f)
+}
+
+type customIconField struct {
+	URL        string
+	IsFlatIcon bool
+	// TODO: along with whether the icon is flat, we also need to know
+	// whether the icon is black or white by default in order to properly
+	// invert the color based on the theme being light or dark
+}
+
+func newCustomIconField(value string) customIconField {
+	field := customIconField{}
+
+	prefix, icon, found := strings.Cut(value, ":")
+	if !found {
+		field.URL = value
+		return field
+	}
+
+	switch prefix {
+	case "si":
+		field.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
+		field.IsFlatIcon = true
+	case "di", "sh":
+		// syntax: di:<icon_name>[.svg|.png]
+		// syntax: sh:<icon_name>[.svg|.png]
+		// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
+		// otherwise, specify the extension of either .svg or .png to use either of the CDN offerings
+		// any other extension will be interpreted as .svg
+		basename, ext, found := strings.Cut(icon, ".")
+		if !found {
+			ext = "svg"
+			basename = icon
+		}
+
+		if ext != "svg" && ext != "png" {
+			ext = "svg"
+		}
+
+		if prefix == "di" {
+			field.URL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/" + ext + "/" + basename + "." + ext
+		} else {
+			field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/" + ext + "/" + basename + "." + ext
+		}
+	default:
+		field.URL = value
+	}
+
+	return field
+}
+
+func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
+	var value string
+	if err := node.Decode(&value); err != nil {
+		return err
+	}
+
+	*i = newCustomIconField(value)
+	return nil
+}

+ 255 - 30
internal/glance/config.go

@@ -1,43 +1,91 @@
 package glance
 
 import (
+	"bytes"
 	"fmt"
-	"io"
+	"html/template"
+	"log"
+	"maps"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
 
+	"github.com/fsnotify/fsnotify"
 	"gopkg.in/yaml.v3"
 )
 
-type Config struct {
-	Server   Server   `yaml:"server"`
-	Theme    Theme    `yaml:"theme"`
-	Branding Branding `yaml:"branding"`
-	Pages    []Page   `yaml:"pages"`
-}
+type config struct {
+	Server struct {
+		Host       string    `yaml:"host"`
+		Port       uint16    `yaml:"port"`
+		AssetsPath string    `yaml:"assets-path"`
+		BaseURL    string    `yaml:"base-url"`
+		StartedAt  time.Time `yaml:"-"` // used in custom css file
+	} `yaml:"server"`
 
-func NewConfigFromYml(contents io.Reader) (*Config, error) {
-	config := NewConfig()
+	Document struct {
+		Head template.HTML `yaml:"head"`
+	} `yaml:"document"`
 
-	contentBytes, err := io.ReadAll(contents)
+	Theme struct {
+		BackgroundColor          *hslColorField `yaml:"background-color"`
+		PrimaryColor             *hslColorField `yaml:"primary-color"`
+		PositiveColor            *hslColorField `yaml:"positive-color"`
+		NegativeColor            *hslColorField `yaml:"negative-color"`
+		Light                    bool           `yaml:"light"`
+		ContrastMultiplier       float32        `yaml:"contrast-multiplier"`
+		TextSaturationMultiplier float32        `yaml:"text-saturation-multiplier"`
+		CustomCSSFile            string         `yaml:"custom-css-file"`
+	} `yaml:"theme"`
 
-	if err != nil {
-		return nil, err
-	}
+	Branding struct {
+		HideFooter   bool          `yaml:"hide-footer"`
+		CustomFooter template.HTML `yaml:"custom-footer"`
+		LogoText     string        `yaml:"logo-text"`
+		LogoURL      string        `yaml:"logo-url"`
+		FaviconURL   string        `yaml:"favicon-url"`
+	} `yaml:"branding"`
+
+	Pages []page `yaml:"pages"`
+}
 
-	err = yaml.Unmarshal(contentBytes, config)
+type page struct {
+	Title                      string `yaml:"name"`
+	Slug                       string `yaml:"slug"`
+	Width                      string `yaml:"width"`
+	ShowMobileHeader           bool   `yaml:"show-mobile-header"`
+	ExpandMobilePageNavigation bool   `yaml:"expand-mobile-page-navigation"`
+	HideDesktopNavigation      bool   `yaml:"hide-desktop-navigation"`
+	CenterVertically           bool   `yaml:"center-vertically"`
+	Columns                    []struct {
+		Size    string  `yaml:"size"`
+		Widgets widgets `yaml:"widgets"`
+	} `yaml:"columns"`
+	PrimaryColumnIndex int8       `yaml:"-"`
+	mu                 sync.Mutex `yaml:"-"`
+}
 
+func newConfigFromYAML(contents []byte) (*config, error) {
+	config := &config{}
+	config.Server.Port = 8080
+
+	err := yaml.Unmarshal(contents, config)
 	if err != nil {
 		return nil, err
 	}
 
-	if err = configIsValid(config); err != nil {
+	if err = isConfigStateValid(config); err != nil {
 		return nil, err
 	}
 
 	for p := range config.Pages {
 		for c := range config.Pages[p].Columns {
 			for w := range config.Pages[p].Columns[c].Widgets {
-				if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil {
-					return nil, err
+				if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
+					return nil, formatWidgetInitError(err, config.Pages[p].Columns[c].Widgets[w])
 				}
 			}
 		}
@@ -46,36 +94,213 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
 	return config, nil
 }
 
-func NewConfig() *Config {
-	config := &Config{}
+func formatWidgetInitError(err error, w widget) error {
+	return fmt.Errorf("%s widget: %v", w.GetType(), err)
+}
+
+var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
 
-	config.Server.Host = ""
-	config.Server.Port = 8080
+func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
+	mainFileContents, err := os.ReadFile(mainFilePath)
+	if err != nil {
+		return nil, nil, fmt.Errorf("reading main YAML file: %w", err)
+	}
+
+	mainFileAbsPath, err := filepath.Abs(mainFilePath)
+	if err != nil {
+		return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err)
+	}
+	mainFileDir := filepath.Dir(mainFileAbsPath)
+
+	includes := make(map[string]struct{})
+	var includesLastErr error
+
+	mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
+		if includesLastErr != nil {
+			return nil
+		}
 
-	return config
+		matches := includePattern.FindSubmatch(match)
+		if len(matches) != 3 {
+			includesLastErr = fmt.Errorf("invalid include match: %v", matches)
+			return nil
+		}
+
+		indent := string(matches[1])
+		includeFilePath := strings.TrimSpace(string(matches[2]))
+		if !filepath.IsAbs(includeFilePath) {
+			includeFilePath = filepath.Join(mainFileDir, includeFilePath)
+		}
+
+		var fileContents []byte
+		var err error
+
+		fileContents, err = os.ReadFile(includeFilePath)
+		if err != nil {
+			includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err)
+			return nil
+		}
+
+		includes[includeFilePath] = struct{}{}
+		return []byte(prefixStringLines(indent, string(fileContents)))
+	})
+
+	if includesLastErr != nil {
+		return nil, nil, includesLastErr
+	}
+
+	return mainFileContents, includes, nil
 }
 
-func configIsValid(config *Config) error {
+func configFilesWatcher(
+	mainFilePath string,
+	lastContents []byte,
+	lastIncludes map[string]struct{},
+	onChange func(newContents []byte),
+	onErr func(error),
+) (func() error, error) {
+	mainFileAbsPath, err := filepath.Abs(mainFilePath)
+	if err != nil {
+		return nil, fmt.Errorf("getting absolute path of main file: %w", err)
+	}
+
+	// TODO: refactor, flaky
+	lastIncludes[mainFileAbsPath] = struct{}{}
+
+	watcher, err := fsnotify.NewWatcher()
+	if err != nil {
+		return nil, fmt.Errorf("creating watcher: %w", err)
+	}
+
+	updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) {
+		for filePath := range previousWatched {
+			if _, ok := newWatched[filePath]; !ok {
+				watcher.Remove(filePath)
+			}
+		}
+
+		for filePath := range newWatched {
+			if _, ok := previousWatched[filePath]; !ok {
+				if err := watcher.Add(filePath); err != nil {
+					log.Printf(
+						"Could not add file to watcher, changes to this file will not trigger a reload. path: %s, error: %v",
+						filePath, err,
+					)
+				}
+			}
+		}
+	}
+
+	updateWatchedFiles(nil, lastIncludes)
+
+	// needed for lastContents and lastIncludes because they get updated in multiple goroutines
+	mu := sync.Mutex{}
+
+	checkForContentChangesBeforeCallback := func() {
+		currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
+		if err != nil {
+			onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
+			return
+		}
+
+		// TODO: refactor, flaky
+		currentIncludes[mainFileAbsPath] = struct{}{}
+
+		mu.Lock()
+		defer mu.Unlock()
+
+		if !maps.Equal(currentIncludes, lastIncludes) {
+			updateWatchedFiles(lastIncludes, currentIncludes)
+			lastIncludes = currentIncludes
+		}
+
+		if !bytes.Equal(lastContents, currentContents) {
+			lastContents = currentContents
+			onChange(currentContents)
+		}
+	}
+
+	const debounceDuration = 500 * time.Millisecond
+	var debounceTimer *time.Timer
+	debouncedCallback := func() {
+		if debounceTimer != nil {
+			debounceTimer.Stop()
+			debounceTimer.Reset(debounceDuration)
+		} else {
+			debounceTimer = time.AfterFunc(debounceDuration, checkForContentChangesBeforeCallback)
+		}
+	}
+
+	go func() {
+		for {
+			select {
+			case event, isOpen := <-watcher.Events:
+				if !isOpen {
+					return
+				}
+				if event.Has(fsnotify.Write) {
+					debouncedCallback()
+				} else if event.Has(fsnotify.Remove) {
+					func() {
+						mu.Lock()
+						defer mu.Unlock()
+						fileAbsPath, _ := filepath.Abs(event.Name)
+						delete(lastIncludes, fileAbsPath)
+					}()
+
+					debouncedCallback()
+				}
+			case err, isOpen := <-watcher.Errors:
+				if !isOpen {
+					return
+				}
+				onErr(fmt.Errorf("watcher error: %w", err))
+			}
+		}
+	}()
+
+	onChange(lastContents)
+
+	return func() error {
+		if debounceTimer != nil {
+			debounceTimer.Stop()
+		}
+
+		return watcher.Close()
+	}, nil
+}
+
+func isConfigStateValid(config *config) error {
+	if len(config.Pages) == 0 {
+		return fmt.Errorf("no pages configured")
+	}
+
+	if config.Server.AssetsPath != "" {
+		if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
+			return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
+		}
+	}
+
 	for i := range config.Pages {
 		if config.Pages[i].Title == "" {
-			return fmt.Errorf("Page %d has no title", i+1)
+			return fmt.Errorf("page %d has no name", i+1)
 		}
 
 		if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
-			return fmt.Errorf("Page %d: width can only be either wide or slim", i+1)
+			return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
 		}
 
 		if len(config.Pages[i].Columns) == 0 {
-			return fmt.Errorf("Page %d has no columns", i+1)
+			return fmt.Errorf("page %d has no columns", i+1)
 		}
 
 		if config.Pages[i].Width == "slim" {
 			if len(config.Pages[i].Columns) > 2 {
-				return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1)
+				return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
 			}
 		} else {
 			if len(config.Pages[i].Columns) > 3 {
-				return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
+				return fmt.Errorf("page %d has more than 3 columns", i+1)
 			}
 		}
 
@@ -83,7 +308,7 @@ func configIsValid(config *Config) error {
 
 		for j := range config.Pages[i].Columns {
 			if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" {
-				return fmt.Errorf("Column %d of page %d: size can only be either small or full", j+1, i+1)
+				return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
 			}
 
 			columnSizesCount[config.Pages[i].Columns[j].Size]++
@@ -92,7 +317,7 @@ func configIsValid(config *Config) error {
 		full := columnSizesCount["full"]
 
 		if full > 2 || full == 0 {
-			return fmt.Errorf("Page %d must have either 1 or 2 full width columns", i+1)
+			return fmt.Errorf("page %d must have either 1 or 2 full width columns", i+1)
 		}
 	}
 

+ 205 - 0
internal/glance/diagnose.go

@@ -0,0 +1,205 @@
+package glance
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"runtime"
+	"strings"
+	"sync"
+	"time"
+)
+
+const httpTestRequestTimeout = 10 * time.Second
+
+var diagnosticSteps = []diagnosticStep{
+	{
+		name: "resolve cloudflare.com through Cloudflare DoH",
+		fn: func() (string, error) {
+			return testHttpRequestWithHeaders("GET", "https://1.1.1.1/dns-query?name=cloudflare.com", map[string]string{
+				"accept": "application/dns-json",
+			}, 200)
+		},
+	},
+	{
+		name: "resolve cloudflare.com through Google DoH",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://8.8.8.8/resolve?name=cloudflare.com", 200)
+		},
+	},
+	{
+		name: "resolve github.com",
+		fn: func() (string, error) {
+			return testDNSResolution("github.com")
+		},
+	},
+	{
+		name: "resolve reddit.com",
+		fn: func() (string, error) {
+			return testDNSResolution("reddit.com")
+		},
+	},
+	{
+		name: "resolve twitch.tv",
+		fn: func() (string, error) {
+			return testDNSResolution("twitch.tv")
+		},
+	},
+	{
+		name: "fetch data from YouTube RSS feed",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZU9T1ceaOgwfLRq7OKFU4Q", 200)
+		},
+	},
+	{
+		name: "fetch data from Twitch.tv GQL",
+		fn: func() (string, error) {
+			// this should always return 0 bytes, we're mainly looking for a 200 status code
+			return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200)
+		},
+	},
+	{
+		name: "fetch data from GitHub API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://api.github.com", 200)
+		},
+	},
+	{
+		name: "fetch data from Open-Meteo API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://geocoding-api.open-meteo.com/v1/search?name=London", 200)
+		},
+	},
+	{
+		name: "fetch data from Reddit API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://www.reddit.com/search.json", 200)
+		},
+	},
+	{
+		name: "fetch data from Yahoo finance API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200)
+		},
+	},
+	{
+		name: "fetch data from Hacker News Firebase API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", 200)
+		},
+	},
+	{
+		name: "fetch data from Docker Hub API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://hub.docker.com/v2/namespaces/library/repositories/ubuntu/tags/latest", 200)
+		},
+	},
+}
+
+func runDiagnostic() {
+	fmt.Println("```")
+	fmt.Println("Glance version: " + buildVersion)
+	fmt.Println("Go version: " + runtime.Version())
+	fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
+	fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no"))
+
+	fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
+
+	var wg sync.WaitGroup
+	for i := range diagnosticSteps {
+		step := &diagnosticSteps[i]
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			start := time.Now()
+			step.extraInfo, step.err = step.fn()
+			step.elapsed = time.Since(start)
+		}()
+	}
+	wg.Wait()
+
+	for _, step := range diagnosticSteps {
+		var extraInfo string
+
+		if step.extraInfo != "" {
+			extraInfo = "| " + step.extraInfo + " "
+		}
+
+		fmt.Printf(
+			"%s %s %s| %dms\n",
+			boolToString(step.err == nil, "✓ Can", "✗ Can't"),
+			step.name,
+			extraInfo,
+			step.elapsed.Milliseconds(),
+		)
+
+		if step.err != nil {
+			fmt.Printf("└╴ error: %v\n", step.err)
+		}
+	}
+	fmt.Println("```")
+}
+
+type diagnosticStep struct {
+	name      string
+	fn        func() (string, error)
+	extraInfo string
+	err       error
+	elapsed   time.Duration
+}
+
+func testHttpRequest(method, url string, expectedStatusCode int) (string, error) {
+	return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode)
+}
+
+func testHttpRequestWithHeaders(method, url string, headers map[string]string, expectedStatusCode int) (string, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), httpTestRequestTimeout)
+	defer cancel()
+
+	request, _ := http.NewRequestWithContext(ctx, method, url, nil)
+	for key, value := range headers {
+		request.Header.Add(key, value)
+	}
+
+	response, err := http.DefaultClient.Do(request)
+	if err != nil {
+		return "", err
+	}
+	defer response.Body.Close()
+
+	body, err := io.ReadAll(response.Body)
+	if err != nil {
+		return "", err
+	}
+
+	printableBody := strings.ReplaceAll(string(body), "\n", "")
+	if len(printableBody) > 50 {
+		printableBody = printableBody[:50] + "..."
+	}
+	if len(printableBody) > 0 {
+		printableBody = ", " + printableBody
+	}
+
+	extraInfo := fmt.Sprintf("%d bytes%s", len(body), printableBody)
+
+	if response.StatusCode != expectedStatusCode {
+		return extraInfo, fmt.Errorf("expected status code %d, got %d", expectedStatusCode, response.StatusCode)
+	}
+
+	return extraInfo, nil
+}
+
+func testDNSResolution(domain string) (string, error) {
+	ips, err := net.LookupIP(domain)
+
+	var ipStrings []string
+	if err == nil {
+		for i := range ips {
+			ipStrings = append(ipStrings, ips[i].String())
+		}
+	}
+
+	return strings.Join(ipStrings, ", "), err
+}

+ 62 - 0
internal/glance/embed.go

@@ -0,0 +1,62 @@
+package glance
+
+import (
+	"crypto/md5"
+	"embed"
+	"encoding/hex"
+	"io"
+	"io/fs"
+	"log"
+	"strconv"
+	"time"
+)
+
+//go:embed static
+var _staticFS embed.FS
+
+//go:embed templates
+var _templateFS embed.FS
+
+var staticFS, _ = fs.Sub(_staticFS, "static")
+var templateFS, _ = fs.Sub(_templateFS, "templates")
+
+var staticFSHash = func() string {
+	hash, err := computeFSHash(staticFS)
+	if err != nil {
+		log.Printf("Could not compute static assets cache key: %v", err)
+		return strconv.FormatInt(time.Now().Unix(), 10)
+	}
+
+	return hash
+}()
+
+func computeFSHash(files fs.FS) (string, error) {
+	hash := md5.New()
+
+	err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+
+		if d.IsDir() {
+			return nil
+		}
+
+		file, err := files.Open(path)
+		if err != nil {
+			return err
+		}
+
+		if _, err := io.Copy(hash, file); err != nil {
+			return err
+		}
+
+		return nil
+	})
+
+	if err != nil {
+		return "", err
+	}
+
+	return hex.EncodeToString(hash.Sum(nil))[:10], nil
+}

+ 123 - 166
internal/glance/glance.go

@@ -5,80 +5,96 @@ import (
 	"context"
 	"fmt"
 	"html/template"
-	"log/slog"
+	"log"
 	"net/http"
 	"path/filepath"
-	"regexp"
 	"strconv"
 	"strings"
 	"sync"
 	"time"
-
-	"github.com/glanceapp/glance/internal/assets"
-	"github.com/glanceapp/glance/internal/widget"
 )
 
-var buildVersion = "dev"
+var (
+	pageTemplate           = mustParseTemplate("page.html", "document.html")
+	pageContentTemplate    = mustParseTemplate("page-content.html")
+	pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
+)
 
-var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
+type application struct {
+	Version          string
+	Config           config
+	ParsedThemeStyle template.HTML
 
-type Application struct {
-	Version    string
-	Config     Config
-	slugToPage map[string]*Page
-	widgetByID map[uint64]widget.Widget
+	slugToPage map[string]*page
+	widgetByID map[uint64]widget
 }
 
-type Theme struct {
-	BackgroundColor          *widget.HSLColorField `yaml:"background-color"`
-	PrimaryColor             *widget.HSLColorField `yaml:"primary-color"`
-	PositiveColor            *widget.HSLColorField `yaml:"positive-color"`
-	NegativeColor            *widget.HSLColorField `yaml:"negative-color"`
-	Light                    bool                  `yaml:"light"`
-	ContrastMultiplier       float32               `yaml:"contrast-multiplier"`
-	TextSaturationMultiplier float32               `yaml:"text-saturation-multiplier"`
-	CustomCSSFile            string                `yaml:"custom-css-file"`
-}
+func newApplication(config *config) (*application, error) {
+	app := &application{
+		Version:    buildVersion,
+		Config:     *config,
+		slugToPage: make(map[string]*page),
+		widgetByID: make(map[uint64]widget),
+	}
 
-type Server struct {
-	Host       string    `yaml:"host"`
-	Port       uint16    `yaml:"port"`
-	AssetsPath string    `yaml:"assets-path"`
-	BaseURL    string    `yaml:"base-url"`
-	AssetsHash string    `yaml:"-"`
-	StartedAt  time.Time `yaml:"-"` // used in custom css file
-}
+	app.slugToPage[""] = &config.Pages[0]
 
-type Branding struct {
-	HideFooter   bool          `yaml:"hide-footer"`
-	CustomFooter template.HTML `yaml:"custom-footer"`
-	LogoText     string        `yaml:"logo-text"`
-	LogoURL      string        `yaml:"logo-url"`
-	FaviconURL   string        `yaml:"favicon-url"`
-}
+	providers := &widgetProviders{
+		assetResolver: app.AssetPath,
+	}
 
-type Column struct {
-	Size    string         `yaml:"size"`
-	Widgets widget.Widgets `yaml:"widgets"`
-}
+	var err error
+	app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme)
+	if err != nil {
+		return nil, fmt.Errorf("parsing theme style: %v", err)
+	}
 
-type templateData struct {
-	App  *Application
-	Page *Page
-}
+	for p := range config.Pages {
+		page := &config.Pages[p]
+		page.PrimaryColumnIndex = -1
+
+		if page.Slug == "" {
+			page.Slug = titleToSlug(page.Title)
+		}
+
+		app.slugToPage[page.Slug] = page
+
+		for c := range page.Columns {
+			column := &page.Columns[c]
+
+			if page.PrimaryColumnIndex == -1 && column.Size == "full" {
+				page.PrimaryColumnIndex = int8(c)
+			}
+
+			for w := range column.Widgets {
+				widget := column.Widgets[w]
+				app.widgetByID[widget.id()] = widget
 
-type Page struct {
-	Title                 string   `yaml:"name"`
-	Slug                  string   `yaml:"slug"`
-	Width                 string   `yaml:"width"`
-	ShowMobileHeader      bool     `yaml:"show-mobile-header"`
-	HideDesktopNavigation bool     `yaml:"hide-desktop-navigation"`
-	CenterVertically      bool     `yaml:"center-vertically"`
-	Columns               []Column `yaml:"columns"`
-	mu                    sync.Mutex
+				widget.setProviders(providers)
+			}
+		}
+	}
+
+	config = &app.Config
+
+	config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
+	config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile)
+
+	if config.Branding.FaviconURL == "" {
+		config.Branding.FaviconURL = app.AssetPath("favicon.png")
+	} else {
+		config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL)
+	}
+
+	config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL)
+
+	return app, nil
 }
 
-func (p *Page) UpdateOutdatedWidgets() {
+func (p *page) updateOutdatedWidgets() {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
 	now := time.Now()
 
 	var wg sync.WaitGroup
@@ -88,14 +104,14 @@ func (p *Page) UpdateOutdatedWidgets() {
 		for w := range p.Columns[c].Widgets {
 			widget := p.Columns[c].Widgets[w]
 
-			if !widget.RequiresUpdate(&now) {
+			if !widget.requiresUpdate(&now) {
 				continue
 			}
 
 			wg.Add(1)
 			go func() {
 				defer wg.Done()
-				widget.Update(context)
+				widget.update(context)
 			}()
 		}
 	}
@@ -103,16 +119,7 @@ func (p *Page) UpdateOutdatedWidgets() {
 	wg.Wait()
 }
 
-// TODO: fix, currently very simple, lots of uncovered edge cases
-func titleToSlug(s string) string {
-	s = strings.ToLower(s)
-	s = sequentialWhitespacePattern.ReplaceAllString(s, "-")
-	s = strings.Trim(s, "-")
-
-	return s
-}
-
-func (a *Application) TransformUserDefinedAssetPath(path string) string {
+func (a *application) transformUserDefinedAssetPath(path string) string {
 	if strings.HasPrefix(path, "/assets/") {
 		return a.Config.Server.BaseURL + path
 	}
@@ -120,74 +127,26 @@ func (a *Application) TransformUserDefinedAssetPath(path string) string {
 	return path
 }
 
-func NewApplication(config *Config) (*Application, error) {
-	if len(config.Pages) == 0 {
-		return nil, fmt.Errorf("no pages configured")
-	}
-
-	app := &Application{
-		Version:    buildVersion,
-		Config:     *config,
-		slugToPage: make(map[string]*Page),
-		widgetByID: make(map[uint64]widget.Widget),
-	}
-
-	app.Config.Server.AssetsHash = assets.PublicFSHash
-	app.slugToPage[""] = &config.Pages[0]
-
-	providers := &widget.Providers{
-		AssetResolver: app.AssetPath,
-	}
-
-	for p := range config.Pages {
-		if config.Pages[p].Slug == "" {
-			config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
-		}
-
-		app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
-
-		for c := range config.Pages[p].Columns {
-			for w := range config.Pages[p].Columns[c].Widgets {
-				widget := config.Pages[p].Columns[c].Widgets[w]
-				app.widgetByID[widget.GetID()] = widget
-
-				widget.SetProviders(providers)
-			}
-		}
-	}
-
-	config = &app.Config
-
-	config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
-	config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile)
-
-	if config.Branding.FaviconURL == "" {
-		config.Branding.FaviconURL = app.AssetPath("favicon.png")
-	} else {
-		config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL)
-	}
-
-	config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
-
-	return app, nil
+type pageTemplateData struct {
+	App  *application
+	Page *page
 }
 
-func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) {
+func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
 	page, exists := a.slugToPage[r.PathValue("page")]
 
 	if !exists {
-		a.HandleNotFound(w, r)
+		a.handleNotFound(w, r)
 		return
 	}
 
-	pageData := templateData{
+	pageData := pageTemplateData{
 		Page: page,
 		App:  a,
 	}
 
 	var responseBytes bytes.Buffer
-	err := assets.PageTemplate.Execute(&responseBytes, pageData)
-
+	err := pageTemplate.Execute(&responseBytes, pageData)
 	if err != nil {
 		w.WriteHeader(http.StatusInternalServerError)
 		w.Write([]byte(err.Error()))
@@ -197,25 +156,22 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request)
 	w.Write(responseBytes.Bytes())
 }
 
-func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) {
+func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) {
 	page, exists := a.slugToPage[r.PathValue("page")]
 
 	if !exists {
-		a.HandleNotFound(w, r)
+		a.handleNotFound(w, r)
 		return
 	}
 
-	pageData := templateData{
+	pageData := pageTemplateData{
 		Page: page,
 	}
 
-	page.mu.Lock()
-	defer page.mu.Unlock()
-	page.UpdateOutdatedWidgets()
+	page.updateOutdatedWidgets()
 
 	var responseBytes bytes.Buffer
-	err := assets.PageContentTemplate.Execute(&responseBytes, pageData)
-
+	err := pageContentTemplate.Execute(&responseBytes, pageData)
 	if err != nil {
 		w.WriteHeader(http.StatusInternalServerError)
 		w.Write([]byte(err.Error()))
@@ -225,74 +181,58 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re
 	w.Write(responseBytes.Bytes())
 }
 
-func (a *Application) HandleNotFound(w http.ResponseWriter, r *http.Request) {
+func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
 	// TODO: add proper not found page
 	w.WriteHeader(http.StatusNotFound)
 	w.Write([]byte("Page not found"))
 }
 
-func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler {
-	server := http.FileServer(fs)
-
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		// TODO: fix always setting cache control even if the file doesn't exist
-		w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds())))
-		server.ServeHTTP(w, r)
-	})
-}
-
-func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) {
+func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
 	widgetValue := r.PathValue("widget")
 
 	widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
-
 	if err != nil {
-		a.HandleNotFound(w, r)
+		a.handleNotFound(w, r)
 		return
 	}
 
 	widget, exists := a.widgetByID[widgetID]
 
 	if !exists {
-		a.HandleNotFound(w, r)
+		a.handleNotFound(w, r)
 		return
 	}
 
-	widget.HandleRequest(w, r)
+	widget.handleRequest(w, r)
 }
 
-func (a *Application) AssetPath(asset string) string {
-	return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset
+func (a *application) AssetPath(asset string) string {
+	return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
 }
 
-func (a *Application) Serve() error {
+func (a *application) server() (func() error, func() error) {
 	// TODO: add gzip support, static files must have their gzipped contents cached
 	// TODO: add HTTPS support
 	mux := http.NewServeMux()
 
-	mux.HandleFunc("GET /{$}", a.HandlePageRequest)
-	mux.HandleFunc("GET /{page}", a.HandlePageRequest)
+	mux.HandleFunc("GET /{$}", a.handlePageRequest)
+	mux.HandleFunc("GET /{page}", a.handlePageRequest)
 
-	mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
-	mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
+	mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
+	mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
 	mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
 		w.WriteHeader(http.StatusOK)
 	})
 
 	mux.Handle(
-		fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
-		http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
+		fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
+		http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)),
 	)
 
+	var absAssetsPath string
 	if a.Config.Server.AssetsPath != "" {
-		absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
-
-		if err != nil {
-			return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath)
-		}
-
-		slog.Info("Serving assets", "path", absAssetsPath)
-		assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
+		absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
+		assetsFS := fileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
 		mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
 	}
 
@@ -301,8 +241,25 @@ func (a *Application) Serve() error {
 		Handler: mux,
 	}
 
-	a.Config.Server.StartedAt = time.Now()
-	slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL)
+	start := func() error {
+		a.Config.Server.StartedAt = time.Now()
+		log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
+			a.Config.Server.Host,
+			a.Config.Server.Port,
+			a.Config.Server.BaseURL,
+			absAssetsPath,
+		)
+
+		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+			return err
+		}
+
+		return nil
+	}
+
+	stop := func() error {
+		return server.Close()
+	}
 
-	return server.ListenAndServe()
+	return start, stop
 }

+ 147 - 18
internal/glance/main.go

@@ -2,45 +2,174 @@ package glance
 
 import (
 	"fmt"
+	"io"
+	"log"
+	"net/http"
 	"os"
 )
 
-func Main() int {
-	options, err := ParseCliOptions()
+var buildVersion = "dev"
 
+func Main() int {
+	options, err := parseCliOptions()
 	if err != nil {
 		fmt.Println(err)
 		return 1
 	}
 
-	configFile, err := os.Open(options.ConfigPath)
+	switch options.intent {
+	case cliIntentServe:
+		// remove in v0.10.0
+		if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
+			return 1
+		}
 
-	if err != nil {
-		fmt.Printf("failed opening config file: %v\n", err)
-		return 1
+		if err := serveApp(options.configPath); err != nil {
+			fmt.Println(err)
+			return 1
+		}
+	case cliIntentConfigValidate:
+		contents, _, err := parseYAMLIncludes(options.configPath)
+		if err != nil {
+			fmt.Printf("Could not parse config file: %v\n", err)
+			return 1
+		}
+
+		if _, err := newConfigFromYAML(contents); err != nil {
+			fmt.Printf("Config file is invalid: %v\n", err)
+			return 1
+		}
+	case cliIntentConfigPrint:
+		contents, _, err := parseYAMLIncludes(options.configPath)
+		if err != nil {
+			fmt.Printf("Could not parse config file: %v\n", err)
+			return 1
+		}
+
+		fmt.Println(string(contents))
+	case cliIntentDiagnose:
+		runDiagnostic()
+	}
+
+	return 0
+}
+
+func serveApp(configPath string) error {
+	exitChannel := make(chan struct{})
+	// the onChange method gets called at most once per 500ms due to debouncing so we shouldn't
+	// need to use atomic.Bool here unless newConfigFromYAML is very slow for some reason
+	hadValidConfigOnStartup := false
+	var stopServer func() error
+
+	onChange := func(newContents []byte) {
+		if stopServer != nil {
+			log.Println("Config file changed, reloading...")
+		}
+
+		config, err := newConfigFromYAML(newContents)
+		if err != nil {
+			log.Printf("Config has errors: %v", err)
+
+			if !hadValidConfigOnStartup {
+				close(exitChannel)
+			}
+
+			return
+		} else if !hadValidConfigOnStartup {
+			hadValidConfigOnStartup = true
+		}
+
+		app, err := newApplication(config)
+		if err != nil {
+			log.Printf("Failed to create application: %v", err)
+			return
+		}
+
+		if stopServer != nil {
+			if err := stopServer(); err != nil {
+				log.Printf("Error while trying to stop server: %v", err)
+			}
+		}
+
+		go func() {
+			var startServer func() error
+			startServer, stopServer = app.server()
+
+			if err := startServer(); err != nil {
+				log.Printf("Failed to start server: %v", err)
+			}
+		}()
 	}
 
-	config, err := NewConfigFromYml(configFile)
-	configFile.Close()
+	onErr := func(err error) {
+		log.Printf("Error watching config files: %v", err)
+	}
 
+	configContents, configIncludes, err := parseYAMLIncludes(configPath)
 	if err != nil {
-		fmt.Printf("failed parsing config file: %v\n", err)
-		return 1
+		return fmt.Errorf("parsing config: %w", err)
 	}
 
-	if options.Intent == CliIntentServe {
-		app, err := NewApplication(config)
+	stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr)
+	if err == nil {
+		defer stopWatching()
+	} else {
+		log.Printf("Error starting file watcher, config file changes will require a manual restart. (%v)", err)
 
+		config, err := newConfigFromYAML(configContents)
 		if err != nil {
-			fmt.Printf("failed creating application: %v\n", err)
-			return 1
+			return fmt.Errorf("validating config file: %w", err)
 		}
 
-		if err := app.Serve(); err != nil {
-			fmt.Printf("http server error: %v\n", err)
-			return 1
+		app, err := newApplication(config)
+		if err != nil {
+			return fmt.Errorf("creating application: %w", err)
+		}
+
+		startServer, _ := app.server()
+		if err := startServer(); err != nil {
+			return fmt.Errorf("starting server: %w", err)
 		}
 	}
 
-	return 0
+	<-exitChannel
+	return nil
+}
+
+func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool {
+	if !isRunningInsideDockerContainer() {
+		return false
+	}
+
+	if _, err := os.Stat(configPath); err == nil {
+		return false
+	}
+
+	// glance.yml wasn't mounted to begin with or was incorrectly mounted as a directory
+	if stat, err := os.Stat("glance.yml"); err != nil || stat.IsDir() {
+		return false
+	}
+
+	templateFile, _ := templateFS.Open("v0.7-update-notice-page.html")
+	bodyContents, _ := io.ReadAll(templateFile)
+
+	// TODO: update - add link
+	fmt.Println("!!! WARNING !!!")
+	fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0, please see <link> for more information.")
+
+	mux := http.NewServeMux()
+	mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusServiceUnavailable)
+		w.Header().Set("Content-Type", "text/html")
+		w.Write([]byte(bodyContents))
+	})
+
+	server := http.Server{
+		Addr:    ":8080",
+		Handler: mux,
+	}
+	server.ListenAndServe()
+
+	return true
 }

+ 0 - 0
internal/assets/static/app-icon.png → internal/glance/static/app-icon.png


+ 0 - 0
internal/assets/static/favicon.png → internal/glance/static/favicon.png


+ 0 - 0
internal/assets/static/fonts/JetBrainsMono-Regular.woff2 → internal/glance/static/fonts/JetBrainsMono-Regular.woff2


+ 0 - 0
internal/assets/static/icons/codeberg.svg → internal/glance/static/icons/codeberg.svg


+ 0 - 0
internal/assets/static/icons/dockerhub.svg → internal/glance/static/icons/dockerhub.svg


+ 0 - 0
internal/assets/static/icons/github.svg → internal/glance/static/icons/github.svg


+ 0 - 0
internal/assets/static/icons/gitlab.svg → internal/glance/static/icons/gitlab.svg


+ 59 - 5
internal/assets/static/js/main.js → internal/glance/static/js/main.js

@@ -1,5 +1,6 @@
 import { setupPopovers } from './popover.js';
-import { throttledDebounce, isElementVisible } from './utils.js';
+import { setupMasonries } from './masonry.js';
+import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
 
 async function fetchPageContent(pageData) {
     // TODO: handle non 200 status codes/time outs
@@ -104,6 +105,7 @@ function setupSearchBoxes() {
         const bangsMap = {};
         const kbdElement = widget.getElementsByTagName("kbd")[0];
         let currentBang = null;
+        let lastQuery = "";
 
         for (let j = 0; j < bangs.length; j++) {
             const bang = bangs[j];
@@ -140,6 +142,14 @@ function setupSearchBoxes() {
                     window.location.href = url;
                 }
 
+                lastQuery = query;
+                inputElement.value = "";
+
+                return;
+            }
+
+            if (event.key == "ArrowUp" && lastQuery.length > 0) {
+                inputElement.value = lastQuery;
                 return;
             }
         };
@@ -245,8 +255,24 @@ function setupGroups() {
 
         for (let t = 0; t < titles.length; t++) {
             const title = titles[t];
+
+            if (title.dataset.titleUrl !== undefined) {
+                title.addEventListener("mousedown", (event) => {
+                    if (event.button != 1) {
+                        return;
+                    }
+
+                    openURLInNewTab(title.dataset.titleUrl, false);
+                    event.preventDefault();
+                });
+            }
+
             title.addEventListener("click", () => {
                 if (t == current) {
+                    if (title.dataset.titleUrl !== undefined) {
+                        openURLInNewTab(title.dataset.titleUrl);
+                    }
+
                     return;
                 }
 
@@ -502,9 +528,34 @@ function timeInZone(now, zone) {
         timeInZone = now
     }
 
-    const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
+    const diffInMinutes = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60);
+
+    return { time: timeInZone, diffInMinutes: diffInMinutes };
+}
+
+function zoneDiffText(diffInMinutes) {
+    if (diffInMinutes == 0) {
+        return "";
+    }
+
+    const sign = diffInMinutes < 0 ? "-" : "+";
+    const signText = diffInMinutes < 0 ? "behind" : "ahead";
+
+    diffInMinutes = Math.abs(diffInMinutes);
+
+    const hours = Math.floor(diffInMinutes / 60);
+    const minutes = diffInMinutes % 60;
+    const hourSuffix = hours == 1 ? "" : "s";
+
+    if (minutes == 0) {
+        return { text: `${sign}${hours}h`, title: `${hours} hour${hourSuffix} ${signText}` };
+    }
+
+    if (hours == 0) {
+        return { text: `${sign}${minutes}m`, title: `${minutes} minutes ${signText}` };
+    }
 
-    return { time: timeInZone, diffInHours: diffInHours };
+    return { text: `${sign}${hours}h~`, title: `${hours} hour${hourSuffix} and ${minutes} minutes ${signText}` };
 }
 
 function setupClocks() {
@@ -547,9 +598,11 @@ function setupClocks() {
             );
 
             updateCallbacks.push((now) => {
-                const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
+                const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
                 setZoneTime(time);
-                diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
+                const { text, title } = zoneDiffText(diffInMinutes);
+                diffElement.textContent = text;
+                diffElement.title = title;
             });
         }
     }
@@ -581,6 +634,7 @@ async function setupPage() {
         setupCollapsibleLists();
         setupCollapsibleGrids();
         setupGroups();
+        setupMasonries();
         setupDynamicRelativeTime();
         setupLazyImages();
     } finally {

+ 53 - 0
internal/glance/static/js/masonry.js

@@ -0,0 +1,53 @@
+
+import { clamp } from "./utils.js";
+
+export function setupMasonries() {
+    const masonryContainers = document.getElementsByClassName("masonry");
+
+    for (let i = 0; i < masonryContainers.length; i++) {
+        const container = masonryContainers[i];
+
+        const options = {
+            minColumnWidth: container.dataset.minColumnWidth || 330,
+            maxColumns: container.dataset.maxColumns || 6,
+        };
+
+        const items = Array.from(container.children);
+        let previousColumnsCount = 0;
+
+        const render = function() {
+            const columnsCount = clamp(
+                Math.floor(container.offsetWidth / options.minColumnWidth),
+                1,
+                Math.min(options.maxColumns, items.length)
+            );
+
+            if (columnsCount === previousColumnsCount) {
+                return;
+            } else {
+                container.textContent = "";
+                previousColumnsCount = columnsCount;
+            }
+
+            const columnsFragment = document.createDocumentFragment();
+
+            for (let i = 0; i < columnsCount; i++) {
+                const column = document.createElement("div");
+                column.className = "masonry-column";
+                columnsFragment.append(column);
+            }
+
+            // poor man's masonry
+            // TODO: add an option that allows placing items in the
+            // shortest column instead of iterating the columns in order
+            for (let i = 0; i < items.length; i++) {
+                columnsFragment.children[i % columnsCount].appendChild(items[i]);
+            }
+
+            container.append(columnsFragment);
+        };
+
+        const observer = new ResizeObserver(() => requestAnimationFrame(render));
+        observer.observe(container);
+    }
+}

+ 7 - 4
internal/assets/static/js/popover.js → internal/glance/static/js/popover.js

@@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() {
 }
 
 function showPopover() {
+    if (pendingTarget === null) return;
+
     activeTarget = pendingTarget;
     pendingTarget = null;
 
@@ -109,9 +111,10 @@ function repositionContainer() {
 
     const containerBounds = containerElement.getBoundingClientRect();
     const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline"));
-    const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverOffset || 0.5);
+    const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverTargetOffset || 0.5);
     const position = activeTarget.dataset.popoverPosition || "below";
-    const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2));
+    const popoverOffest = activeTarget.dataset.popoverOffset || 0.5;
+    const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width * popoverOffest));
 
     if (left < 0) {
         containerElement.style.left = 0;
@@ -120,11 +123,11 @@ function repositionContainer() {
     } else if (left + containerBounds.width > window.innerWidth) {
         containerElement.style.removeProperty("left");
         containerElement.style.right = 0;
-        containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + "px");
+        containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px");
     } else {
         containerElement.style.removeProperty("right");
         containerElement.style.left = left + "px";
-        containerElement.style.removeProperty("--triangle-offset");
+        containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + -1 + "px");
     }
 
     const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;

+ 13 - 0
internal/assets/static/js/utils.js → internal/glance/static/js/utils.js

@@ -23,3 +23,16 @@ export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
 export function isElementVisible(element) {
     return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
 }
+
+export function clamp(value, min, max) {
+    return Math.min(Math.max(value, min), max);
+}
+
+// NOTE: inconsistent behavior between browsers when it comes to
+// whether the newly opened tab gets focused or not, potentially
+// depending on the event that this function is called from
+export function openURLInNewTab(url, focus = true) {
+    const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
+
+    if (focus && newWindow != null) newWindow.focus();
+}

+ 70 - 15
internal/assets/static/main.css → internal/glance/static/main.css

@@ -440,6 +440,17 @@ kbd:active {
     box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
 }
 
+.masonry {
+    display: flex;
+    gap: var(--widget-gap);
+}
+
+.masonry-column {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+}
+
 .popover-container, [data-popover-html] {
     display: none;
 }
@@ -514,6 +525,7 @@ kbd:active {
     list-style: none;
     position: relative;
     display: flex;
+    z-index: 1;
 }
 
 .details[open] .summary {
@@ -535,6 +547,10 @@ kbd:active {
     opacity: 1;
 }
 
+.details:not([open]) .list-with-transition {
+    display: none;
+}
+
 .summary::after {
     content: "◀";
     font-size: 1.2em;
@@ -707,6 +723,7 @@ details[open] .summary::after {
     justify-content: space-between;
     position: relative;
     margin-bottom: 1.8rem;
+    z-index: 1;
 }
 
 .widget-error-header::before {
@@ -722,19 +739,11 @@ details[open] .summary::after {
 .widget-error-icon {
     width: 2.4rem;
     height: 2.4rem;
-    border: 0.2rem solid var(--color-negative);
-    border-radius: 50%;
-    text-align: center;
-    line-height: 2rem;
     flex-shrink: 0;
+    stroke: var(--color-negative);
     opacity: 0.6;
 }
 
-.widget-error-icon::before {
-    content: '!';
-    color: var(--color-text-highlight);
-}
-
 .widget-content {
     container-type: inline-size;
     container-name: widget;
@@ -1059,7 +1068,7 @@ details[open] .summary::after {
     opacity: 0.8;
 }
 
-:root:not(.light-scheme) .simple-icon {
+:root:not(.light-scheme) .flat-icon {
     filter: invert(1);
 }
 
@@ -1095,7 +1104,6 @@ details[open] .summary::after {
 
 .dns-stats-graph-gridlines-container {
     position: absolute;
-    z-index: -1;
     inset: 0;
 }
 
@@ -1122,7 +1130,6 @@ details[open] .summary::after {
     content: '';
     position: absolute;
     inset: 1px 0;
-    z-index: -1;
     opacity: 0;
     background: var(--color-text-base);
     transition: opacity .2s;
@@ -1264,7 +1271,6 @@ details[open] .summary::after {
     overflow: hidden;
     mask-image: linear-gradient(0deg, transparent 40%, #000);
     -webkit-mask-image: linear-gradient(0deg, transparent 40%, #000);
-    z-index: -1;
 }
 
 .weather-column-rain::before {
@@ -1339,6 +1345,10 @@ details[open] .summary::after {
     transform: translate(-50%, -50%);
 }
 
+.clock-time {
+    min-width: 8ch;
+}
+
 .clock-time span {
     color: var(--color-text-highlight);
 }
@@ -1355,7 +1365,7 @@ details[open] .summary::after {
     transition: filter 0.3s, opacity 0.3s;
 }
 
-.monitor-site-icon.simple-icon {
+.monitor-site-icon.flat-icon {
     opacity: 0.7;
 }
 
@@ -1363,7 +1373,7 @@ details[open] .summary::after {
     opacity: 1;
 }
 
-.monitor-site:hover .monitor-site-icon:not(.simple-icon) {
+.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
     filter: grayscale(0);
 }
 
@@ -1374,6 +1384,39 @@ details[open] .summary::after {
     height: 2rem;
 }
 
+.monitor-site-status-icon-compact {
+    width: 1.8rem;
+    height: 1.8rem;
+    flex-shrink: 0;
+}
+
+.docker-container-icon {
+    display: block;
+    filter: grayscale(0.4);
+    object-fit: contain;
+    aspect-ratio: 1 / 1;
+    width: 2.7rem;
+    opacity: 0.8;
+    transition: filter 0.3s, opacity 0.3s;
+}
+
+.docker-container-icon.flat-icon {
+    opacity: 0.7;
+}
+
+.docker-container:hover .docker-container-icon {
+    opacity: 1;
+}
+
+.docker-container:hover .docker-container-icon:not(.flat-icon) {
+    filter: grayscale(0);
+}
+
+.docker-container-status-icon {
+    width: 2rem;
+    height: 2rem;
+}
+
 .thumbnail {
     filter: grayscale(0.2) contrast(0.9);
     opacity: 0.8;
@@ -1493,6 +1536,14 @@ details[open] .summary::after {
     border: 2px solid var(--color-widget-background);
 }
 
+.twitch-stream-preview {
+    max-width: 100%;
+    width: 400px;
+    aspect-ratio: 16 / 9;
+    border-radius: var(--border-radius);
+    object-fit: cover;
+}
+
 .reddit-card-thumbnail {
     width: 100%;
     height: 100%;
@@ -1775,12 +1826,14 @@ details[open] .summary::after {
 .gap-5              { gap: 0.5rem; }
 .gap-7              { gap: 0.7rem; }
 .gap-10             { gap: 1rem; }
+.gap-12             { gap: 1.2rem; }
 .gap-15             { gap: 1.5rem; }
 .gap-20             { gap: 2rem; }
 .gap-25             { gap: 2.5rem; }
 .gap-35             { gap: 3.5rem; }
 .gap-45             { gap: 4.5rem; }
 .gap-55             { gap: 5.5rem; }
+.margin-left-auto   { margin-left: auto; }
 .margin-top-3       { margin-top: 0.3rem; }
 .margin-top-5       { margin-top: 0.5rem; }
 .margin-top-7       { margin-top: 0.7rem; }
@@ -1794,6 +1847,7 @@ details[open] .summary::after {
 .margin-block-3     { margin-block: 0.3rem; }
 .margin-block-5     { margin-block: 0.5rem; }
 .margin-block-7     { margin-block: 0.7rem; }
+.margin-block-8     { margin-block: 0.8rem; }
 .margin-block-10    { margin-block: 1rem; }
 .margin-block-15    { margin-block: 1.5rem; }
 .margin-bottom-3    { margin-bottom: 0.3rem; }
@@ -1807,6 +1861,7 @@ details[open] .summary::after {
 .list               { --list-half-gap: 0rem; }
 .list-gap-2         { --list-half-gap: 0.1rem; }
 .list-gap-4         { --list-half-gap: 0.2rem; }
+.list-gap-8         { --list-half-gap: 0.4rem; }
 .list-gap-10        { --list-half-gap: 0.5rem; }
 .list-gap-14        { --list-half-gap: 0.7rem; }
 .list-gap-20        { --list-half-gap: 1rem; }

+ 0 - 0
internal/assets/static/manifest.json → internal/glance/static/manifest.json


+ 56 - 0
internal/glance/templates.go

@@ -0,0 +1,56 @@
+package glance
+
+import (
+	"fmt"
+	"html/template"
+	"math"
+	"strconv"
+	"time"
+
+	"golang.org/x/text/language"
+	"golang.org/x/text/message"
+)
+
+var intl = message.NewPrinter(language.English)
+
+var globalTemplateFunctions = template.FuncMap{
+	"formatViewerCount": formatViewerCount,
+	"formatNumber":      intl.Sprint,
+	"absInt": func(i int) int {
+		return int(math.Abs(float64(i)))
+	},
+	"formatPrice": func(price float64) string {
+		return intl.Sprintf("%.2f", price)
+	},
+	"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
+		return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
+	},
+}
+
+func mustParseTemplate(primary string, dependencies ...string) *template.Template {
+	t, err := template.New(primary).
+		Funcs(globalTemplateFunctions).
+		ParseFS(templateFS, append([]string{primary}, dependencies...)...)
+
+	if err != nil {
+		panic(err)
+	}
+
+	return t
+}
+
+func formatViewerCount(count int) string {
+	if count < 1_000 {
+		return strconv.Itoa(count)
+	}
+
+	if count < 10_000 {
+		return fmt.Sprintf("%.1fk", float64(count)/1_000)
+	}
+
+	if count < 1_000_000 {
+		return fmt.Sprintf("%dk", count/1_000)
+	}
+
+	return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
+}

+ 2 - 2
internal/assets/templates/bookmarks.html → internal/glance/templates/bookmarks.html

@@ -8,9 +8,9 @@
         <ul class="list list-gap-2">
         {{ range .Links }}
         <li class="flex items-center gap-10">
-            {{ if ne "" .Icon }}
+            {{ if ne "" .Icon.URL }}
             <div class="bookmarks-icon-container">
-                <img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
+                <img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" 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>

+ 6 - 1
internal/assets/templates/calendar.html → internal/glance/templates/calendar.html

@@ -11,13 +11,18 @@
     </div>
 
     <div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
+        {{ if .StartSunday }}
+            <div class="calendar-day">Su</div>
+        {{ end }}
         <div class="calendar-day">Mo</div>
         <div class="calendar-day">Tu</div>
         <div class="calendar-day">We</div>
         <div class="calendar-day">Th</div>
         <div class="calendar-day">Fr</div>
         <div class="calendar-day">Sa</div>
-        <div class="calendar-day">Su</div>
+        {{ if not .StartSunday }}
+            <div class="calendar-day">Su</div>
+        {{ end }}
     </div>
 
     <div class="flex flex-wrap">

+ 0 - 0
internal/assets/templates/change-detection.html → internal/glance/templates/change-detection.html


+ 0 - 0
internal/assets/templates/clock.html → internal/glance/templates/clock.html


+ 7 - 0
internal/glance/templates/custom-api.html

@@ -0,0 +1,7 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }}
+
+{{ define "widget-content" }}
+{{ .CompiledHTML }}
+{{ end }}

+ 1 - 1
internal/assets/templates/dns-stats.html → internal/glance/templates/dns-stats.html

@@ -73,7 +73,7 @@
         <summary class="summary">Top blocked domains</summary>
         <ul class="list list-gap-4 list-with-transition size-h5">
             {{ range .Stats.TopBlockedDomains }}
-            <li class="flex justify-between align-center">
+            <li class="flex justify-between">
                 <div class="text-truncate rtl">{{ .Domain }}</div>
                 <div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
             </li>

+ 64 - 0
internal/glance/templates/docker-containers.html

@@ -0,0 +1,64 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+<div class="dynamic-columns list-gap-20 list-with-separator">
+    {{ range .Containers }}
+    <div class="docker-container flex items-center gap-15">
+        <div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem">
+            <img class="docker-container-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
+            <div data-popover-html>
+                <div class="color-highlight text-truncate block">{{ .Image }}</div>
+                <div>{{ .StateText }}</div>
+                {{ if .Children }}
+                <ul class="list list-gap-4 margin-top-10">
+                    {{ range .Children }}
+                    <li class="flex gap-7 items-center">
+                        <div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
+                        <div class="color-highlight">{{ .Title }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
+                    </li>
+                    {{ end }}
+                </ul>
+                {{ end }}
+            </div>
+        </div>
+
+        <div class="min-width-0">
+            {{ if .URL }}
+            <a href="{{ .URL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
+            {{ else }}
+            <div class="color-highlight text-truncate size-title-dynamic">{{ .Title }}</div>
+            {{ end }}
+            {{ if .Description }}
+            <div class="text-truncate">{{ .Description }}</div>
+            {{ end }}
+        </div>
+
+        <div class="margin-left-auto shrink-0" data-popover-type="text" data-popover-position="above" data-popover-text="{{ .State }}">
+        {{ template "state-icon" .StateIcon }}
+        </div>
+    </div>
+    {{ else }}
+    <div class="text-center">No containers available to show.</div>
+    {{ end }}
+</div>
+{{ end }}
+
+{{ define "state-icon" }}
+{{ if eq . "ok" }}
+<svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
+    <path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
+</svg>
+{{ else if eq . "warn" }}
+<svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
+    <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
+</svg>
+{{ else if eq . "paused" }}
+<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
+    <path fill-rule="evenodd" d="M2 10a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm5-2.25A.75.75 0 0 1 7.75 7h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Zm4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Z" clip-rule="evenodd" />
+</svg>
+{{ else }}
+<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
+    <path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
+</svg>
+{{ end }}
+{{ end }}

+ 0 - 0
internal/assets/templates/document.html → internal/glance/templates/document.html


+ 0 - 0
internal/assets/templates/extension.html → internal/glance/templates/extension.html


+ 2 - 2
internal/assets/templates/forum-posts.html → internal/glance/templates/forum-posts.html

@@ -12,7 +12,7 @@
                 </svg>
                 {{ else if ne .ThumbnailUrl "" }}
                 <img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
-                {{ else if .HasTargetUrl }}
+                {{ else if ne "" .TargetUrl }}
                 <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>
@@ -37,7 +37,7 @@
                     <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
                     <li>{{ .Score | formatNumber }} points</li>
                     <li>{{ .CommentCount | formatNumber }} comments</li>
-                    {{ if .HasTargetUrl }}
+                    {{ if ne "" .TargetUrl }}
                     <li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
                     {{ end }}
                 </ul>

+ 1 - 1
internal/assets/templates/group.html → internal/glance/templates/group.html

@@ -6,7 +6,7 @@
 <div class="widget-group-header">
     <div class="widget-header gap-20">
         {{ range $i, $widget := .Widgets }}
-            <button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}">{{ $widget.Title }}</button>
+            <button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}"{{ if ne "" .TitleURL }} data-title-url="{{ .TitleURL }}"{{ end }}>{{ $widget.Title }}</button>
         {{ end }}
     </div>
 </div>

+ 0 - 0
internal/assets/templates/iframe.html → internal/glance/templates/iframe.html


+ 1 - 1
internal/assets/templates/markets.html → internal/glance/templates/markets.html

@@ -6,7 +6,7 @@
     <div class="flex items-center gap-15">
         <div class="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 title="{{ .Name }}" class="text-truncate">{{ .Name }}</div>
         </div>
 
         <a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>

+ 39 - 0
internal/glance/templates/monitor-compact.html

@@ -0,0 +1,39 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+{{ if not (and .ShowFailingOnly (not .HasFailing)) }}
+<ul class="dynamic-columns list-gap-8">
+    {{ range .Sites }}
+    {{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }}{{ continue }}{{ end }}
+    <div class="flex items-center gap-12">
+        {{ template "site" . }}
+    </div>
+    {{ end }}
+</ul>
+{{ else }}
+<div class="flex items-center justify-center gap-10 padding-block-5">
+    <p>All sites are online</p>
+    <svg class="shrink-0" style="width: 1.7rem;" 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>
+{{ end }}
+{{ end }}
+
+{{ define "site" }}
+<a class="size-title-dynamic color-highlight text-truncate block grow" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
+{{ if not .Status.TimedOut }}<div>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</div>{{ end }}
+{{ if eq .StatusStyle "ok" }}
+<div class="monitor-site-status-icon-compact" title="{{ .Status.Code }}">
+    <svg fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
+        <path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
+    </svg>
+</div>
+{{ else }}
+<div class="monitor-site-status-icon-compact" title="{{ if .Status.Error }}{{ .Status.Error }}{{ else }}{{ .Status.Code }}{{ end }}">
+    <svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
+        <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
+    </svg>
+</div>
+{{ end }}
+{{ end }}

+ 6 - 6
internal/assets/templates/monitor.html → internal/glance/templates/monitor.html

@@ -21,8 +21,8 @@
 {{ end }}
 
 {{ define "site" }}
-{{ if .IconUrl }}
-<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
+{{ if .Icon.URL }}
+<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
 {{ end }}
 <div class="min-width-0">
     <a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
@@ -39,14 +39,14 @@
 </div>
 {{ if eq .StatusStyle "ok" }}
 <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 fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
+        <path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" 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 fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
+        <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
     </svg>
 </div>
 {{ end }}

+ 0 - 0
internal/assets/templates/content.html → internal/glance/templates/page-content.html


+ 6 - 3
internal/assets/templates/page.html → internal/glance/templates/page.html

@@ -14,10 +14,13 @@
 {{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }}
 
 {{ define "document-head-after" }}
-{{ template "page-style-overrides.gotmpl" . }}
+{{ .App.ParsedThemeStyle }}
+
 {{ if ne "" .App.Config.Theme.CustomCSSFile }}
 <link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
 {{ end }}
+
+{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
 {{ end }}
 
 {{ define "navigation-links" }}
@@ -44,9 +47,9 @@
         <div class="mobile-navigation-icons">
             <a class="mobile-navigation-label" href="#top">↑</a>
             {{ range $i, $column := .Page.Columns }}
-            <label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
+            <label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
             {{ end }}
-            <label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
+            <label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"{{ if .Page.ExpandMobilePageNavigation }} checked{{ end }}><div class="hamburger-icon"></div></label>
         </div>
         <div class="mobile-navigation-page-links">
             {{ template "navigation-links" . }}

+ 0 - 0
internal/assets/templates/reddit-horizontal-cards.html → internal/glance/templates/reddit-horizontal-cards.html


+ 0 - 0
internal/assets/templates/reddit-vertical-cards.html → internal/glance/templates/reddit-vertical-cards.html


+ 2 - 2
internal/assets/templates/releases.html → internal/glance/templates/releases.html

@@ -1,13 +1,13 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
-<ul class="list list-gap-10 collapsible-container single-line-titles" data-collapse-after="{{ .CollapseAfter }}">
+<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
     {{ range .Releases }}
     <li>
         <div class="flex items-center gap-10">
             <a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
             {{ if $.ShowSourceIcon }}
-            <img class="simple-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
+            <img class="flat-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
             {{ end }}
         </div>
         <ul class="list-horizontal-text">

+ 20 - 20
internal/assets/templates/repository.html → internal/glance/templates/repository.html

@@ -1,58 +1,58 @@
 {{ 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>
+<a class="size-h4 color-highlight" href="https://github.com/{{ $.Repository.Name }}" target="_blank" rel="noreferrer">{{ .Repository.Name }}</a>
 <ul class="list-horizontal-text">
-    <li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
-    <li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
+    <li>{{ .Repository.Stars | formatNumber }} stars</li>
+    <li>{{ .Repository.Forks | formatNumber }} forks</li>
 </ul>
 
-{{ if gt (len .RepositoryDetails.Commits) 0 }}
-<hr class="margin-block-10">
-<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
+{{ if gt (len .Repository.Commits) 0 }}
+<hr class="margin-block-8">
+<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
 <div class="flex gap-7 size-h5 margin-top-3">
     <ul class="list list-gap-2">
-        {{ range .RepositoryDetails.Commits }}
+        {{ range .Repository.Commits }}
         <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
         {{ end }}
     </ul>
     <ul class="list list-gap-2 min-width-0">
-        {{ range .RepositoryDetails.Commits }}
-        <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
+        {{ range .Repository.Commits }}
+        <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
         {{ end }}
     </ul>
 </div>
 {{ end }}
 
-{{ 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>
+{{ if gt (len .Repository.PullRequests) 0 }}
+<hr class="margin-block-8">
+<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)</a>
 <div class="flex gap-7 size-h5 margin-top-3">
     <ul class="list list-gap-2">
-        {{ range .RepositoryDetails.PullRequests }}
+        {{ range .Repository.PullRequests }}
         <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></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>
+        {{ range .Repository.PullRequests }}
+        <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
         {{ end }}
     </ul>
 </div>
 {{ end }}
 
-{{ if gt (len .RepositoryDetails.Issues) 0 }}
+{{ if gt (len .Repository.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>
+<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .Repository.OpenIssues | formatNumber }} total)</a>
 <div class="flex gap-7 size-h5 margin-top-3">
     <ul class="list list-gap-2">
-        {{ range .RepositoryDetails.Issues }}
+        {{ range .Repository.Issues }}
         <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></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>
+        {{ range .Repository.Issues }}
+        <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
         {{ end }}
     </ul>
 </div>

+ 0 - 0
internal/assets/templates/rss-detailed-list.html → internal/glance/templates/rss-detailed-list.html


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


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


+ 0 - 0
internal/assets/templates/rss-list.html → internal/glance/templates/rss-list.html


+ 0 - 0
internal/assets/templates/search.html → internal/glance/templates/search.html


+ 11 - 0
internal/glance/templates/split-column.html

@@ -0,0 +1,11 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
+
+{{ define "widget-content" }}
+<div class="masonry" data-max-columns="{{ .MaxColumns }}">
+{{ range .Widgets }}
+    {{ .Render }}
+{{ end }}
+</div>
+{{ end }}

+ 14 - 0
internal/glance/templates/theme-style.gotmpl

@@ -0,0 +1,14 @@
+<style>
+:root {
+    {{ if .BackgroundColor }}
+    --bgh: {{ .BackgroundColor.Hue }};
+    --bgs: {{ .BackgroundColor.Saturation }}%;
+    --bgl: {{ .BackgroundColor.Lightness }}%;
+    {{ end }}
+    {{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
+    {{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
+    {{ if .PrimaryColor }}--color-primary: {{ .PrimaryColor.AsCSSValue }};{{ end }}
+    {{ if .PositiveColor }}--color-positive: {{ .PositiveColor.AsCSSValue }};{{ end }}
+    {{ if .NegativeColor }}--color-negative: {{ .NegativeColor.AsCSSValue }};{{ end }}
+}
+</style>

+ 8 - 2
internal/assets/templates/twitch-channels.html → internal/glance/templates/twitch-channels.html

@@ -5,9 +5,15 @@
     {{ range .Channels }}
     <li>
         <div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
-            <div class="twitch-channel-avatar-container">
+            <div class="twitch-channel-avatar-container"{{ if .IsLive }} data-popover-type="html" data-popover-position="above" data-popover-margin="0.15rem" data-popover-offset="0.2"{{ end }}>
+                {{ if .IsLive }}
+                <div data-popover-html>
+                    <img class="twitch-stream-preview" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_{{ .Login }}-440x248.jpg" loading="lazy" alt="">
+                    <p class="margin-top-10 color-highlight text-truncate-3-lines">{{ .StreamTitle }}</p>
+                </div>
+                {{ end }}
                 {{ if .Exists }}
-                <a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
+                <a href="https://twitch.tv/{{ .Login }}" target="_blank" rel="noreferrer">
                     <img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
                 </a>
                 {{ else }}

+ 0 - 0
internal/assets/templates/twitch-games-list.html → internal/glance/templates/twitch-games-list.html


+ 44 - 0
internal/glance/templates/v0.7-update-notice-page.html

@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <link rel="stylesheet" href="static/main.css">
+    <title>Update notice</title>
+    <style>
+        body {
+            display: flex;
+            align-items: center;
+            justify-content: center;
+        }
+
+        .content-bounds {
+            max-width: 700px;
+            margin-top: -10rem;
+        }
+
+        .comfy-line-height {
+            line-height: 1.9;
+        }
+    </style>
+</head>
+<body>
+
+<!-- TODO: update - add links -->
+
+<div class="content-bounds color-highlight">
+    <p class="uppercase size-h5 color-negative padding-inline-widget">UPDATE NOTICE</p>
+    <div class="widget-content-frame margin-top-10 padding-widget">
+        <p class="comfy-line-height">
+            The default location of glance.yml in the Docker image has
+            changed since v0.7.0, please see the <a class="color-primary" href="#">migration guide</a>
+            for instructions or visit the <a class="color-primary" href="#">release notes</a>
+            to find out more about why this change was necessary. Sorry for the inconvenience.
+        </p>
+
+        <p class="margin-top-15 color-base">Migration should take around 5 minutes.</p>
+    </div>
+</div>
+
+</body>
+</html>

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


+ 0 - 0
internal/assets/templates/videos-grid.html → internal/glance/templates/videos-grid.html


+ 0 - 0
internal/assets/templates/videos.html → internal/glance/templates/videos.html


+ 0 - 0
internal/assets/templates/weather.html → internal/glance/templates/weather.html


+ 3 - 1
internal/assets/templates/widget-base.html → internal/glance/templates/widget-base.html

@@ -15,7 +15,9 @@
         {{ else }}
             <div class="widget-error-header">
                 <div class="color-negative size-h3">ERROR</div>
-                <div class="widget-error-icon"></div>
+                <svg class="widget-error-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
+                    <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
+                </svg>
             </div>
             <p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
         {{ end}}

+ 72 - 9
internal/feed/utils.go → internal/glance/utils.go

@@ -1,19 +1,19 @@
-package feed
+package glance
 
 import (
-	"errors"
+	"bytes"
 	"fmt"
+	"html/template"
+	"net/http"
 	"net/url"
+	"os"
 	"regexp"
 	"slices"
 	"strings"
 	"time"
 )
 
-var (
-	ErrNoContent      = errors.New("failed to retrieve any content")
-	ErrPartialContent = errors.New("failed to retrieve some of the content")
-)
+var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
 
 func percentChange(current, previous float64) float64 {
 	return (current/previous - 1) * 100
@@ -25,7 +25,6 @@ func extractDomainFromUrl(u string) string {
 	}
 
 	parsed, err := url.Parse(u)
-
 	if err != nil {
 		return ""
 	}
@@ -33,7 +32,7 @@ func extractDomainFromUrl(u string) string {
 	return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
 }
 
-func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
+func svgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
 	if len(values) < 2 {
 		return ""
 	}
@@ -86,6 +85,21 @@ func stripURLScheme(url string) string {
 	return urlSchemePattern.ReplaceAllString(url, "")
 }
 
+func isRunningInsideDockerContainer() bool {
+	_, err := os.Stat("/.dockerenv")
+	return err == nil
+}
+
+func prefixStringLines(prefix string, s string) string {
+	lines := strings.Split(s, "\n")
+
+	for i, line := range lines {
+		lines[i] = prefix + line
+	}
+
+	return strings.Join(lines, "\n")
+}
+
 func limitStringLength(s string, max int) (string, bool) {
 	asRunes := []rune(s)
 
@@ -98,7 +112,6 @@ func limitStringLength(s string, max int) (string, bool) {
 
 func parseRFC3339Time(t string) time.Time {
 	parsed, err := time.Parse(time.RFC3339, t)
-
 	if err != nil {
 		return time.Now()
 	}
@@ -106,6 +119,14 @@ func parseRFC3339Time(t string) time.Time {
 	return parsed
 }
 
+func boolToString(b bool, trueValue, falseValue string) string {
+	if b {
+		return trueValue
+	}
+
+	return falseValue
+}
+
 func normalizeVersionFormat(version string) string {
 	version = strings.ToLower(strings.TrimSpace(version))
 
@@ -115,3 +136,45 @@ func normalizeVersionFormat(version string) string {
 
 	return version
 }
+
+func titleToSlug(s string) string {
+	s = strings.ToLower(s)
+	s = sequentialWhitespacePattern.ReplaceAllString(s, "-")
+	s = strings.Trim(s, "-")
+
+	return s
+}
+
+func fileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler {
+	server := http.FileServer(fs)
+	cacheControlValue := fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds()))
+
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// TODO: fix always setting cache control even if the file doesn't exist
+		w.Header().Set("Cache-Control", cacheControlValue)
+		server.ServeHTTP(w, r)
+	})
+}
+
+func executeTemplateToHTML(t *template.Template, data interface{}) (template.HTML, error) {
+	var b bytes.Buffer
+
+	err := t.Execute(&b, data)
+	if err != nil {
+		return "", fmt.Errorf("executing template: %w", err)
+	}
+
+	return template.HTML(b.String()), nil
+}
+
+func stringToBool(s string) bool {
+	return s == "true" || s == "yes"
+}
+
+func itemAtIndexOrDefault[T any](items []T, index int, def T) T {
+	if index >= len(items) {
+		return def
+	}
+
+	return items[index]
+}

+ 34 - 0
internal/glance/widget-bookmarks.go

@@ -0,0 +1,34 @@
+package glance
+
+import (
+	"html/template"
+)
+
+var bookmarksWidgetTemplate = mustParseTemplate("bookmarks.html", "widget-base.html")
+
+type bookmarksWidget struct {
+	widgetBase `yaml:",inline"`
+	cachedHTML template.HTML `yaml:"-"`
+	Groups     []struct {
+		Title string         `yaml:"title"`
+		Color *hslColorField `yaml:"color"`
+		Links []struct {
+			Title     string          `yaml:"title"`
+			URL       string          `yaml:"url"`
+			Icon      customIconField `yaml:"icon"`
+			SameTab   bool            `yaml:"same-tab"`
+			HideArrow bool            `yaml:"hide-arrow"`
+		} `yaml:"links"`
+	} `yaml:"groups"`
+}
+
+func (widget *bookmarksWidget) initialize() error {
+	widget.withTitle("Bookmarks").withError(nil)
+	widget.cachedHTML = widget.renderTemplate(widget, bookmarksWidgetTemplate)
+
+	return nil
+}
+
+func (widget *bookmarksWidget) Render() template.HTML {
+	return widget.cachedHTML
+}

+ 86 - 0
internal/glance/widget-calendar.go

@@ -0,0 +1,86 @@
+package glance
+
+import (
+	"context"
+	"html/template"
+	"time"
+)
+
+var calendarWidgetTemplate = mustParseTemplate("calendar.html", "widget-base.html")
+
+type calendarWidget struct {
+	widgetBase  `yaml:",inline"`
+	Calendar    *calendar
+	StartSunday bool `yaml:"start-sunday"`
+}
+
+func (widget *calendarWidget) initialize() error {
+	widget.withTitle("Calendar").withCacheOnTheHour()
+
+	return nil
+}
+
+func (widget *calendarWidget) update(ctx context.Context) {
+	widget.Calendar = newCalendar(time.Now(), widget.StartSunday)
+	widget.withError(nil).scheduleNextUpdate()
+}
+
+func (widget *calendarWidget) Render() template.HTML {
+	return widget.renderTemplate(widget, calendarWidgetTemplate)
+}
+
+type calendar struct {
+	CurrentDay        int
+	CurrentWeekNumber int
+	CurrentMonthName  string
+	CurrentYear       int
+	Days              []int
+}
+
+// TODO: very inflexible, refactor to allow more customizability
+// TODO: allow changing between showing the previous and next week and the entire month
+func newCalendar(now time.Time, startSunday bool) *calendar {
+	year, week := now.ISOWeek()
+	weekday := now.Weekday()
+	if !startSunday {
+		weekday = (weekday + 6) % 7 // Shift Monday to 0
+	}
+
+	currentMonthDays := daysInMonth(now.Month(), year)
+
+	var previousMonthDays int
+
+	if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
+		previousMonthDays = daysInMonth(12, year-1)
+	} else {
+		previousMonthDays = daysInMonth(previousMonthNumber, year)
+	}
+
+	startDaysFrom := now.Day() - int(weekday) - 7
+
+	days := make([]int, 21)
+
+	for i := 0; i < 21; i++ {
+		day := startDaysFrom + i
+
+		if day < 1 {
+			day = previousMonthDays + day
+		} else if day > currentMonthDays {
+			day = day - currentMonthDays
+		}
+
+		days[i] = day
+	}
+
+	return &calendar{
+		CurrentDay:        now.Day(),
+		CurrentWeekNumber: week,
+		CurrentMonthName:  now.Month().String(),
+		CurrentYear:       year,
+		Days:              days,
+	}
+}
+
+func daysInMonth(m time.Month, year int) int {
+	return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
+}

+ 74 - 16
internal/feed/changedetection.go → internal/glance/widget-changedetection.go

@@ -1,7 +1,9 @@
-package feed
+package glance
 
 import (
+	"context"
 	"fmt"
+	"html/template"
 	"log/slog"
 	"net/http"
 	"sort"
@@ -9,7 +11,65 @@ import (
 	"time"
 )
 
-type ChangeDetectionWatch struct {
+var changeDetectionWidgetTemplate = mustParseTemplate("change-detection.html", "widget-base.html")
+
+type changeDetectionWidget struct {
+	widgetBase       `yaml:",inline"`
+	ChangeDetections changeDetectionWatchList `yaml:"-"`
+	WatchUUIDs       []string                 `yaml:"watches"`
+	InstanceURL      string                   `yaml:"instance-url"`
+	Token            optionalEnvField         `yaml:"token"`
+	Limit            int                      `yaml:"limit"`
+	CollapseAfter    int                      `yaml:"collapse-after"`
+}
+
+func (widget *changeDetectionWidget) initialize() error {
+	widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour)
+
+	if widget.Limit <= 0 {
+		widget.Limit = 10
+	}
+
+	if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
+		widget.CollapseAfter = 5
+	}
+
+	if widget.InstanceURL == "" {
+		widget.InstanceURL = "https://www.changedetection.io"
+	}
+
+	return nil
+}
+
+func (widget *changeDetectionWidget) update(ctx context.Context) {
+	if len(widget.WatchUUIDs) == 0 {
+		uuids, err := fetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token))
+
+		if !widget.canContinueUpdateAfterHandlingErr(err) {
+			return
+		}
+
+		widget.WatchUUIDs = uuids
+	}
+
+	watches, err := fetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token))
+
+	if !widget.canContinueUpdateAfterHandlingErr(err) {
+		return
+	}
+
+	if len(watches) > widget.Limit {
+		watches = watches[:widget.Limit]
+	}
+
+	widget.ChangeDetections = watches
+}
+
+func (widget *changeDetectionWidget) Render() template.HTML {
+	return widget.renderTemplate(widget, changeDetectionWidgetTemplate)
+}
+
+type changeDetectionWatch struct {
 	Title        string
 	URL          string
 	LastChanged  time.Time
@@ -17,9 +77,9 @@ type ChangeDetectionWatch struct {
 	PreviousHash string
 }
 
-type ChangeDetectionWatches []ChangeDetectionWatch
+type changeDetectionWatchList []changeDetectionWatch
 
-func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches {
+func (r changeDetectionWatchList) sortByNewest() changeDetectionWatchList {
 	sort.Slice(r, func(i, j int) bool {
 		return r[i].LastChanged.After(r[j].LastChanged)
 	})
@@ -35,15 +95,14 @@ type changeDetectionResponseJson struct {
 	PreviousHash string `json:"previous_md5"`
 }
 
-func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
+func fetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
 	request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
 
 	if token != "" {
 		request.Header.Add("x-api-key", token)
 	}
 
-	uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request)
-
+	uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultHTTPClient, request)
 	if err != nil {
 		return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
 	}
@@ -57,8 +116,8 @@ func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]str
 	return uuids, nil
 }
 
-func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
-	watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
+func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (changeDetectionWatchList, error) {
+	watches := make(changeDetectionWatchList, 0, len(requestedWatchIDs))
 
 	if len(requestedWatchIDs) == 0 {
 		return watches, nil
@@ -76,10 +135,9 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
 		requests[i] = request
 	}
 
-	task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient)
+	task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultHTTPClient)
 	job := newJob(task, requests).withWorkers(15)
 	responses, errs, err := workerPoolDo(job)
-
 	if err != nil {
 		return nil, err
 	}
@@ -89,13 +147,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
 	for i := range responses {
 		if errs[i] != nil {
 			failed++
-			slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL)
+			slog.Error("Failed to fetch or parse change detection watch", "url", requests[i].URL, "error", errs[i])
 			continue
 		}
 
 		watchJson := responses[i]
 
-		watch := ChangeDetectionWatch{
+		watch := changeDetectionWatch{
 			URL:     watchJson.URL,
 			DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
 		}
@@ -126,13 +184,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
 	}
 
 	if len(watches) == 0 {
-		return nil, ErrNoContent
+		return nil, errNoContent
 	}
 
-	watches.SortByNewest()
+	watches.sortByNewest()
 
 	if failed > 0 {
-		return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed)
+		return watches, fmt.Errorf("%w: could not get %d watches", errPartialContent, failed)
 	}
 
 	return watches, nil

+ 11 - 13
internal/widget/clock.go → internal/glance/widget-clock.go

@@ -1,15 +1,15 @@
-package widget
+package glance
 
 import (
 	"errors"
 	"fmt"
 	"html/template"
 	"time"
-
-	"github.com/glanceapp/glance/internal/assets"
 )
 
-type Clock struct {
+var clockWidgetTemplate = mustParseTemplate("clock.html", "widget-base.html")
+
+type clockWidget struct {
 	widgetBase `yaml:",inline"`
 	cachedHTML template.HTML `yaml:"-"`
 	HourFormat string        `yaml:"hour-format"`
@@ -19,32 +19,30 @@ type Clock struct {
 	} `yaml:"timezones"`
 }
 
-func (widget *Clock) Initialize() error {
+func (widget *clockWidget) initialize() error {
 	widget.withTitle("Clock").withError(nil)
 
 	if widget.HourFormat == "" {
 		widget.HourFormat = "24h"
 	} else if widget.HourFormat != "12h" && widget.HourFormat != "24h" {
-		return errors.New("invalid hour format for clock widget, must be either 12h or 24h")
+		return errors.New("hour-format must be either 12h or 24h")
 	}
 
 	for t := range widget.Timezones {
 		if widget.Timezones[t].Timezone == "" {
-			return errors.New("missing timezone value for clock widget")
+			return errors.New("missing timezone value")
 		}
 
-		_, err := time.LoadLocation(widget.Timezones[t].Timezone)
-
-		if err != nil {
-			return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err)
+		if _, err := time.LoadLocation(widget.Timezones[t].Timezone); err != nil {
+			return fmt.Errorf("invalid timezone '%s': %v", widget.Timezones[t].Timezone, err)
 		}
 	}
 
-	widget.cachedHTML = widget.render(widget, assets.ClockTemplate)
+	widget.cachedHTML = widget.renderTemplate(widget, clockWidgetTemplate)
 
 	return nil
 }
 
-func (widget *Clock) Render() template.HTML {
+func (widget *clockWidget) Render() template.HTML {
 	return widget.cachedHTML
 }

+ 58 - 0
internal/glance/widget-container.go

@@ -0,0 +1,58 @@
+package glance
+
+import (
+	"context"
+	"sync"
+	"time"
+)
+
+type containerWidgetBase struct {
+	Widgets widgets `yaml:"widgets"`
+}
+
+func (widget *containerWidgetBase) _initializeWidgets() error {
+	for i := range widget.Widgets {
+		if err := widget.Widgets[i].initialize(); err != nil {
+			return formatWidgetInitError(err, widget.Widgets[i])
+		}
+	}
+
+	return nil
+}
+
+func (widget *containerWidgetBase) _update(ctx context.Context) {
+	var wg sync.WaitGroup
+	now := time.Now()
+
+	for w := range widget.Widgets {
+		widget := widget.Widgets[w]
+
+		if !widget.requiresUpdate(&now) {
+			continue
+		}
+
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			widget.update(ctx)
+		}()
+	}
+
+	wg.Wait()
+}
+
+func (widget *containerWidgetBase) _setProviders(providers *widgetProviders) {
+	for i := range widget.Widgets {
+		widget.Widgets[i].setProviders(providers)
+	}
+}
+
+func (widget *containerWidgetBase) _requiresUpdate(now *time.Time) bool {
+	for i := range widget.Widgets {
+		if widget.Widgets[i].requiresUpdate(now) {
+			return true
+		}
+	}
+
+	return false
+}

+ 208 - 0
internal/glance/widget-custom-api.go

@@ -0,0 +1,208 @@
+package glance
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"html/template"
+	"io"
+	"log/slog"
+	"net/http"
+	"time"
+
+	"github.com/tidwall/gjson"
+)
+
+var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
+
+type customAPIWidget struct {
+	widgetBase       `yaml:",inline"`
+	URL              optionalEnvField            `yaml:"url"`
+	Template         string                      `yaml:"template"`
+	Frameless        bool                        `yaml:"frameless"`
+	Headers          map[string]optionalEnvField `yaml:"headers"`
+	APIRequest       *http.Request               `yaml:"-"`
+	compiledTemplate *template.Template          `yaml:"-"`
+	CompiledHTML     template.HTML               `yaml:"-"`
+}
+
+func (widget *customAPIWidget) initialize() error {
+	widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
+
+	if widget.URL == "" {
+		return errors.New("URL is required")
+	}
+
+	if widget.Template == "" {
+		return errors.New("template is required")
+	}
+
+	compiledTemplate, err := template.New("").Funcs(customAPITemplateFuncs).Parse(widget.Template)
+	if err != nil {
+		return fmt.Errorf("parsing template: %w", err)
+	}
+
+	widget.compiledTemplate = compiledTemplate
+
+	req, err := http.NewRequest(http.MethodGet, widget.URL.String(), nil)
+	if err != nil {
+		return err
+	}
+
+	for key, value := range widget.Headers {
+		req.Header.Add(key, value.String())
+	}
+
+	widget.APIRequest = req
+
+	return nil
+}
+
+func (widget *customAPIWidget) update(ctx context.Context) {
+	compiledHTML, err := fetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
+	if !widget.canContinueUpdateAfterHandlingErr(err) {
+		return
+	}
+
+	widget.CompiledHTML = compiledHTML
+}
+
+func (widget *customAPIWidget) Render() template.HTML {
+	return widget.renderTemplate(widget, customAPIWidgetTemplate)
+}
+
+func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
+	emptyBody := template.HTML("")
+
+	resp, err := defaultHTTPClient.Do(req)
+	if err != nil {
+		return emptyBody, err
+	}
+	defer resp.Body.Close()
+
+	bodyBytes, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return emptyBody, err
+	}
+
+	body := string(bodyBytes)
+
+	if !gjson.Valid(body) {
+		truncatedBody, isTruncated := limitStringLength(body, 100)
+		if isTruncated {
+			truncatedBody += "... <truncated>"
+		}
+
+		slog.Error("Invalid response JSON in custom API widget", "url", req.URL.String(), "body", truncatedBody)
+		return emptyBody, errors.New("invalid response JSON")
+	}
+
+	var templateBuffer bytes.Buffer
+
+	data := CustomAPITemplateData{
+		JSON:     decoratedGJSONResult{gjson.Parse(body)},
+		Response: resp,
+	}
+
+	err = tmpl.Execute(&templateBuffer, &data)
+	if err != nil {
+		return emptyBody, err
+	}
+
+	return template.HTML(templateBuffer.String()), nil
+}
+
+type decoratedGJSONResult struct {
+	gjson.Result
+}
+
+type CustomAPITemplateData struct {
+	JSON     decoratedGJSONResult
+	Response *http.Response
+}
+
+func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult {
+	decoratedResults := make([]decoratedGJSONResult, len(results))
+
+	for i, result := range results {
+		decoratedResults[i] = decoratedGJSONResult{result}
+	}
+
+	return decoratedResults
+}
+
+func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
+	if key == "" {
+		return gJsonResultArrayToDecoratedResultArray(r.Result.Array())
+	}
+
+	return gJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
+}
+
+func (r *decoratedGJSONResult) String(key string) string {
+	if key == "" {
+		return r.Result.String()
+	}
+
+	return r.Get(key).String()
+}
+
+func (r *decoratedGJSONResult) Int(key string) int64 {
+	if key == "" {
+		return r.Result.Int()
+	}
+
+	return r.Get(key).Int()
+}
+
+func (r *decoratedGJSONResult) Float(key string) float64 {
+	if key == "" {
+		return r.Result.Float()
+	}
+
+	return r.Get(key).Float()
+}
+
+func (r *decoratedGJSONResult) Bool(key string) bool {
+	if key == "" {
+		return r.Result.Bool()
+	}
+
+	return r.Get(key).Bool()
+}
+
+var customAPITemplateFuncs = func() template.FuncMap {
+	funcs := template.FuncMap{
+		"toFloat": func(a int64) float64 {
+			return float64(a)
+		},
+		"toInt": func(a float64) int64 {
+			return int64(a)
+		},
+		"mathexpr": func(left float64, op string, right float64) float64 {
+			if right == 0 {
+				return 0
+			}
+
+			switch op {
+			case "+":
+				return left + right
+			case "-":
+				return left - right
+			case "*":
+				return left * right
+			case "/":
+				return left / right
+			default:
+				return 0
+			}
+		},
+	}
+
+	for key, value := range globalTemplateFunctions {
+		funcs[key] = value
+	}
+
+	return funcs
+}()

+ 352 - 0
internal/glance/widget-dns-stats.go

@@ -0,0 +1,352 @@
+package glance
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"html/template"
+	"log/slog"
+	"net/http"
+	"sort"
+	"strings"
+	"time"
+)
+
+var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html")
+
+type dnsStatsWidget struct {
+	widgetBase `yaml:",inline"`
+
+	TimeLabels [8]string `yaml:"-"`
+	Stats      *dnsStats `yaml:"-"`
+
+	HourFormat    string           `yaml:"hour-format"`
+	Service       string           `yaml:"service"`
+	AllowInsecure bool             `yaml:"allow-insecure"`
+	URL           optionalEnvField `yaml:"url"`
+	Token         optionalEnvField `yaml:"token"`
+	Username      optionalEnvField `yaml:"username"`
+	Password      optionalEnvField `yaml:"password"`
+}
+
+func makeDNSWidgetTimeLabels(format string) [8]string {
+	now := time.Now()
+	var labels [8]string
+
+	for h := 24; h > 0; h -= 3 {
+		labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format))
+	}
+
+	return labels
+}
+
+func (widget *dnsStatsWidget) initialize() error {
+	widget.
+		withTitle("DNS Stats").
+		withTitleURL(string(widget.URL)).
+		withCacheDuration(10 * time.Minute)
+
+	if widget.Service != "adguard" && widget.Service != "pihole" {
+		return errors.New("service must be either 'adguard' or 'pihole'")
+	}
+
+	return nil
+}
+
+func (widget *dnsStatsWidget) update(ctx context.Context) {
+	var stats *dnsStats
+	var err error
+
+	if widget.Service == "adguard" {
+		stats, err = fetchAdguardStats(string(widget.URL), widget.AllowInsecure, string(widget.Username), string(widget.Password))
+	} else {
+		stats, err = fetchPiholeStats(string(widget.URL), widget.AllowInsecure, string(widget.Token))
+	}
+
+	if !widget.canContinueUpdateAfterHandlingErr(err) {
+		return
+	}
+
+	if widget.HourFormat == "24h" {
+		widget.TimeLabels = makeDNSWidgetTimeLabels("15:00")
+	} else {
+		widget.TimeLabels = makeDNSWidgetTimeLabels("3PM")
+	}
+
+	widget.Stats = stats
+}
+
+func (widget *dnsStatsWidget) Render() template.HTML {
+	return widget.renderTemplate(widget, dnsStatsWidgetTemplate)
+}
+
+type dnsStats struct {
+	TotalQueries      int
+	BlockedQueries    int
+	BlockedPercent    int
+	ResponseTime      int
+	DomainsBlocked    int
+	Series            [8]dnsStatsSeries
+	TopBlockedDomains []dnsStatsBlockedDomain
+}
+
+type dnsStatsSeries struct {
+	Queries        int
+	Blocked        int
+	PercentTotal   int
+	PercentBlocked int
+}
+
+type dnsStatsBlockedDomain struct {
+	Domain         string
+	PercentBlocked int
+}
+
+type adguardStatsResponse struct {
+	TotalQueries      int              `json:"num_dns_queries"`
+	QueriesSeries     []int            `json:"dns_queries"`
+	BlockedQueries    int              `json:"num_blocked_filtering"`
+	BlockedSeries     []int            `json:"blocked_filtering"`
+	ResponseTime      float64          `json:"avg_processing_time"`
+	TopBlockedDomains []map[string]int `json:"top_blocked_domains"`
+}
+
+func fetchAdguardStats(instanceURL string, allowInsecure bool, username, password string) (*dnsStats, error) {
+	requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats"
+
+	request, err := http.NewRequest("GET", requestURL, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	request.SetBasicAuth(username, password)
+
+	var client requestDoer
+	if !allowInsecure {
+		client = defaultHTTPClient
+	} else {
+		client = defaultInsecureHTTPClient
+	}
+
+	responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request)
+	if err != nil {
+		return nil, err
+	}
+
+	var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
+
+	stats := &dnsStats{
+		TotalQueries:      responseJson.TotalQueries,
+		BlockedQueries:    responseJson.BlockedQueries,
+		ResponseTime:      int(responseJson.ResponseTime * 1000),
+		TopBlockedDomains: make([]dnsStatsBlockedDomain, 0, topBlockedDomainsCount),
+	}
+
+	if stats.TotalQueries <= 0 {
+		return stats, nil
+	}
+
+	stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
+
+	for i := 0; i < topBlockedDomainsCount; i++ {
+		domain := responseJson.TopBlockedDomains[i]
+		var firstDomain string
+
+		for k := range domain {
+			firstDomain = k
+			break
+		}
+
+		if firstDomain == "" {
+			continue
+		}
+
+		stats.TopBlockedDomains = append(stats.TopBlockedDomains, dnsStatsBlockedDomain{
+			Domain: firstDomain,
+		})
+
+		if stats.BlockedQueries > 0 {
+			stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100)
+		}
+	}
+
+	queriesSeries := responseJson.QueriesSeries
+	blockedSeries := responseJson.BlockedSeries
+
+	const bars = 8
+	const hoursSpan = 24
+	const hoursPerBar int = hoursSpan / bars
+
+	if len(queriesSeries) > hoursSpan {
+		queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
+	} else if len(queriesSeries) < hoursSpan {
+		queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
+	}
+
+	if len(blockedSeries) > hoursSpan {
+		blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
+	} else if len(blockedSeries) < hoursSpan {
+		blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
+	}
+
+	maxQueriesInSeries := 0
+
+	for i := 0; i < bars; i++ {
+		queries := 0
+		blocked := 0
+
+		for j := 0; j < hoursPerBar; j++ {
+			queries += queriesSeries[i*hoursPerBar+j]
+			blocked += blockedSeries[i*hoursPerBar+j]
+		}
+
+		stats.Series[i] = dnsStatsSeries{
+			Queries: queries,
+			Blocked: blocked,
+		}
+
+		if queries > 0 {
+			stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
+		}
+
+		if queries > maxQueriesInSeries {
+			maxQueriesInSeries = queries
+		}
+	}
+
+	for i := 0; i < bars; i++ {
+		stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
+	}
+
+	return stats, nil
+}
+
+type piholeStatsResponse struct {
+	TotalQueries      int                     `json:"dns_queries_today"`
+	QueriesSeries     map[int64]int           `json:"domains_over_time"`
+	BlockedQueries    int                     `json:"ads_blocked_today"`
+	BlockedSeries     map[int64]int           `json:"ads_over_time"`
+	BlockedPercentage float64                 `json:"ads_percentage_today"`
+	TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
+	DomainsBlocked    int                     `json:"domains_being_blocked"`
+}
+
+// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
+// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
+type piholeTopBlockedDomains map[string]int
+
+func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
+	// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
+	// because of the UnmarshalJSON method getting called recursively
+	temp := make(map[string]int)
+
+	err := json.Unmarshal(data, &temp)
+	if err != nil {
+		*p = make(piholeTopBlockedDomains)
+	} else {
+		*p = temp
+	}
+
+	return nil
+}
+
+func fetchPiholeStats(instanceURL string, allowInsecure bool, token string) (*dnsStats, error) {
+	if token == "" {
+		return nil, errors.New("missing API token")
+	}
+
+	requestURL := strings.TrimRight(instanceURL, "/") +
+		"/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
+
+	request, err := http.NewRequest("GET", requestURL, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var client requestDoer
+	if !allowInsecure {
+		client = defaultHTTPClient
+	} else {
+		client = defaultInsecureHTTPClient
+	}
+
+	responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, request)
+	if err != nil {
+		return nil, err
+	}
+
+	stats := &dnsStats{
+		TotalQueries:   responseJson.TotalQueries,
+		BlockedQueries: responseJson.BlockedQueries,
+		BlockedPercent: int(responseJson.BlockedPercentage),
+		DomainsBlocked: responseJson.DomainsBlocked,
+	}
+
+	if len(responseJson.TopBlockedDomains) > 0 {
+		domains := make([]dnsStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains))
+
+		for domain, count := range responseJson.TopBlockedDomains {
+			domains = append(domains, dnsStatsBlockedDomain{
+				Domain:         domain,
+				PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100),
+			})
+		}
+
+		sort.Slice(domains, func(a, b int) bool {
+			return domains[a].PercentBlocked > domains[b].PercentBlocked
+		})
+
+		stats.TopBlockedDomains = domains[:min(len(domains), 5)]
+	}
+
+	// Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
+	if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 {
+		slog.Warn(
+			"DNS stats for pihole: did not get expected 144 data points",
+			"len(queries)", len(responseJson.QueriesSeries),
+			"len(blocked)", len(responseJson.BlockedSeries),
+		)
+		return stats, nil
+	}
+
+	var lowestTimestamp int64 = 0
+
+	for timestamp := range responseJson.QueriesSeries {
+		if lowestTimestamp == 0 || timestamp < lowestTimestamp {
+			lowestTimestamp = timestamp
+		}
+	}
+
+	maxQueriesInSeries := 0
+
+	for i := 0; i < 8; i++ {
+		queries := 0
+		blocked := 0
+
+		for j := 0; j < 18; j++ {
+			index := lowestTimestamp + int64(i*10800+j*600)
+
+			queries += responseJson.QueriesSeries[index]
+			blocked += responseJson.BlockedSeries[index]
+		}
+
+		if queries > maxQueriesInSeries {
+			maxQueriesInSeries = queries
+		}
+
+		stats.Series[i] = dnsStatsSeries{
+			Queries: queries,
+			Blocked: blocked,
+		}
+
+		if queries > 0 {
+			stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
+		}
+	}
+
+	for i := 0; i < 8; i++ {
+		stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
+	}
+
+	return stats, nil
+}

+ 272 - 0
internal/glance/widget-docker-containers.go

@@ -0,0 +1,272 @@
+package glance
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"html/template"
+	"net"
+	"net/http"
+	"sort"
+	"strings"
+	"time"
+)
+
+var dockerContainersWidgetTemplate = mustParseTemplate("docker-containers.html", "widget-base.html")
+
+type dockerContainersWidget struct {
+	widgetBase    `yaml:",inline"`
+	HideByDefault bool                `yaml:"hide-by-default"`
+	SockPath      string              `yaml:"sock-path"`
+	Containers    dockerContainerList `yaml:"-"`
+}
+
+func (widget *dockerContainersWidget) initialize() error {
+	widget.withTitle("Docker Containers").withCacheDuration(1 * time.Minute)
+
+	if widget.SockPath == "" {
+		widget.SockPath = "/var/run/docker.sock"
+	}
+
+	return nil
+}
+
+func (widget *dockerContainersWidget) update(ctx context.Context) {
+	containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault)
+	if !widget.canContinueUpdateAfterHandlingErr(err) {
+		return
+	}
+
+	containers.sortByStateIconThenTitle()
+	widget.Containers = containers
+}
+
+func (widget *dockerContainersWidget) Render() template.HTML {
+	return widget.renderTemplate(widget, dockerContainersWidgetTemplate)
+}
+
+const (
+	dockerContainerLabelHide        = "glance.hide"
+	dockerContainerLabelTitle       = "glance.title"
+	dockerContainerLabelURL         = "glance.url"
+	dockerContainerLabelDescription = "glance.description"
+	dockerContainerLabelSameTab     = "glance.same-tab"
+	dockerContainerLabelIcon        = "glance.icon"
+	dockerContainerLabelID          = "glance.id"
+	dockerContainerLabelParent      = "glance.parent"
+)
+
+const (
+	dockerContainerStateIconOK     = "ok"
+	dockerContainerStateIconPaused = "paused"
+	dockerContainerStateIconWarn   = "warn"
+	dockerContainerStateIconOther  = "other"
+)
+
+var dockerContainerStateIconPriorities = map[string]int{
+	dockerContainerStateIconWarn:   0,
+	dockerContainerStateIconOther:  1,
+	dockerContainerStateIconPaused: 2,
+	dockerContainerStateIconOK:     3,
+}
+
+type dockerContainerJsonResponse struct {
+	Names  []string              `json:"Names"`
+	Image  string                `json:"Image"`
+	State  string                `json:"State"`
+	Status string                `json:"Status"`
+	Labels dockerContainerLabels `json:"Labels"`
+}
+
+type dockerContainerLabels map[string]string
+
+func (l *dockerContainerLabels) getOrDefault(label, def string) string {
+	if l == nil {
+		return def
+	}
+
+	v, ok := (*l)[label]
+	if !ok {
+		return def
+	}
+
+	if v == "" {
+		return def
+	}
+
+	return v
+}
+
+type dockerContainer struct {
+	Title       string
+	URL         string
+	SameTab     bool
+	Image       string
+	State       string
+	StateText   string
+	StateIcon   string
+	Description string
+	Icon        customIconField
+	Children    dockerContainerList
+}
+
+type dockerContainerList []dockerContainer
+
+func (containers dockerContainerList) sortByStateIconThenTitle() {
+	sort.SliceStable(containers, func(a, b int) bool {
+		p := &dockerContainerStateIconPriorities
+		if containers[a].StateIcon != containers[b].StateIcon {
+			return (*p)[containers[a].StateIcon] < (*p)[containers[b].StateIcon]
+		}
+
+		return strings.ToLower(containers[a].Title) < strings.ToLower(containers[b].Title)
+	})
+}
+
+func dockerContainerStateToStateIcon(state string) string {
+	switch state {
+	case "running":
+		return dockerContainerStateIconOK
+	case "paused":
+		return dockerContainerStateIconPaused
+	case "exited", "unhealthy", "dead":
+		return dockerContainerStateIconWarn
+	default:
+		return dockerContainerStateIconOther
+	}
+}
+
+func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContainerList, error) {
+	containers, err := fetchAllDockerContainersFromSock(socketPath)
+	if err != nil {
+		return nil, fmt.Errorf("fetching containers: %w", err)
+	}
+
+	containers, children := groupDockerContainerChildren(containers, hideByDefault)
+	dockerContainers := make(dockerContainerList, 0, len(containers))
+
+	for i := range containers {
+		container := &containers[i]
+
+		dc := dockerContainer{
+			Title:       deriveDockerContainerTitle(container),
+			URL:         container.Labels.getOrDefault(dockerContainerLabelURL, ""),
+			Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""),
+			SameTab:     stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")),
+			Image:       container.Image,
+			State:       strings.ToLower(container.State),
+			StateText:   strings.ToLower(container.Status),
+			Icon:        newCustomIconField(container.Labels.getOrDefault(dockerContainerLabelIcon, "si:docker")),
+		}
+
+		if idValue := container.Labels.getOrDefault(dockerContainerLabelID, ""); idValue != "" {
+			if children, ok := children[idValue]; ok {
+				for i := range children {
+					child := &children[i]
+					dc.Children = append(dc.Children, dockerContainer{
+						Title:     deriveDockerContainerTitle(child),
+						StateText: child.Status,
+						StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)),
+					})
+				}
+			}
+		}
+
+		dc.Children.sortByStateIconThenTitle()
+
+		stateIconSupersededByChild := false
+		for i := range dc.Children {
+			if dc.Children[i].StateIcon == dockerContainerStateIconWarn {
+				dc.StateIcon = dockerContainerStateIconWarn
+				stateIconSupersededByChild = true
+				break
+			}
+		}
+		if !stateIconSupersededByChild {
+			dc.StateIcon = dockerContainerStateToStateIcon(dc.State)
+		}
+
+		dockerContainers = append(dockerContainers, dc)
+	}
+
+	return dockerContainers, nil
+}
+
+func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string {
+	if v := container.Labels.getOrDefault(dockerContainerLabelTitle, ""); v != "" {
+		return v
+	}
+
+	return strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, "n/a"), "/")
+}
+
+func groupDockerContainerChildren(
+	containers []dockerContainerJsonResponse,
+	hideByDefault bool,
+) (
+	[]dockerContainerJsonResponse,
+	map[string][]dockerContainerJsonResponse,
+) {
+	parents := make([]dockerContainerJsonResponse, 0, len(containers))
+	children := make(map[string][]dockerContainerJsonResponse)
+
+	for i := range containers {
+		container := &containers[i]
+
+		if isDockerContainerHidden(container, hideByDefault) {
+			continue
+		}
+
+		isParent := container.Labels.getOrDefault(dockerContainerLabelID, "") != ""
+		parent := container.Labels.getOrDefault(dockerContainerLabelParent, "")
+
+		if !isParent && parent != "" {
+			children[parent] = append(children[parent], *container)
+		} else {
+			parents = append(parents, *container)
+		}
+	}
+
+	return parents, children
+}
+
+func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefault bool) bool {
+	if v := container.Labels.getOrDefault(dockerContainerLabelHide, ""); v != "" {
+		return stringToBool(v)
+	}
+
+	return hideByDefault
+}
+
+func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) {
+	client := &http.Client{
+		Timeout: 3 * time.Second,
+		Transport: &http.Transport{
+			DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+				return net.Dial("unix", socketPath)
+			},
+		},
+	}
+
+	request, err := http.NewRequest("GET", "http://docker/containers/json?all=true", nil)
+	if err != nil {
+		return nil, fmt.Errorf("creating request: %w", err)
+	}
+
+	response, err := client.Do(request)
+	if err != nil {
+		return nil, fmt.Errorf("sending request to socket: %w", err)
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("non-200 response status: %s", response.Status)
+	}
+
+	var containers []dockerContainerJsonResponse
+	if err := json.NewDecoder(response.Body).Decode(&containers); err != nil {
+		return nil, fmt.Errorf("decoding response: %w", err)
+	}
+
+	return containers, nil
+}

+ 152 - 0
internal/glance/widget-extension.go

@@ -0,0 +1,152 @@
+package glance
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"html"
+	"html/template"
+	"io"
+	"log/slog"
+	"net/http"
+	"net/url"
+	"time"
+)
+
+var extensionWidgetTemplate = mustParseTemplate("extension.html", "widget-base.html")
+
+type extensionWidget struct {
+	widgetBase          `yaml:",inline"`
+	URL                 string            `yaml:"url"`
+	FallbackContentType string            `yaml:"fallback-content-type"`
+	Parameters          map[string]string `yaml:"parameters"`
+	AllowHtml           bool              `yaml:"allow-potentially-dangerous-html"`
+	Extension           extension         `yaml:"-"`
+	cachedHTML          template.HTML     `yaml:"-"`
+}
+
+func (widget *extensionWidget) initialize() error {
+	widget.withTitle("Extension").withCacheDuration(time.Minute * 30)
+
+	if widget.URL == "" {
+		return errors.New("URL is required")
+	}
+
+	if _, err := url.Parse(widget.URL); err != nil {
+		return fmt.Errorf("parsing URL: %v", err)
+	}
+
+	return nil
+}
+
+func (widget *extensionWidget) update(ctx context.Context) {
+	extension, err := fetchExtension(extensionRequestOptions{
+		URL:                 widget.URL,
+		FallbackContentType: widget.FallbackContentType,
+		Parameters:          widget.Parameters,
+		AllowHtml:           widget.AllowHtml,
+	})
+
+	widget.canContinueUpdateAfterHandlingErr(err)
+
+	widget.Extension = extension
+
+	if extension.Title != "" {
+		widget.Title = extension.Title
+	}
+
+	widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate)
+}
+
+func (widget *extensionWidget) Render() template.HTML {
+	return widget.cachedHTML
+}
+
+type extensionType int
+
+const (
+	extensionContentHTML    extensionType = iota
+	extensionContentUnknown               = iota
+)
+
+var extensionStringToType = map[string]extensionType{
+	"html": extensionContentHTML,
+}
+
+const (
+	extensionHeaderTitle       = "Widget-Title"
+	extensionHeaderContentType = "Widget-Content-Type"
+)
+
+type extensionRequestOptions struct {
+	URL                 string            `yaml:"url"`
+	FallbackContentType string            `yaml:"fallback-content-type"`
+	Parameters          map[string]string `yaml:"parameters"`
+	AllowHtml           bool              `yaml:"allow-potentially-dangerous-html"`
+}
+
+type extension struct {
+	Title   string
+	Content template.HTML
+}
+
+func convertExtensionContent(options extensionRequestOptions, content []byte, contentType extensionType) template.HTML {
+	switch contentType {
+	case extensionContentHTML:
+		if options.AllowHtml {
+			return template.HTML(content)
+		}
+
+		fallthrough
+	default:
+		return template.HTML(html.EscapeString(string(content)))
+	}
+}
+
+func fetchExtension(options extensionRequestOptions) (extension, error) {
+	request, _ := http.NewRequest("GET", options.URL, nil)
+
+	query := url.Values{}
+
+	for key, value := range options.Parameters {
+		query.Set(key, value)
+	}
+
+	request.URL.RawQuery = query.Encode()
+
+	response, err := http.DefaultClient.Do(request)
+	if err != nil {
+		slog.Error("Failed fetching extension", "url", options.URL, "error", err)
+		return extension{}, fmt.Errorf("%w: request failed: %w", errNoContent, err)
+	}
+
+	defer response.Body.Close()
+
+	body, err := io.ReadAll(response.Body)
+	if err != nil {
+		slog.Error("Failed reading response body of extension", "url", options.URL, "error", err)
+		return extension{}, fmt.Errorf("%w: could not read body: %w", errNoContent, err)
+	}
+
+	extension := extension{}
+
+	if response.Header.Get(extensionHeaderTitle) == "" {
+		extension.Title = "Extension"
+	} else {
+		extension.Title = response.Header.Get(extensionHeaderTitle)
+	}
+
+	contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)]
+
+	if !ok {
+		contentType, ok = extensionStringToType[options.FallbackContentType]
+
+		if !ok {
+			contentType = extensionContentUnknown
+		}
+	}
+
+	extension.Content = convertExtensionContent(options, body, contentType)
+
+	return extension, nil
+}

+ 52 - 0
internal/glance/widget-group.go

@@ -0,0 +1,52 @@
+package glance
+
+import (
+	"context"
+	"errors"
+	"html/template"
+	"time"
+)
+
+var groupWidgetTemplate = mustParseTemplate("group.html", "widget-base.html")
+
+type groupWidget struct {
+	widgetBase          `yaml:",inline"`
+	containerWidgetBase `yaml:",inline"`
+}
+
+func (widget *groupWidget) initialize() error {
+	widget.withError(nil)
+	widget.HideHeader = true
+
+	for i := range widget.Widgets {
+		widget.Widgets[i].setHideHeader(true)
+
+		if widget.Widgets[i].GetType() == "group" {
+			return errors.New("nested groups are not supported")
+		} else if widget.Widgets[i].GetType() == "split-column" {
+			return errors.New("split columns inside of groups are not supported")
+		}
+	}
+
+	if err := widget.containerWidgetBase._initializeWidgets(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (widget *groupWidget) update(ctx context.Context) {
+	widget.containerWidgetBase._update(ctx)
+}
+
+func (widget *groupWidget) setProviders(providers *widgetProviders) {
+	widget.containerWidgetBase._setProviders(providers)
+}
+
+func (widget *groupWidget) requiresUpdate(now *time.Time) bool {
+	return widget.containerWidgetBase._requiresUpdate(now)
+}
+
+func (widget *groupWidget) Render() template.HTML {
+	return widget.renderTemplate(widget, groupWidgetTemplate)
+}

+ 152 - 0
internal/glance/widget-hacker-news.go

@@ -0,0 +1,152 @@
+package glance
+
+import (
+	"context"
+	"fmt"
+	"html/template"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type hackerNewsWidget struct {
+	widgetBase          `yaml:",inline"`
+	Posts               forumPostList `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:"-"`
+}
+
+func (widget *hackerNewsWidget) initialize() error {
+	widget.
+		withTitle("Hacker News").
+		withTitleURL("https://news.ycombinator.com/").
+		withCacheDuration(30 * time.Minute)
+
+	if widget.Limit <= 0 {
+		widget.Limit = 15
+	}
+
+	if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
+		widget.CollapseAfter = 5
+	}
+
+	if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" {
+		widget.SortBy = "top"
+	}
+
+	return nil
+}
+
+func (widget *hackerNewsWidget) update(ctx context.Context) {
+	posts, err := fetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate)
+
+	if !widget.canContinueUpdateAfterHandlingErr(err) {
+		return
+	}
+
+	if widget.ExtraSortBy == "engagement" {
+		posts.calculateEngagement()
+		posts.sortByEngagement()
+	}
+
+	if widget.Limit < len(posts) {
+		posts = posts[:widget.Limit]
+	}
+
+	widget.Posts = posts
+}
+
+func (widget *hackerNewsWidget) Render() template.HTML {
+	return widget.renderTemplate(widget, forumPostsTemplate)
+}
+
+type hackerNewsPostResponseJson struct {
+	Id           int    `json:"id"`
+	Score        int    `json:"score"`
+	Title        string `json:"title"`
+	TargetUrl    string `json:"url,omitempty"`
+	CommentCount int    `json:"descendants"`
+	TimePosted   int64  `json:"time"`
+}
+
+func fetchHackerNewsPostIds(sort string) ([]int, error) {
+	request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
+	response, err := decodeJsonFromRequest[[]int](defaultHTTPClient, request)
+	if err != nil {
+		return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent)
+	}
+
+	return response, nil
+}
+
+func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (forumPostList, error) {
+	requests := make([]*http.Request, len(postIds))
+
+	for i, id := range postIds {
+		request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
+		requests[i] = request
+	}
+
+	task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultHTTPClient)
+	job := newJob(task, requests).withWorkers(30)
+	results, errs, err := workerPoolDo(job)
+	if err != nil {
+		return nil, err
+	}
+
+	posts := make(forumPostList, 0, len(postIds))
+
+	for i := range results {
+		if errs[i] != nil {
+			slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
+			continue
+		}
+
+		var commentsUrl string
+
+		if commentsUrlTemplate == "" {
+			commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
+		} else {
+			commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
+		}
+
+		posts = append(posts, forumPost{
+			Title:           results[i].Title,
+			DiscussionUrl:   commentsUrl,
+			TargetUrl:       results[i].TargetUrl,
+			TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
+			CommentCount:    results[i].CommentCount,
+			Score:           results[i].Score,
+			TimePosted:      time.Unix(results[i].TimePosted, 0),
+		})
+	}
+
+	if len(posts) == 0 {
+		return nil, errNoContent
+	}
+
+	if len(posts) != len(postIds) {
+		return posts, fmt.Errorf("%w could not fetch some hacker news posts", errPartialContent)
+	}
+
+	return posts, nil
+}
+
+func fetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (forumPostList, error) {
+	postIds, err := fetchHackerNewsPostIds(sort)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(postIds) > limit {
+		postIds = postIds[:limit]
+	}
+
+	return fetchHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
+}

+ 4 - 4
internal/widget/html.go → internal/glance/widget-html.go

@@ -1,20 +1,20 @@
-package widget
+package glance
 
 import (
 	"html/template"
 )
 
-type HTML struct {
+type htmlWidget struct {
 	widgetBase `yaml:",inline"`
 	Source     template.HTML `yaml:"source"`
 }
 
-func (widget *HTML) Initialize() error {
+func (widget *htmlWidget) initialize() error {
 	widget.withTitle("").withError(nil)
 
 	return nil
 }
 
-func (widget *HTML) Render() template.HTML {
+func (widget *htmlWidget) Render() template.HTML {
 	return widget.Source
 }

+ 10 - 12
internal/widget/iframe.go → internal/glance/widget-iframe.go

@@ -1,32 +1,30 @@
-package widget
+package glance
 
 import (
 	"errors"
 	"fmt"
 	"html/template"
 	"net/url"
-
-	"github.com/glanceapp/glance/internal/assets"
 )
 
-type IFrame struct {
+var iframeWidgetTemplate = mustParseTemplate("iframe.html", "widget-base.html")
+
+type iframeWidget struct {
 	widgetBase `yaml:",inline"`
 	cachedHTML template.HTML `yaml:"-"`
 	Source     string        `yaml:"source"`
 	Height     int           `yaml:"height"`
 }
 
-func (widget *IFrame) Initialize() error {
+func (widget *iframeWidget) initialize() error {
 	widget.withTitle("IFrame").withError(nil)
 
 	if widget.Source == "" {
-		return errors.New("missing source for iframe")
+		return errors.New("source is required")
 	}
 
-	_, err := url.Parse(widget.Source)
-
-	if err != nil {
-		return fmt.Errorf("invalid source for iframe: %v", err)
+	if _, err := url.Parse(widget.Source); err != nil {
+		return fmt.Errorf("parsing URL: %v", err)
 	}
 
 	if widget.Height == 50 {
@@ -35,11 +33,11 @@ func (widget *IFrame) Initialize() error {
 		widget.Height = 50
 	}
 
-	widget.cachedHTML = widget.render(widget, assets.IFrameTemplate)
+	widget.cachedHTML = widget.renderTemplate(widget, iframeWidgetTemplate)
 
 	return nil
 }
 
-func (widget *IFrame) Render() template.HTML {
+func (widget *iframeWidget) Render() template.HTML {
 	return widget.cachedHTML
 }

+ 144 - 0
internal/glance/widget-lobsters.go

@@ -0,0 +1,144 @@
+package glance
+
+import (
+	"context"
+	"html/template"
+	"net/http"
+	"strings"
+	"time"
+)
+
+type lobstersWidget struct {
+	widgetBase     `yaml:",inline"`
+	Posts          forumPostList `yaml:"-"`
+	InstanceURL    string        `yaml:"instance-url"`
+	CustomURL      string        `yaml:"custom-url"`
+	Limit          int           `yaml:"limit"`
+	CollapseAfter  int           `yaml:"collapse-after"`
+	SortBy         string        `yaml:"sort-by"`
+	Tags           []string      `yaml:"tags"`
+	ShowThumbnails bool          `yaml:"-"`
+}
+
+func (widget *lobstersWidget) initialize() error {
+	widget.withTitle("Lobsters").withCacheDuration(time.Hour)
+
+	if widget.InstanceURL == "" {
+		widget.withTitleURL("https://lobste.rs")
+	} else {
+		widget.withTitleURL(widget.InstanceURL)
+	}
+
+	if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") {
+		widget.SortBy = "hot"
+	}
+
+	if widget.Limit <= 0 {
+		widget.Limit = 15
+	}
+
+	if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
+		widget.CollapseAfter = 5
+	}
+
+	return nil
+}
+
+func (widget *lobstersWidget) update(ctx context.Context) {
+	posts, err := fetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
+
+	if !widget.canContinueUpdateAfterHandlingErr(err) {
+		return
+	}
+
+	if widget.Limit < len(posts) {
+		posts = posts[:widget.Limit]
+	}
+
+	widget.Posts = posts
+}
+
+func (widget *lobstersWidget) Render() template.HTML {
+	return widget.renderTemplate(widget, forumPostsTemplate)
+}
+
+type lobstersPostResponseJson struct {
+	CreatedAt    string   `json:"created_at"`
+	Title        string   `json:"title"`
+	URL          string   `json:"url"`
+	Score        int      `json:"score"`
+	CommentCount int      `json:"comment_count"`
+	CommentsURL  string   `json:"comments_url"`
+	Tags         []string `json:"tags"`
+}
+
+type lobstersFeedResponseJson []lobstersPostResponseJson
+
+func fetchLobstersPostsFromFeed(feedUrl string) (forumPostList, error) {
+	request, err := http.NewRequest("GET", feedUrl, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultHTTPClient, request)
+	if err != nil {
+		return nil, err
+	}
+
+	posts := make(forumPostList, 0, len(feed))
+
+	for i := range feed {
+		createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
+
+		posts = append(posts, forumPost{
+			Title:           feed[i].Title,
+			DiscussionUrl:   feed[i].CommentsURL,
+			TargetUrl:       feed[i].URL,
+			TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
+			CommentCount:    feed[i].CommentCount,
+			Score:           feed[i].Score,
+			TimePosted:      createdAt,
+			Tags:            feed[i].Tags,
+		})
+	}
+
+	if len(posts) == 0 {
+		return nil, errNoContent
+	}
+
+	return posts, nil
+}
+
+func fetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (forumPostList, error) {
+	var feedUrl string
+
+	if customURL != "" {
+		feedUrl = customURL
+	} else {
+		if instanceURL != "" {
+			instanceURL = strings.TrimRight(instanceURL, "/") + "/"
+		} else {
+			instanceURL = "https://lobste.rs/"
+		}
+
+		if sortBy == "hot" {
+			sortBy = "hottest"
+		} else if sortBy == "new" {
+			sortBy = "newest"
+		}
+
+		if len(tags) == 0 {
+			feedUrl = instanceURL + sortBy + ".json"
+		} else {
+			tags := strings.Join(tags, ",")
+			feedUrl = instanceURL + "t/" + tags + ".json"
+		}
+	}
+
+	posts, err := fetchLobstersPostsFromFeed(feedUrl)
+	if err != nil {
+		return nil, err
+	}
+
+	return posts, nil
+}

+ 205 - 0
internal/glance/widget-markets.go

@@ -0,0 +1,205 @@
+package glance
+
+import (
+	"context"
+	"fmt"
+	"html/template"
+	"log/slog"
+	"math"
+	"net/http"
+	"sort"
+	"time"
+)
+
+var marketsWidgetTemplate = mustParseTemplate("markets.html", "widget-base.html")
+
+type marketsWidget struct {
+	widgetBase     `yaml:",inline"`
+	StocksRequests []marketRequest `yaml:"stocks"`
+	MarketRequests []marketRequest `yaml:"markets"`
+	Sort           string          `yaml:"sort-by"`
+	Markets        marketList      `yaml:"-"`
+}
+
+func (widget *marketsWidget) initialize() error {
+	widget.withTitle("Markets").withCacheDuration(time.Hour)
+
+	// legacy support, remove in v0.10.0
+	if len(widget.MarketRequests) == 0 {
+		widget.MarketRequests = widget.StocksRequests
+	}
+
+	return nil
+}
+
+func (widget *marketsWidget) update(ctx context.Context) {
+	markets, err := fetchMarketsDataFromYahoo(widget.MarketRequests)
+
+	if !widget.canContinueUpdateAfterHandlingErr(err) {
+		return
+	}
+
+	if widget.Sort == "absolute-change" {
+		markets.sortByAbsChange()
+	}
+
+	if widget.Sort == "change" {
+		markets.sortByChange()
+	}
+
+	widget.Markets = markets
+}
+
+func (widget *marketsWidget) Render() template.HTML {
+	return widget.renderTemplate(widget, marketsWidgetTemplate)
+}
+
+type marketRequest struct {
+	Name       string `yaml:"name"`
+	Symbol     string `yaml:"symbol"`
+	ChartLink  string `yaml:"chart-link"`
+	SymbolLink string `yaml:"symbol-link"`
+}
+
+type market struct {
+	marketRequest
+	Currency       string
+	Price          float64
+	PercentChange  float64
+	SvgChartPoints string
+}
+
+type marketList []market
+
+func (t marketList) sortByAbsChange() {
+	sort.Slice(t, func(i, j int) bool {
+		return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
+	})
+}
+
+func (t marketList) sortByChange() {
+	sort.Slice(t, func(i, j int) bool {
+		return t[i].PercentChange > t[j].PercentChange
+	})
+}
+
+type marketResponseJson struct {
+	Chart struct {
+		Result []struct {
+			Meta struct {
+				Currency           string  `json:"currency"`
+				Symbol             string  `json:"symbol"`
+				RegularMarketPrice float64 `json:"regularMarketPrice"`
+				ChartPreviousClose float64 `json:"chartPreviousClose"`
+			} `json:"meta"`
+			Indicators struct {
+				Quote []struct {
+					Close []float64 `json:"close,omitempty"`
+				} `json:"quote"`
+			} `json:"indicators"`
+		} `json:"result"`
+	} `json:"chart"`
+}
+
+// TODO: allow changing chart time frame
+const marketChartDays = 21
+
+func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, error) {
+	requests := make([]*http.Request, 0, len(marketRequests))
+
+	for i := range marketRequests {
+		request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
+		requests = append(requests, request)
+	}
+
+	job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultHTTPClient), requests)
+	responses, errs, err := workerPoolDo(job)
+	if err != nil {
+		return nil, fmt.Errorf("%w: %v", errNoContent, err)
+	}
+
+	markets := make(marketList, 0, len(responses))
+	var failed int
+
+	for i := range responses {
+		if errs[i] != nil {
+			failed++
+			slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
+			continue
+		}
+
+		response := responses[i]
+
+		if len(response.Chart.Result) == 0 {
+			failed++
+			slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
+			continue
+		}
+
+		prices := response.Chart.Result[0].Indicators.Quote[0].Close
+
+		if len(prices) > marketChartDays {
+			prices = prices[len(prices)-marketChartDays:]
+		}
+
+		previous := response.Chart.Result[0].Meta.RegularMarketPrice
+
+		if len(prices) >= 2 && prices[len(prices)-2] != 0 {
+			previous = prices[len(prices)-2]
+		}
+
+		points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
+
+		currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
+
+		if !exists {
+			currency = response.Chart.Result[0].Meta.Currency
+		}
+
+		markets = append(markets, market{
+			marketRequest: marketRequests[i],
+			Price:         response.Chart.Result[0].Meta.RegularMarketPrice,
+			Currency:      currency,
+			PercentChange: percentChange(
+				response.Chart.Result[0].Meta.RegularMarketPrice,
+				previous,
+			),
+			SvgChartPoints: points,
+		})
+	}
+
+	if len(markets) == 0 {
+		return nil, errNoContent
+	}
+
+	if failed > 0 {
+		return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", errPartialContent, failed)
+	}
+
+	return markets, nil
+}
+
+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": "₱",
+}

Some files were not shown because too many files changed in this diff