Bläddra i källkod

Merge branch 'release/v0.6.0' into main

Svilen Markov 11 månader sedan
förälder
incheckning
9899f6b761
88 ändrade filer med 3172 tillägg och 691 borttagningar
  1. 10 0
      .dockerignore
  2. 7 0
      .github/PULL_REQUEST_TEMPLATE.md
  3. 39 0
      .github/workflows/release.yaml
  4. 72 0
      .goreleaser.yaml
  5. 7 5
      Dockerfile
  6. 3 2
      Dockerfile.goreleaser
  7. 5 7
      README.md
  8. 419 20
      docs/configuration.md
  9. 158 0
      docs/extensions.md
  10. BIN
      docs/images/change-detection-widget-preview.png
  11. BIN
      docs/images/clock-widget-preview.png
  12. BIN
      docs/images/extension-html-reusing-existing-features-preview.png
  13. BIN
      docs/images/extension-overview.png
  14. BIN
      docs/images/group-widget-preview.png
  15. BIN
      docs/images/lobsters-widget-preview.png
  16. 0 0
      docs/images/markets-widget-preview.png
  17. BIN
      docs/images/rss-widget-detailed-list-preview.png
  18. BIN
      docs/images/search-widget-bangs-preview.png
  19. BIN
      docs/images/search-widget-preview.png
  20. BIN
      docs/images/themes/kanagawa-dark.png
  21. 10 0
      docs/themes.md
  22. 4 4
      go.mod
  23. 6 6
      go.sum
  24. 41 0
      internal/assets/files.go
  25. BIN
      internal/assets/static/app-icon.png
  26. 352 58
      internal/assets/static/main.css
  27. 455 29
      internal/assets/static/main.js
  28. 14 0
      internal/assets/static/manifest.json
  29. 7 1
      internal/assets/templates.go
  30. 17 0
      internal/assets/templates/change-detection.html
  31. 30 0
      internal/assets/templates/clock.html
  32. 12 4
      internal/assets/templates/document.html
  33. 5 0
      internal/assets/templates/extension.html
  34. 23 17
      internal/assets/templates/forum-posts.html
  35. 20 0
      internal/assets/templates/group.html
  36. 9 9
      internal/assets/templates/markets.html
  37. 4 4
      internal/assets/templates/monitor.html
  38. 41 36
      internal/assets/templates/page.html
  39. 1 1
      internal/assets/templates/reddit-horizontal-cards.html
  40. 1 1
      internal/assets/templates/reddit-vertical-cards.html
  41. 3 6
      internal/assets/templates/releases.html
  42. 2 2
      internal/assets/templates/repository.html
  43. 40 0
      internal/assets/templates/rss-detailed-list.html
  44. 7 3
      internal/assets/templates/rss-horizontal-cards-2.html
  45. 7 3
      internal/assets/templates/rss-horizontal-cards.html
  46. 10 11
      internal/assets/templates/rss-list.html
  47. 24 0
      internal/assets/templates/search.html
  48. 17 16
      internal/assets/templates/twitch-channels.html
  49. 11 15
      internal/assets/templates/twitch-games-list.html
  50. 2 2
      internal/assets/templates/video-card-contents.html
  51. 2 2
      internal/assets/templates/videos-grid.html
  52. 1 1
      internal/assets/templates/videos.html
  53. 5 3
      internal/assets/templates/widget-base.html
  54. 139 0
      internal/feed/changedetection.go
  55. 97 0
      internal/feed/extension.go
  56. 4 23
      internal/feed/github.go
  57. 91 0
      internal/feed/lobsters.go
  58. 33 7
      internal/feed/monitor.go
  59. 12 7
      internal/feed/primitives.go
  60. 13 1
      internal/feed/requests.go
  61. 130 5
      internal/feed/rss.go
  62. 11 3
      internal/feed/twitch.go
  63. 18 0
      internal/feed/utils.go
  64. 19 22
      internal/feed/yahoo.go
  65. 11 9
      internal/feed/youtube.go
  66. 22 2
      internal/glance/config.go
  67. 71 15
      internal/glance/glance.go
  68. 1 1
      internal/glance/main.go
  69. 2 6
      internal/widget/bookmarks.go
  70. 66 0
      internal/widget/changedetection.go
  71. 50 0
      internal/widget/clock.go
  72. 59 0
      internal/widget/extension.go
  73. 12 0
      internal/widget/fields.go
  74. 70 0
      internal/widget/group.go
  75. 4 1
      internal/widget/hacker-news.go
  76. 20 0
      internal/widget/html.go
  77. 64 0
      internal/widget/lobsters.go
  78. 17 22
      internal/widget/monitor.go
  79. 4 1
      internal/widget/reddit.go
  80. 13 0
      internal/widget/rss.go
  81. 68 0
      internal/widget/search.go
  82. 19 14
      internal/widget/stocks.go
  83. 15 2
      internal/widget/twitch-channels.go
  84. 4 1
      internal/widget/twitch-top-games.go
  85. 13 7
      internal/widget/videos.go
  86. 26 10
      internal/widget/weather.go
  87. 71 19
      internal/widget/widget.go
  88. 0 245
      scripts/build-and-ship/main.go

+ 10 - 0
.dockerignore

@@ -0,0 +1,10 @@
+# https://docs.docker.com/build/building/context/#dockerignore-files
+# Ignore all files by default
+*
+
+# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
+!/build/
+!/internal/
+!/go.mod
+!/go.sum
+!main.go

+ 7 - 0
.github/PULL_REQUEST_TEMPLATE.md

@@ -0,0 +1,7 @@
+<!--
+
+If your pull request adds new features or changes existing ones please use the latest release/* branch as the base.
+
+Documentation updates (including new themes) can be submitted to the main branch.
+
+-->

+ 39 - 0
.github/workflows/release.yaml

@@ -0,0 +1,39 @@
+name: Create release
+
+permissions:
+  contents: write
+
+on:
+  push:
+    tags:
+      - 'v*'
+
+jobs:
+  release:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout the target Git reference
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Log in to Docker Hub
+        uses: docker/login-action@v3
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      - name: Set up Golang
+        uses: actions/setup-go@v5
+        with:
+          go-version-file: go.mod
+
+      - name: Set up Docker buildx
+        uses: docker/setup-buildx-action@v3
+
+      - name: Run GoReleaser
+        uses: goreleaser/goreleaser-action@v5
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          args: release

+ 72 - 0
.goreleaser.yaml

@@ -0,0 +1,72 @@
+project_name: glanceapp/glance
+
+checksum:
+  disable: true
+
+builds:
+  - binary: glance
+    env:
+      - CGO_ENABLED=0
+    goos:
+      - linux
+      - openbsd
+      - freebsd
+      - windows
+      - darwin
+    goarch:
+      - amd64
+      - arm64
+      - arm
+      - 386
+    goarm:
+      - 7
+    ldflags:
+      - -s -w -X github.com/glanceapp/glance/internal/glance.buildVersion={{ .Tag }}
+
+archives:
+  -
+    name_template: "glance-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
+    files:
+      - nothing*
+    format_overrides:
+      - goos: windows
+        format: zip
+
+dockers:
+  - image_templates:
+      - &amd64_image "{{ .ProjectName }}:{{ .Tag }}-amd64"
+    build_flag_templates:
+      - --platform=linux/amd64
+    goarch: amd64
+    use: buildx
+    dockerfile: Dockerfile.goreleaser
+
+  - image_templates:
+      - &arm64v8_image "{{ .ProjectName }}:{{ .Tag }}-arm64"
+    build_flag_templates:
+      - --platform=linux/arm64
+    goarch: arm64
+    use: buildx
+    dockerfile: Dockerfile.goreleaser
+
+  - image_templates:
+      - &arm64v7_image "{{ .ProjectName }}:{{ .Tag }}-arm64v7"
+    build_flag_templates:
+      - --platform=linux/arm64/v7
+    goarch: arm
+    goarm: 7
+    use: buildx
+    dockerfile: Dockerfile.goreleaser
+
+docker_manifests:
+  - name_template: "{{ .ProjectName }}:{{ .Tag }}"
+    image_templates:
+      - *amd64_image
+      - *arm64v8_image
+      - *arm64v7_image
+  - name_template: "{{ .ProjectName }}:latest"
+    skip_push: auto
+    image_templates:
+      - *amd64_image
+      - *arm64v8_image
+      - *arm64v7_image

+ 7 - 5
Dockerfile

@@ -1,11 +1,13 @@
-FROM alpine:3.19
+FROM golang:1.22.5-alpine3.20 AS builder
 
 
-ARG TARGETOS
-ARG TARGETARCH
-ARG TARGETVARIANT
+WORKDIR /app
+COPY . /app
+RUN CGO_ENABLED=0 go build .
+
+FROM alpine:3.20
 
 
 WORKDIR /app
 WORKDIR /app
-COPY build/glance-$TARGETOS-$TARGETARCH${TARGETVARIANT} /app/glance
+COPY --from=builder /app/glance .
 
 
 EXPOSE 8080/tcp
 EXPOSE 8080/tcp
 ENTRYPOINT ["/app/glance"]
 ENTRYPOINT ["/app/glance"]

+ 3 - 2
Dockerfile.single-platform → Dockerfile.goreleaser

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

+ 5 - 7
README.md

@@ -10,7 +10,10 @@
 * Subreddit posts
 * Subreddit posts
 * Weather
 * Weather
 * Bookmarks
 * Bookmarks
+* Hacker News
+* Lobsters
 * Latest YouTube videos from specific channels
 * Latest YouTube videos from specific channels
+* Clock
 * Calendar
 * Calendar
 * Stocks
 * Stocks
 * iframe
 * iframe
@@ -18,6 +21,7 @@
 * GitHub releases
 * GitHub releases
 * Repository overview
 * Repository overview
 * Site monitor
 * Site monitor
+* Search box
 
 
 #### Themeable
 #### Themeable
 ![multiple color schemes example](docs/images/themes-example.png)
 ![multiple color schemes example](docs/images/themes-example.png)
@@ -92,18 +96,12 @@ go run .
 
 
 ### Building Docker image
 ### Building Docker image
 
 
-Build Glance with CGO disabled:
-
-```bash
-CGO_ENABLED=0 go build -o build/glance .
-```
-
 Build the image:
 Build the image:
 
 
 **Make sure to replace "owner" with your name or organization.**
 **Make sure to replace "owner" with your name or organization.**
 
 
 ```bash
 ```bash
-docker build -t owner/glance:latest -f Dockerfile.single-platform .
+docker build -t owner/glance:latest .
 ```
 ```
 
 
 Push the image to your registry:
 Push the image to your registry:

+ 419 - 20
docs/configuration.md

@@ -11,17 +11,24 @@
   - [RSS](#rss)
   - [RSS](#rss)
   - [Videos](#videos)
   - [Videos](#videos)
   - [Hacker News](#hacker-news)
   - [Hacker News](#hacker-news)
+  - [Lobsters](#lobsters)
   - [Reddit](#reddit)
   - [Reddit](#reddit)
+  - [Search](#search-widget)
+  - [Group](#group)
+  - [Extension](#extension)
   - [Weather](#weather)
   - [Weather](#weather)
   - [Monitor](#monitor)
   - [Monitor](#monitor)
   - [Releases](#releases)
   - [Releases](#releases)
   - [Repository](#repository)
   - [Repository](#repository)
   - [Bookmarks](#bookmarks)
   - [Bookmarks](#bookmarks)
   - [Calendar](#calendar)
   - [Calendar](#calendar)
-  - [Stocks](#stocks)
+  - [ChangeDetection.io](#changedetectionio)
+  - [Clock](#clock)
+  - [Markets](#markets)
   - [Twitch Channels](#twitch-channels)
   - [Twitch Channels](#twitch-channels)
   - [Twitch Top Games](#twitch-top-games)
   - [Twitch Top Games](#twitch-top-games)
   - [iframe](#iframe)
   - [iframe](#iframe)
+  - [HTML](#html)
 
 
 ## Intro
 ## Intro
 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.
 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.
@@ -77,8 +84,8 @@ pages:
           - type: weather
           - type: weather
             location: London, United Kingdom
             location: London, United Kingdom
 
 
-          - type: stocks
-            stocks:
+          - type: markets
+            markets:
               - symbol: SPY
               - symbol: SPY
                 name: S&P 500
                 name: S&P 500
               - symbol: BTC-USD
               - symbol: BTC-USD
@@ -118,6 +125,7 @@ server:
 | ---- | ---- | -------- | ------- |
 | ---- | ---- | -------- | ------- |
 | host | string | no |  |
 | host | string | no |  |
 | port | number | no | 8080 |
 | port | number | no | 8080 |
+| base-url | string | no | |
 | assets-path | string | no |  |
 | assets-path | string | no |  |
 
 
 #### `host`
 #### `host`
@@ -126,6 +134,9 @@ The address which the server will listen on. Setting it to `localhost` means tha
 #### `port`
 #### `port`
 A number between 1 and 65,535, so long as that port isn't already used by anything else.
 A number between 1 and 65,535, so long as that port isn't already used by anything else.
 
 
+#### `base-url`
+The base URL that Glance is hosted under. No need to specify this unless you're using a reverse proxy and are hosting Glance under a directory. If that's the case then you can set this value to `/glance` or whatever the directory is called. Note that the forward slash (`/`) in the beginning is required unless you specify the full domain and path.
+
 #### `assets-path`
 #### `assets-path`
 The path to a directory that will be served by the server under the `/assets/` path. This is handy for widgets like the Monitor where you have to specify an icon URL and you want to self host all the icons rather than pointing to an external source.
 The path to a directory that will be served by the server under the `/assets/` path. This is handy for widgets like the Monitor where you have to specify an icon URL and you want to self host all the icons rather than pointing to an external source.
 
 
@@ -256,6 +267,8 @@ theme:
 > .widget-type-rss a {
 > .widget-type-rss a {
 >     font-size: 1.5rem;
 >     font-size: 1.5rem;
 > }
 > }
+>
+> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets.
 
 
 
 
 ## Pages & Columns
 ## Pages & Columns
@@ -283,6 +296,8 @@ pages:
 | ---- | ---- | -------- | ------- |
 | ---- | ---- | -------- | ------- |
 | title | string | yes | |
 | title | string | yes | |
 | slug | string | no | |
 | slug | string | no | |
+| width | string | no | |
+| hide-desktop-navigation | boolean | no | false |
 | show-mobile-header | boolean | no | false |
 | show-mobile-header | boolean | no | false |
 | columns | array | yes | |
 | columns | array | yes | |
 
 
@@ -292,6 +307,21 @@ The name of the page which gets shown in the navigation bar.
 #### `slug`
 #### `slug`
 The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title.
 The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title.
 
 
+#### `width`
+The maximum width of the page on desktop. Possible values are `slim` and `wide`.
+
+* default: `1600px`
+* slim: `1100px`
+* wide: `1920px`
+
+> [!NOTE]
+>
+> When using `slim`, the maximum number of columns allowed for that page is `2`.
+
+
+#### `hide-desktop-navigation`
+Whether to show the navigation links at the top of the page on desktop.
+
 #### `show-mobile-header`
 #### `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.
 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.
 
 
@@ -376,7 +406,9 @@ pages:
 | ---- | ---- | -------- |
 | ---- | ---- | -------- |
 | type | string | yes |
 | type | string | yes |
 | title | string | no |
 | title | string | no |
+| title-url | string | no |
 | cache | string | no |
 | cache | string | no |
+| css-class | string | no |
 
 
 #### `type`
 #### `type`
 Used to specify the widget.
 Used to specify the widget.
@@ -384,6 +416,9 @@ Used to specify the widget.
 #### `title`
 #### `title`
 The title of the widget. If left blank it will be defined by the widget.
 The title of the widget. If left blank it will be defined by the widget.
 
 
+#### `title-url`
+The URL to go to when clicking on the widget's title. If left blank it will be defined by the widget (if available).
+
 #### `cache`
 #### `cache`
 How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples:
 How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples:
 
 
@@ -398,6 +433,9 @@ cache: 1d  # 1 day
 >
 >
 > Not all widgets can have their cache duration modified. The calendar and weather widgets update on the hour and this cannot be changed.
 > Not all widgets can have their cache duration modified. The calendar and weather widgets update on the hour and this cannot be changed.
 
 
+#### `css-class`
+Set custom CSS classes for the specific widget instance.
+
 ### RSS
 ### RSS
 Display a list of articles from multiple RSS feeds.
 Display a list of articles from multiple RSS feeds.
 
 
@@ -433,6 +471,10 @@ Used to change the appearance of the widget. Possible values are `vertical-list`
 
 
 ![preview of vertical-list style for RSS widget](images/rss-feed-vertical-list-preview.png)
 ![preview of vertical-list style for RSS widget](images/rss-feed-vertical-list-preview.png)
 
 
+`detailed-list`
+
+![preview of detailed-list style for RSS widget](images/rss-widget-detailed-list-preview.png)
+
 `horizontal-cards`
 `horizontal-cards`
 
 
 ![preview of horizontal-cards style for RSS widget](images/rss-feed-horizontal-cards-preview.png)
 ![preview of horizontal-cards style for RSS widget](images/rss-feed-horizontal-cards-preview.png)
@@ -451,10 +493,16 @@ Used to modify the height of cards when using the `horizontal-cards-2` style. Th
 An array of RSS/atom feeds. The title can optionally be changed.
 An array of RSS/atom feeds. The title can optionally be changed.
 
 
 ###### Properties for each feed
 ###### Properties for each feed
-| Name | Type | Required | Default |
-| ---- | ---- | -------- | ------- |
-| url | string | yes | |
-| title | string | no | the title provided by the feed |
+| Name | Type | Required | Default | Notes |
+| ---- | ---- | -------- | ------- | ----- |
+| url | string | yes | | |
+| title | string | no | the title provided by the feed | |
+| 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 | | |
+
+###### `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.
 
 
 ##### `limit`
 ##### `limit`
 The maximum number of articles to show.
 The maximum number of articles to show.
@@ -484,6 +532,8 @@ Preview:
 | channels | array | yes | |
 | channels | array | yes | |
 | limit | integer | no | 25 |
 | limit | integer | no | 25 |
 | style | string | no | horizontal-cards |
 | style | string | no | horizontal-cards |
+| collapse-after-rows | integer | no | 4 |
+| include-shorts | boolean | no | false |
 | video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
 | video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
 
 
 ##### `channels`
 ##### `channels`
@@ -498,6 +548,9 @@ Then scroll down and click on "Share channel", then "Copy channel ID":
 ##### `limit`
 ##### `limit`
 The maximum number of videos to show.
 The maximum number of videos to show.
 
 
+##### `collapse-after-rows`
+Specify the number of rows to show when using the `grid-cards` style before the "SHOW MORE" button appears.
+
 ##### `style`
 ##### `style`
 Used to change the appearance of the widget. Possible values are `horizontal-cards` and `grid-cards`.
 Used to change the appearance of the widget. Possible values are `horizontal-cards` and `grid-cards`.
 
 
@@ -558,6 +611,57 @@ Can be used to specify an additional sort which will be applied on top of the al
 
 
 The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
 The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
 
 
+### Lobsters
+Display a list of posts from [Lobsters](https://lobste.rs).
+
+Example:
+
+```yaml
+- type: lobsters
+  sort-by: hot
+  tags:
+    - go
+    - security
+    - linux
+  limit: 15
+  collapse-after: 5
+```
+
+Preview:
+![](images/lobsters-widget-preview.png)
+
+#### Properties
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| instance-url | string | no | https://lobste.rs/ |
+| custom-url | string | no | |
+| limit | integer | no | 15 |
+| collapse-after | integer | no | 5 |
+| sort-by | string | no | hot |
+| tags | array | no | |
+
+##### `instance-url`
+The base URL for a lobsters instance hosted somewhere other than on lobste.rs. Example:
+
+```yaml
+instance-url: https://www.journalduhacker.net/
+```
+
+##### `custom-url`
+A custom URL to retrieve lobsters posts from. If this is specified, the `instance-url`, `sort-by` and `tags` properties are ignored.
+
+##### `limit`
+The maximum number of posts to show.
+
+##### `collapse-after`
+How many posts are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
+
+##### `sort-by`
+The sort order in which posts are returned. Possible options are `hot` and `new`.
+
+##### `tags`
+Limit to posts containing one of the given tags. **You cannot specify a sort order when filtering by tags, it will default to `hot`.**
+
 ### Reddit
 ### Reddit
 Display a list of posts from a specific subreddit.
 Display a list of posts from a specific subreddit.
 
 
@@ -654,7 +758,7 @@ https://your.proxy/?url={REQUEST-URL}
 ##### `sort-by`
 ##### `sort-by`
 Can be used to specify the order in which the posts should get returned. Possible values are `hot`, `new`, `top` and `rising`.
 Can be used to specify the order in which the posts should get returned. Possible values are `hot`, `new`, `top` and `rising`.
 
 
-##### `top-perid`
+##### `top-period`
 Available only when `sort-by` is set to `top`. Possible values are `hour`, `day`, `week`, `month`, `year` and `all`.
 Available only when `sort-by` is set to `top`. Possible values are `hour`, `day`, `week`, `month`, `year` and `all`.
 
 
 ##### `search`
 ##### `search`
@@ -667,6 +771,167 @@ Can be used to specify an additional sort which will be applied on top of the al
 
 
 The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
 The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
 
 
+### Search Widget
+Display a search bar that can be used to search for specific terms on various search engines.
+
+Example:
+
+```yaml
+- type: search
+  search-engine: duckduckgo
+  bangs:
+    - title: YouTube
+      shortcut: "!yt"
+      url: https://www.youtube.com/results?search_query={QUERY}
+```
+
+Preview:
+
+![](images/search-widget-preview.png)
+
+#### Keyboard shortcuts
+| Keys | Action | Condition |
+| ---- | ------ | --------- |
+| <kbd>S</kbd> | Focus the search bar | Not already focused on another input field |
+| <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 |
+
+> [!TIP]
+>
+> You can use the property `new-tab` with a value of `true` if you want to show search results in a new tab by default. <kbd>Ctrl</kbd> + <kbd>Enter</kbd> will then show results in the same tab.
+
+#### Properties
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| search-engine | string | no | duckduckgo |
+| new-tab | boolean | no | false |
+| autofocus | boolean | no | false |
+| bangs | array | no | |
+
+##### `search-engine`
+Either a value from the table below or a URL to a custom search engine. Use `{QUERY}` to indicate where the query value gets placed.
+
+| Name | URL |
+| ---- | --- |
+| duckduckgo | `https://duckduckgo.com/?q={QUERY}` |
+| google | `https://www.google.com/search?q={QUERY}` |
+
+##### `new-tab`
+When set to `true`, swaps the shortcuts for showing results in the same or new tab, defaulting to showing results in a new tab.
+
+##### `new-tab`
+When set to `true`, automatically focuses the search input on page load.
+
+##### `bangs`
+What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube:
+
+![](images/search-widget-bangs-preview.png)
+
+##### Properties for each bang
+| Name | Type | Required |
+| ---- | ---- | -------- |
+| title | string | no |
+| shortcut | string | yes |
+| url | string | yes |
+
+###### `title`
+Optional title that will appear on the right side of the search bar when the query starts with the associated shortcut.
+
+###### `shortcut`
+Any value you wish to use as the shortcut for the search engine. It does not have to start with `!`.
+
+> [!IMPORTANT]
+>
+> In YAML some characters have special meaning when placed in the beginning of a value. If your shortcut starts with `!` (and potentially some other special characters) you'll have to wrap the value in quotes:
+> ```yaml
+> shortcut: "!yt"
+>```
+
+###### `url`
+The URL of the search engine. Use `{QUERY}` to indicate where the query value gets placed. Examples:
+
+```yaml
+url: https://www.reddit.com/search?q={QUERY}
+url: https://store.steampowered.com/search/?term={QUERY}
+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.
+
+Example:
+
+```yaml
+- type: group
+  widgets:
+    - type: reddit
+      subreddit: gamingnews
+      show-thumbnails: true
+      collapse-after: 6
+    - type: reddit
+      subreddit: games
+    - type: reddit
+      subreddit: pcgaming
+      show-thumbnails: true
+```
+
+Preview:
+
+![](images/group-widget-preview.png)
+
+#### Sharing properties
+
+To avoid repetition you can use [YAML anchors](https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/) and share properties between widgets.
+
+Example:
+
+```yaml
+- type: group
+  define: &shared-properties
+      type: reddit
+      show-thumbnails: true
+      collapse-after: 6
+  widgets:
+    - subreddit: gamingnews
+      <<: *shared-properties
+    - subreddit: games
+      <<: *shared-properties
+    - subreddit: pcgaming
+      <<: *shared-properties
+```
+
+### 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).
+
+```yaml
+- type: extension
+  url: https://domain.com/widget/display-a-message
+  allow-potentially-dangerous-html: true
+  parameters:
+    message: Hello, world!
+```
+
+#### Properties
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| url | string | yes | |
+| allow-potentially-dangerous-html | boolean | no | false |
+| parameters | key & value | no | |
+
+##### `url`
+The URL of the extension.
+
+##### `allow-potentially-dangerous-html`
+Whether to allow the extension to display HTML.
+
+> [!WARNING]
+>
+> There's a reason this property is scary-sounding. It's intended to be used by developers who are comfortable with developing and using their own extensions. Do not enable it if you have no idea what it means or if you're not **absolutely sure** that the extension URL you're using is safe.
+
+##### `parameters`
+A list of keys and values that will be sent to the extension as query paramters.
+
 ### Weather
 ### Weather
 Display weather information for a specific location. The data is provided by https://open-meteo.com/.
 Display weather information for a specific location. The data is provided by https://open-meteo.com/.
 
 
@@ -675,6 +940,7 @@ Example:
 ```yaml
 ```yaml
 - type: weather
 - type: weather
   units: metric
   units: metric
+  hour-format: 12h
   location: London, United Kingdom
   location: London, United Kingdom
 ```
 ```
 
 
@@ -699,6 +965,7 @@ Each bar represents a 2 hour interval. The yellow background represents sunrise
 | ---- | ---- | -------- | ------- |
 | ---- | ---- | -------- | ------- |
 | location | string | yes |  |
 | location | string | yes |  |
 | units | string | no | metric |
 | units | string | no | metric |
+| hour-format | string | no | 12h |
 | hide-location | boolean | no | false |
 | hide-location | boolean | no | false |
 | show-area-name | boolean | no | false |
 | show-area-name | boolean | no | false |
 
 
@@ -708,6 +975,9 @@ The name of the city and country to fetch weather information for. Attempting to
 ##### `units`
 ##### `units`
 Whether to show the temperature in celsius or fahrenheit, possible values are `metric` or `imperial`.
 Whether to show the temperature in celsius or fahrenheit, possible values are `metric` or `imperial`.
 
 
+#### `hour-format`
+Whether to show the hours of the day in 12-hour format or 24-hour format. Possible values are `12h` and `24h`.
+
 ##### `hide-location`
 ##### `hide-location`
 Optionally don't display the location name on the widget.
 Optionally don't display the location name on the widget.
 
 
@@ -725,7 +995,7 @@ Greenville, United States
 ```
 ```
 
 
 ### Monitor
 ### Monitor
-Display a list of sites and whether they are reachable (online) or not. This is determined by sending a HEAD request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds.
+Display a list of sites and whether they are reachable (online) or not. This is determined by sending a GET request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds.
 
 
 Example:
 Example:
 
 
@@ -776,7 +1046,9 @@ Properties for each site:
 | ---- | ---- | -------- | ------- |
 | ---- | ---- | -------- | ------- |
 | title | string | yes | |
 | title | string | yes | |
 | url | string | yes | |
 | url | string | yes | |
+| check-url | string | no | |
 | icon | string | no | |
 | icon | string | no | |
+| allow-insecure | boolean | no | false |
 | same-tab | boolean | no | false |
 | same-tab | boolean | no | false |
 
 
 `title`
 `title`
@@ -785,11 +1057,29 @@ The title used to indicate the site.
 
 
 `url`
 `url`
 
 
-The URL which will be requested and its response will determine the status of the site. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`.
+The public facing URL of a monitored service, the user will be redirected here. If `check-url` is not specified, this is used as the status check.
+
+`check-url`
+
+The URL which will be requested and its response will determine the status of the site. If not specified, the `url` property is used.
 
 
 `icon`
 `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).
+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:
+
+```yaml
+icon: si:jellyfin
+icon: si:gitea
+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.
+
+`allow-insecure`
+
+Whether to ignore invalid/self-signed certificates.
 
 
 `same-tab`
 `same-tab`
 
 
@@ -986,6 +1276,98 @@ Whether to open the link in the same tab or a new one.
 
 
 Whether to hide the colored arrow on each link.
 Whether to hide the colored arrow on each link.
 
 
+### ChangeDetection.io
+Display a list watches from changedetection.io.
+
+Example
+
+```yaml
+- type: change-detection
+  instance-url: https://changedetection.mydomain.com/
+  token: ${CHANGE_DETECTION_TOKEN}
+```
+
+Preview:
+
+![](images/change-detection-widget-preview.png)
+
+#### Properties
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| instance-url | string | no | `https://www.changedetection.io` |
+| token | string | no |  |
+| limit | integer | no | 10 |
+| collapse-after | integer | no | 5 |
+| watches | array of strings | no |  |
+
+##### `instance-url`
+The URL pointing to your instance of `changedetection.io`.
+
+##### `token`
+The API access token which can be found in `SETTINGS > API`. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`.
+
+##### `limit`
+The maximum number of watches to show.
+
+##### `collapse-after`
+How many watches are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
+
+##### `watches`
+By default all of the configured watches will be shown. Optionally, you can specify a list of UUIDs for the specific watches you want to have listed:
+
+```yaml
+  - type: change-detection
+    watches:
+      - 1abca041-6d4f-4554-aa19-809147f538d3
+      - 705ed3e4-ea86-4d25-a064-822a6425be2c
+```
+
+### Clock
+Display a clock showing the current time and date. Optionally, also display the the time in other timezones.
+
+Example:
+
+```yaml
+- type: clock
+  hour-format: 24h
+  timezones:
+    - timezone: Europe/Paris
+      label: Paris
+    - timezone: America/New_York
+      label: New York
+    - timezone: Asia/Tokyo
+      label: Tokyo
+```
+
+Preview:
+
+![](images/clock-widget-preview.png)
+
+#### Properties
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| hour-format | string | no | 24h |
+| timezones | array | no |  |
+
+##### `hour-format`
+Whether to show the time in 12 or 24 hour format. Possible values are `12h` and `24h`.
+
+#### Properties for each timezone
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| timezone | string | yes | |
+| label | string | no | |
+
+##### `timezone`
+A timezone identifier such as `Europe/London`, `America/New_York`, etc. The full list of available identifiers can be found [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
+
+##### `label`
+Optionally, override the display value for the timezone to something more meaningful such as "Home", "Work" or anything else.
+
+
 ### Calendar
 ### Calendar
 Display a calendar.
 Display a calendar.
 
 
@@ -1003,14 +1385,14 @@ Preview:
 >
 >
 > There is currently no customizability available for the calendar. Extra features will be added in the future.
 > There is currently no customizability available for the calendar. Extra features will be added in the future.
 
 
-### Stocks
-Display a list of stocks, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
+### Markets
+Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
 
 
 Example:
 Example:
 
 
 ```yaml
 ```yaml
-- type: stocks
-  stocks:
+- type: markets
+  markets:
     - symbol: SPY
     - symbol: SPY
       name: S&P 500
       name: S&P 500
     - symbol: BTC-USD
     - symbol: BTC-USD
@@ -1025,21 +1407,21 @@ Example:
 
 
 Preview:
 Preview:
 
 
-![](images/stocks-widget-preview.png)
+![](images/markets-widget-preview.png)
 
 
 #### Properties
 #### Properties
 
 
 | Name | Type | Required |
 | Name | Type | Required |
 | ---- | ---- | -------- |
 | ---- | ---- | -------- |
-| stocks | array | yes |
+| markets | array | yes |
 | sort-by | string | no |
 | sort-by | string | no |
 | style | string | no |
 | style | string | no |
 
 
-##### `stocks`
-An array of stocks for which to display information about.
+##### `markets`
+An array of markets for which to display information about.
 
 
 ##### `sort-by`
 ##### `sort-by`
-By default the stocks are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change.
+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.
 
 
 ##### `style`
 ##### `style`
 To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
 To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
@@ -1091,6 +1473,7 @@ Preview:
 | ---- | ---- | -------- | ------- |
 | ---- | ---- | -------- | ------- |
 | channels | array | yes | |
 | channels | array | yes | |
 | collapse-after | integer | no | 5 |
 | collapse-after | integer | no | 5 |
+| sort-by | string | no | viewers |
 
 
 ##### `channels`
 ##### `channels`
 A list of channels to display.
 A list of channels to display.
@@ -1098,6 +1481,9 @@ A list of channels to display.
 ##### `collapse-after`
 ##### `collapse-after`
 How many channels are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
 How many channels are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
 
 
+##### `sort-by`
+Can be used to specify the order in which the channels are displayed. Possible values are `viewers` and `live`.
+
 ### Twitch top games
 ### Twitch top games
 Display a list of games with the most viewers on Twitch.
 Display a list of games with the most viewers on Twitch.
 
 
@@ -1160,3 +1546,16 @@ The source of the iframe.
 
 
 ##### `height`
 ##### `height`
 The height of the iframe. The minimum allowed height is 50.
 The height of the iframe. The minimum allowed height is 50.
+
+### HTML
+Embed any HTML.
+
+Example:
+
+```yaml
+- type: html
+  source: |
+    <p>Hello, <span class="color-primary">World</span>!</p>
+```
+
+Note the use of `|` after `source:`, this allows you to insert a multi-line string.

+ 158 - 0
docs/extensions.md

@@ -0,0 +1,158 @@
+# Extensions
+
+> [!IMPORTANT]
+>
+> **This document as well as the extensions feature are a work in progress. The API may change in the future. You are responsible for maintaining your own extensions.**
+
+## Overview
+
+With the intention of requiring minimal knowledge in order to develop extensions, rather than being a convoluted protocol they are nothing more than an HTTP request to a server that returns a few special headers. The exchange between Glance and extensions can be seen in the following diagram:
+
+![](images/extension-overview.png)
+
+If you know how to setup an HTTP server and a bit of HTML and CSS you're ready to start building your own extensions.
+
+> [!TIP]
+>
+> By default, the extension widget has a cache time of 30 minutes. To avoid having to restart Glance after every extension change you can set the cache time of the widget to 1 second:
+> ```yaml
+> - type: extension
+>   url: http://localhost:8081
+>   cache: 1s
+> ```
+
+## Headers
+
+### `Widget-Title`
+Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
+
+### `Widget-Content-Type`
+Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text.
+
+## Content Types
+
+> [!NOTE]
+>
+> Currently, `html` is the only supported content type. The long-term goal is to have generic content types such as `videos`, `forum-posts`, `markets`, `streams`, etc. which will be returned in JSON format and displayed by Glance using existing styles and functionality, allowing extension developers to achieve a native look while only focusing on providing data from their preferred source.
+
+### `html`
+Displays the content as HTML. This requires the user to have the `allow-potentially-dangerous-html` property set to `true`, otherwise the content will be shown as plain text.
+
+
+#### Using existing classes and functionality
+Most of the features seen throughout Glance can easily be used in your custom HTML extensions. Below is an example of some of these features:
+
+```html
+<p class="color-subdue">Text with subdued color</p>
+<p>Text with base color</p>
+<p class="color-highlight">Text with highlighted color</p>
+<p class="color-primary">Text with primary color</p>
+<p class="color-positive">Text with positive color</p>
+<p class="color-negative">Text with negative color</p>
+
+<hr class="margin-block-15">
+
+<p class="size-h1">Font size 1</p>
+<p class="size-h2">Font size 2</p>
+<p class="size-h3">Font size 3</p>
+<p class="size-h4">Font size 4</p>
+<p class="size-base">Font size base</p>
+<p class="size-h5">Font size 5</p>
+<p class="size-h6">Font size 6</p>
+
+<hr class="margin-block-15">
+
+<a class="visited-indicator" href="#notvisitedprobably">Link with visited indicator</a>
+
+<hr class="margin-block-15">
+
+<a class="color-primary-if-not-visited" href="#notvisitedprobably">Link with primary color if not visited</a>
+
+<hr class="margin-block-15">
+
+<p>Event happened <span data-dynamic-relative-time="<unix timestamp>"></span> ago</p>
+
+<hr class="margin-block-15">
+
+<ul class="list-horizontal-text">
+    <li>horizontal</li>
+    <li>list</li>
+    <li>with</li>
+    <li>multiple</li>
+    <li>text</li>
+    <li>items</li>
+</ul>
+
+<hr class="margin-block-15">
+
+<ul class="list list-gap-10 list-with-separator">
+    <li>list</li>
+    <li>with</li>
+    <li>gap</li>
+    <li>and</li>
+    <li>horizontal</li>
+    <li>lines</li>
+</ul>
+
+<hr class="margin-block-15">
+
+<ul class="list collapsible-container" data-collapse-after="3">
+    <li>collapsible</li>
+    <li>list</li>
+    <li>with</li>
+    <li>many</li>
+    <li>items</li>
+    <li>that</li>
+    <li>will</li>
+    <li>appear</li>
+    <li>when</li>
+    <li>you</li>
+    <li>click</li>
+    <li>the</li>
+    <li>button</li>
+    <li>below</li>
+</ul>
+
+<hr class="margin-bottom-15">
+
+<p class="margin-bottom-10">Lazily loaded image:</p>
+
+<img src="https://picsum.photos/200" alt="" loading="lazy">
+
+<hr class="margin-block-15">
+
+<p class="margin-bottom-10">List of posts:</p>
+
+<ul class="list list-gap-14 collapsible-container" data-collapse-after="5">
+    <li>
+        <a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
+        <ul class="list-horizontal-text">
+            <li data-dynamic-relative-time="<unix timestamp>"></li>
+            <li>3,321 points</li>
+            <li>139 comments</li>
+        </ul>
+    </li>
+    <li>
+        <a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
+        <ul class="list-horizontal-text">
+            <li data-dynamic-relative-time="<unix timestamp>"></li>
+            <li>3,321 points</li>
+            <li>139 comments</li>
+        </ul>
+    </li>
+    <li>
+        <a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
+        <ul class="list-horizontal-text">
+            <li data-dynamic-relative-time="<unix timestamp>"></li>
+            <li>3,321 points</li>
+            <li>139 comments</li>
+        </ul>
+    </li>
+</ul>
+```
+
+All of that will result in the following:
+
+![](images/extension-html-reusing-existing-features-preview.png)
+
+**Class names or features may change, once again, you are responsible for maintaining your own extensions.**

BIN
docs/images/change-detection-widget-preview.png


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


BIN
docs/images/extension-html-reusing-existing-features-preview.png


BIN
docs/images/extension-overview.png


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


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


+ 0 - 0
docs/images/stocks-widget-preview.png → docs/images/markets-widget-preview.png


BIN
docs/images/rss-widget-detailed-list-preview.png


BIN
docs/images/search-widget-bangs-preview.png


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


BIN
docs/images/themes/kanagawa-dark.png


+ 10 - 0
docs/themes.md

@@ -53,6 +53,16 @@ theme:
   primary-color: 97 13 80
   primary-color: 97 13 80
 ```
 ```
 
 
+### Kanagawa Dark
+![screenshot](images/themes/kanagawa-dark.png)
+```yaml
+theme:
+  background-color: 240 13 14
+  primary-color: 51 33 68
+  negative-color: 358 100 68
+  contrast-multiplier: 1.2
+```
+
 ### Tucan
 ### Tucan
 ![screenshot](images/themes/tucan.png)
 ![screenshot](images/themes/tucan.png)
 ```yaml
 ```yaml

+ 4 - 4
go.mod

@@ -1,19 +1,19 @@
 module github.com/glanceapp/glance
 module github.com/glanceapp/glance
 
 
-go 1.22.0
+go 1.22.5
 
 
 require (
 require (
 	github.com/mmcdole/gofeed v1.3.0
 	github.com/mmcdole/gofeed v1.3.0
-	golang.org/x/text v0.14.0
+	golang.org/x/text v0.16.0
 	gopkg.in/yaml.v3 v3.0.1
 	gopkg.in/yaml.v3 v3.0.1
 )
 )
 
 
 require (
 require (
-	github.com/PuerkitoBio/goquery v1.9.1 // indirect
+	github.com/PuerkitoBio/goquery v1.9.2 // indirect
 	github.com/andybalholm/cascadia v1.3.2 // indirect
 	github.com/andybalholm/cascadia v1.3.2 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/mmcdole/goxpp v1.1.1 // indirect
 	github.com/mmcdole/goxpp v1.1.1 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
-	golang.org/x/net v0.24.0 // indirect
+	golang.org/x/net v0.27.0 // indirect
 )
 )

+ 6 - 6
go.sum

@@ -1,5 +1,5 @@
-github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
-github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
+github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
+github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
 github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
 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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -33,8 +33,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.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.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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
-golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
-golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
+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/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -54,8 +54,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.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.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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

+ 41 - 0
internal/assets/files.go

@@ -1,8 +1,14 @@
 package assets
 package assets
 
 
 import (
 import (
+	"crypto/md5"
 	"embed"
 	"embed"
+	"encoding/hex"
+	"io"
 	"io/fs"
 	"io/fs"
+	"log/slog"
+	"strconv"
+	"time"
 )
 )
 
 
 //go:embed static
 //go:embed static
@@ -13,3 +19,38 @@ var _templateFS embed.FS
 
 
 var PublicFS, _ = fs.Sub(_publicFS, "static")
 var PublicFS, _ = fs.Sub(_publicFS, "static")
 var TemplateFS, _ = fs.Sub(_templateFS, "templates")
 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)

BIN
internal/assets/static/app-icon.png


+ 352 - 58
internal/assets/static/main.css

@@ -37,6 +37,7 @@
 
 
     --ths: var(--bgh), calc(var(--bgs) * var(--tsm));
     --ths: var(--bgh), calc(var(--bgs) * var(--tsm));
     --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
     --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
+    --color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
     --color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
     --color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
     --color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
     --color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
 
 
@@ -53,7 +54,20 @@
     --scheme: 100% -;
     --scheme: 100% -;
 }
 }
 
 
-.size-title-dynamic {
+.page {
+    height: 100%;
+    padding-block: var(--widget-gap);
+}
+
+.page-content, .page.content-ready .page-loading-container {
+    display: none;
+}
+
+.page.content-ready > .page-content {
+    display: block;
+}
+
+.page-column-small .size-title-dynamic {
     font-size: var(--font-size-h4);
     font-size: var(--font-size-h4);
 }
 }
 
 
@@ -71,14 +85,16 @@
     white-space: nowrap;
     white-space: nowrap;
 }
 }
 
 
-.text-truncate-3-lines {
+.text-truncate-2-lines, .text-truncate-3-lines {
     overflow: hidden;
     overflow: hidden;
     text-overflow: ellipsis;
     text-overflow: ellipsis;
-    -webkit-line-clamp: 3;
     display: -webkit-box;
     display: -webkit-box;
     -webkit-box-orient: vertical;
     -webkit-box-orient: vertical;
 }
 }
 
 
+.text-truncate-3-lines { -webkit-line-clamp: 3; }
+.text-truncate-2-lines { -webkit-line-clamp: 2; }
+
 .visited-indicator:not(.text-truncate)::after,
 .visited-indicator:not(.text-truncate)::after,
 .visited-indicator.text-truncate::before,
 .visited-indicator.text-truncate::before,
 .bookmarks-link:not(.bookmarks-link-no-arrow)::after {
 .bookmarks-link:not(.bookmarks-link-no-arrow)::after {
@@ -106,6 +122,7 @@
 .list-gap-14 { --list-half-gap: 0.7rem; }
 .list-gap-14 { --list-half-gap: 0.7rem; }
 .list-gap-20 { --list-half-gap: 1rem; }
 .list-gap-20 { --list-half-gap: 1rem; }
 .list-gap-24 { --list-half-gap: 1.2rem; }
 .list-gap-24 { --list-half-gap: 1.2rem; }
+.list-gap-34 { --list-half-gap: 1.7rem; }
 
 
 .list > *:not(:first-child) {
 .list > *:not(:first-child) {
     margin-top: calc(var(--list-half-gap) * 2);
     margin-top: calc(var(--list-half-gap) * 2);
@@ -117,70 +134,135 @@
     padding-top: var(--list-half-gap);
     padding-top: var(--list-half-gap);
 }
 }
 
 
-@keyframes listItemReveal {
+.collapsible-container:not(.container-expanded) > .collapsible-item {
+    display: none;
+}
+
+.collapsible-item {
+    animation: collapsibleItemReveal .25s backwards;
+}
+
+@keyframes collapsibleItemReveal {
     from {
     from {
         opacity: 0;
         opacity: 0;
         transform: translateY(10px);
         transform: translateY(10px);
     }
     }
 }
 }
 
 
-.list-collapsible-item {
-    display: none;
-    animation: listItemReveal 0.3s backwards;
-    animation-delay: var(--animation-delay);
-}
-
-.list-collapsible-label {
-    display: flex;
-    align-items: center;
-    gap: 1rem;
+.expand-toggle-button {
+    font: inherit;
+    border: 0;
+    cursor: pointer;
+    display: block;
+    width: 100%;
+    text-align: left;
+    color: var(--color-text-base);
+    text-transform: uppercase;
+    font-size: var(--font-size-h4);
     padding: var(--widget-content-vertical-padding) 0;
     padding: var(--widget-content-vertical-padding) 0;
     background: var(--color-widget-background);
     background: var(--color-widget-background);
 }
 }
 
 
-.list-collapsible-label:has(.list-collapsible-input:checked) {
+.expand-toggle-button.container-expanded {
     position: sticky;
     position: sticky;
-    bottom: 0;
+    /* -1px to hide 1px gap on chrome */
+    bottom: -1px;
 }
 }
 
 
-.list-collapsible:has(+ .list-collapsible-label > .list-collapsible-input:checked) .list-collapsible-item {
-    display: block;
+.expand-toggle-button-icon {
+    display: inline-block;
+    margin-left: 1rem;
+    position: relative;
+    top: -.2rem;
 }
 }
 
 
-.list-collapsible-input {
-    display: none;
+.expand-toggle-button-icon::before {
+    content: '';
+    font-size: 0.8rem;
+    transform: rotate(90deg);
+    line-height: 1;
+    display: inline-block;
+    transition: transform 0.3s;
+}
+
+.expand-toggle-button.container-expanded .expand-toggle-button-icon::before {
+    transform: rotate(-90deg);
+}
+
+.widget-group-header {
+    overflow-x: auto;
+    scrollbar-width: thin;
 }
 }
 
 
-.list-collapsible-label::before, .list-collapsible-label::after {
+.widget-group-title {
+    background: none;
+    font: inherit;
+    border: none;
+    color: inherit;
+    text-transform: uppercase;
+    border-bottom: 1px solid transparent;
     cursor: pointer;
     cursor: pointer;
-    display: block;
+    flex-shrink: 0;
+    padding-bottom: 0.1rem;
+    transition: color .3s, border-color .3s;
 }
 }
 
 
-.list-collapsible-label::before {
-    content: 'SHOW MORE';
-    font-size: var(--font-size-h4);
+.widget-group-title:hover:not(.widget-group-title-current) {
+    border-bottom-color: var(--color-text-subdue);
+    color: var(--color-text-highlight);
 }
 }
 
 
-.list-collapsible-label:has(.list-collapsible-input:checked)::before {
-    content: 'SHOW LESS';
+.widget-group-title-current {
+    border-bottom-color: var(--color-primary);
+    color: var(--color-text-highlight);
 }
 }
 
 
-.list-collapsible-label::after {
-    content: '';
-    font-size: 0.8rem;
-    transform: rotate(90deg);
-    line-height: 1;
-    transition: transform 0.3s;
+.widget-group-content {
+    animation: widgetGroupContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
 }
 }
 
 
-.list-collapsible-label:has(.list-collapsible-input:checked)::after {
-    transform: rotate(-90deg);
+.widget-group-content[data-direction="right"] {
+    --direction: 5px;
+}
+
+.widget-group-content[data-direction="left"] {
+    --direction: -5px;
 }
 }
 
 
-.widget-content:has(.list-collapsible-label:last-child) {
+@keyframes widgetGroupContentEntrance {
+    from {
+        opacity: 0;
+        transform: translateX(var(--direction));
+    }
+}
+
+.widget-group-content:not(.widget-group-content-current) {
+    display: none;
+}
+
+.widget-content:has(.expand-toggle-button:last-child) {
     padding-bottom: 0;
     padding-bottom: 0;
 }
 }
 
 
+.cards-grid.collapsible-container + .expand-toggle-button {
+    text-align: center;
+    margin-top: 0.5rem;
+    background-color: var(--color-background);
+}
+
+.attachments {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+}
+
+.attachments > * {
+    border-radius: var(--border-radius);
+    padding: 0.1rem 0.5rem;
+    font-size: var(--font-size-h6);
+    background-color: var(--color-separator);
+}
+
 ::selection {
 ::selection {
     background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
     background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
     color: var(--color-text-highlight);
     color: var(--color-text-highlight);
@@ -234,9 +316,14 @@ html {
     scroll-behavior: smooth;
     scroll-behavior: smooth;
 }
 }
 
 
+html, body {
+    height: 100%;
+}
+
 a {
 a {
     text-decoration: none;
     text-decoration: none;
     color: inherit;
     color: inherit;
+    overflow-wrap: break-word;
 }
 }
 
 
 ul {
 ul {
@@ -266,7 +353,6 @@ body {
 .page-columns {
 .page-columns {
     display: flex;
     display: flex;
     gap: var(--widget-gap);
     gap: var(--widget-gap);
-    margin: var(--widget-gap) 0;
     animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
     animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
 }
 }
 
 
@@ -278,13 +364,19 @@ body {
 }
 }
 
 
 .page-loading-container {
 .page-loading-container {
-    margin: 50px auto;
-    width: fit-content;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
     animation: loadingContainerEntrance 200ms backwards;
     animation: loadingContainerEntrance 200ms backwards;
     animation-delay: 150ms;
     animation-delay: 150ms;
     font-size: 2rem;
     font-size: 2rem;
 }
 }
 
 
+.page-loading-container > .loading-icon {
+    translate: 0 -250%;
+}
+
 @keyframes loadingContainerEntrance {
 @keyframes loadingContainerEntrance {
     from {
     from {
         opacity: 0;
         opacity: 0;
@@ -327,12 +419,38 @@ body {
     border: 1px solid var(--color-negative);
     border: 1px solid var(--color-negative);
 }
 }
 
 
+kbd {
+    font: inherit;
+    padding: 0.1rem 0.8rem;
+    border-radius: var(--border-radius);
+    border: 2px solid var(--color-widget-background-highlight);
+    box-shadow: 0 2px 0 var(--color-widget-background-highlight);
+    user-select: none;
+    transition: transform .1s, box-shadow .1s;
+    font-size: var(--font-size-h5);
+    cursor: pointer;
+}
+
+kbd:active {
+    transform: translateY(2px);
+    box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
+}
+
 .content-bounds {
 .content-bounds {
     max-width: 1600px;
     max-width: 1600px;
+    width: 100%;
     margin-inline: auto;
     margin-inline: auto;
     padding: 0 var(--content-bounds-padding);
     padding: 0 var(--content-bounds-padding);
 }
 }
 
 
+.page-width-wide .content-bounds {
+    max-width: 1920px;
+}
+
+.page-width-slim .content-bounds {
+    max-width: 1100px;
+}
+
 .dynamic-columns {
 .dynamic-columns {
     gap: calc(var(--widget-content-vertical-padding) / 2);
     gap: calc(var(--widget-content-vertical-padding) / 2);
     display: grid;
     display: grid;
@@ -401,6 +519,7 @@ body {
 
 
 .cards-horizontal {
 .cards-horizontal {
     overflow-x: auto;
     overflow-x: auto;
+    scrollbar-width: thin;
     padding-bottom: 1rem;
     padding-bottom: 1rem;
 }
 }
 
 
@@ -495,7 +614,7 @@ body {
 .widget-header {
 .widget-header {
     padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
     padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
     font-size: var(--font-size-h4);
     font-size: var(--font-size-h4);
-    margin-bottom: 1rem;
+    margin-bottom: 0.9rem;
     display: flex;
     display: flex;
     align-items: center;
     align-items: center;
     gap: 1rem;
     gap: 1rem;
@@ -551,7 +670,8 @@ body {
 }
 }
 
 
 .footer {
 .footer {
-    margin-block: calc(var(--widget-gap) * 1.5);
+    padding-bottom: calc(var(--widget-gap) * 1.5);
+    padding-top: calc(var(--widget-gap) / 2);
     animation: loadingContainerEntrance 200ms backwards;
     animation: loadingContainerEntrance 200ms backwards;
     animation-delay: 150ms;
     animation-delay: 150ms;
 }
 }
@@ -578,16 +698,16 @@ body {
     color: var(--color-text-highlight);
     color: var(--color-text-highlight);
 }
 }
 
 
-.stock-chart {
+.market-chart {
     margin-left: auto;
     margin-left: auto;
     width: 6.5rem;
     width: 6.5rem;
 }
 }
 
 
-.stock-chart svg {
+.market-chart svg {
     width: 100%;
     width: 100%;
 }
 }
 
 
-.stock-values {
+.market-values {
     min-width: 8rem;
     min-width: 8rem;
 }
 }
 
 
@@ -638,6 +758,86 @@ body {
     -webkit-box-orient: vertical;
     -webkit-box-orient: vertical;
 }
 }
 
 
+.search-icon {
+    width: 2.3rem;
+}
+
+.search-icon-container {
+    position: relative;
+    flex-shrink: 0;
+}
+
+/* gives a wider hit area for the 3 people that will notice the animation : ) */
+.search-icon-container::before {
+    content: '';
+    position: absolute;
+    inset: -1rem;
+}
+
+.search-icon-container:hover > .search-icon {
+    animation: searchIconHover 2.9s forwards;
+}
+
+@keyframes searchIconHover {
+    0%, 39% { translate: 0 0; }
+    20% { scale: 1.3; }
+    40% { scale: 1; }
+    50% { translate: -30% 30%; }
+    70% { translate: 30% -30%; }
+    90% { translate: -30% -30%; }
+    100% { translate: 0 0; }
+}
+
+.search {
+    transition: border-color .2s;
+    position: relative;
+}
+
+.search:hover {
+    border-color: var(--color-text-subdue);
+}
+
+.search:focus-within {
+    border-color: var(--color-primary);
+}
+
+.search-input {
+    border: 0;
+    background: none;
+    width: 100%;
+    height: 6rem;
+    font: inherit;
+    outline: none;
+    color: var(--color-text-highlight);
+}
+
+.search-input::placeholder {
+    color: var(--color-text-base-muted);
+    opacity: 1;
+}
+
+.search-bangs { display: none; }
+
+.search-bang {
+    border-radius: calc(var(--border-radius) * 2);
+    background: var(--color-widget-background-highlight);
+    padding: 0.3rem 1rem;
+    flex-shrink: 0;
+    font-size: var(--font-size-h5);
+    animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
+}
+
+@keyframes searchBangsEntrance {
+    0% {
+        opacity: 0;
+        transform: translateX(-10px);
+    }
+}
+
+.search-bang:empty {
+    display: none;
+}
+
 .forum-post-list-item {
 .forum-post-list-item {
     display: flex;
     display: flex;
     gap: 1.2rem;
     gap: 1.2rem;
@@ -653,6 +853,10 @@ body {
     margin-top: 0.1rem;
     margin-top: 0.1rem;
 }
 }
 
 
+.forum-post-tags-container {
+    transform: translateY(-0.15rem);
+}
+
 .bookmarks-group {
 .bookmarks-group {
     --bookmarks-group-color: var(--color-primary);
     --bookmarks-group-color: var(--color-primary);
 }
 }
@@ -706,7 +910,7 @@ body {
     flex-direction: column;
     flex-direction: column;
     width: calc(100% / 12);
     width: calc(100% / 12);
     padding-top: 3px;
     padding-top: 3px;
-    max-width: 3.5rem;
+    max-width: 30px;
 }
 }
 
 
 .weather-column-value, .weather-columns:hover .weather-column-value {
 .weather-column-value, .weather-columns:hover .weather-column-value {
@@ -840,6 +1044,10 @@ body {
     transform: translate(-50%, -50%);
     transform: translate(-50%, -50%);
 }
 }
 
 
+.clock-time span {
+    color: var(--color-text-highlight);
+}
+
 .monitor-site-icon {
 .monitor-site-icon {
     display: block;
     display: block;
     opacity: 0.8;
     opacity: 0.8;
@@ -866,11 +1074,22 @@ body {
 
 
 .thumbnail {
 .thumbnail {
     filter: grayscale(0.2) contrast(0.9);
     filter: grayscale(0.2) contrast(0.9);
-    transition: all 0.2s;
     opacity: 0.8;
     opacity: 0.8;
+    transition: filter 0.2s, opacity .2s;
+}
+
+.thumbnail-container {
+    flex-shrink: 0;
+    border: 1px solid var(--color-separator);
+    border-radius: var(--border-radius);
+}
+
+.thumbnail-container > * {
+    border-radius: var(--border-radius);
+    object-fit: cover;
 }
 }
 
 
-.thumbnail-container:hover .thumbnail {
+.thumbnail-parent:hover .thumbnail {
     opacity: 1;
     opacity: 1;
     filter: none;
     filter: none;
 }
 }
@@ -918,8 +1137,23 @@ body {
     z-index: 3;
     z-index: 3;
 }
 }
 
 
+.rss-detailed-description {
+    max-width: 55rem;
+    color: var(--color-text-base-muted);
+}
+
+.rss-detailed-thumbnail {
+    margin-top: 0.3rem;
+}
+
+.rss-detailed-thumbnail > * {
+    aspect-ratio: 3 / 2;
+    height: 8.7rem;
+}
+
 .twitch-category-thumbnail {
 .twitch-category-thumbnail {
     width: 5rem;
     width: 5rem;
+    aspect-ratio: 3 / 4;
     border-radius: var(--border-radius);
     border-radius: var(--border-radius);
 }
 }
 
 
@@ -985,7 +1219,7 @@ body {
         display: none;
         display: none;
     }
     }
 
 
-    .page-column-full .size-title-dynamic {
+    .page-column-small .size-title-dynamic {
         font-size: var(--font-size-h3);
         font-size: var(--font-size-h3);
     }
     }
 
 
@@ -996,10 +1230,10 @@ body {
 
 
     .page-column {
     .page-column {
         display: none;
         display: none;
-        animation: columnEntrance 0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
+        animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
     }
     }
 
 
-    .animate-element-transition .page-column {
+    .page-columns-transitioned .page-column {
         animation-duration: .3s;
         animation-duration: .3s;
     }
     }
 
 
@@ -1010,8 +1244,9 @@ body {
         }
         }
     }
     }
 
 
-    body {
-        padding-bottom: calc(var(--mobile-navigation-height) + var(--content-bounds-padding));
+    .mobile-navigation-offset {
+        height: var(--mobile-navigation-height);
+        flex-shrink: 0;
     }
     }
 
 
     .mobile-navigation {
     .mobile-navigation {
@@ -1044,7 +1279,8 @@ body {
         padding: 15px var(--content-bounds-padding);
         padding: 15px var(--content-bounds-padding);
         display: flex;
         display: flex;
         align-items: center;
         align-items: center;
-        overflow-x: scroll;
+        overflow-x: auto;
+        scrollbar-width: thin;
         gap: 2.5rem;
         gap: 2.5rem;
     }
     }
 
 
@@ -1107,9 +1343,48 @@ body {
         box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
         box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
     }
     }
 
 
-    .list-collapsible-label:has(.list-collapsible-input:checked) {
+    .expand-toggle-button.container-expanded {
         bottom: var(--mobile-navigation-height);
         bottom: var(--mobile-navigation-height);
     }
     }
+
+    .cards-grid + .expand-toggle-button.container-expanded {
+        /* hides content that peeks through the rounded borders of the mobile navigation */
+        box-shadow: 0 var(--border-radius) 0 0 var(--color-background);
+    }
+
+    .weather-column-rain::before {
+        background-size: 7px 7px;
+    }
+}
+
+@media (max-width: 1190px) and (display-mode: standalone) {
+    :root {
+        --safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
+    }
+
+    .list-collapsible-label:has(.list-collapsible-input:checked) {
+        bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
+    }
+
+    .mobile-navigation {
+        transform: translateY(calc(100% - var(--mobile-navigation-height) - var(--safe-area-inset-bottom)));
+        padding-bottom: var(--safe-area-inset-bottom);
+    }
+
+    .mobile-navigation-icons {
+        padding-bottom: var(--safe-area-inset-bottom);
+        transition: padding-bottom .3s;
+    }
+
+    .mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) {
+        padding-bottom: 0;
+    }
+}
+
+@media (display-mode: standalone) {
+    body {
+        padding-top: env(safe-area-inset-top, 0);
+    }
 }
 }
 
 
 @media (max-width: 550px) {
 @media (max-width: 550px) {
@@ -1123,22 +1398,30 @@ body {
 
 
     .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
     .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
 
 
-    .forum-post-list-item {
-        flex-flow: row-reverse;
+    .row-reverse-on-mobile {
+        flex-direction: row-reverse;
     }
     }
 
 
-    .hide-on-mobile {
+    .hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) {
         display: none
         display: none
     }
     }
 
 
     .mobile-reachability-header {
     .mobile-reachability-header {
         display: block;
         display: block;
         font-size: 3rem;
         font-size: 3rem;
-        padding: 10dvh 1rem;
+        padding: 10vh 1rem;
         text-align: center;
         text-align: center;
         color: var(--color-text-highlight);
         color: var(--color-text-highlight);
         animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
         animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
     }
     }
+
+    .rss-detailed-thumbnail > * {
+        height: 6rem;
+    }
+
+    .rss-detailed-description {
+        -webkit-line-clamp: 3;
+    }
 }
 }
 
 
 .size-h1   { font-size: var(--font-size-h1); }
 .size-h1   { font-size: var(--font-size-h1); }
@@ -1166,7 +1449,10 @@ body {
 .shrink             { flex-shrink: 1; }
 .shrink             { flex-shrink: 1; }
 .shrink-0           { flex-shrink: 0; }
 .shrink-0           { flex-shrink: 0; }
 .min-width-0        { min-width: 0; }
 .min-width-0        { min-width: 0; }
+.max-width-100      { max-width: 100%; }
+.height-100         { height: 100%; }
 .block              { display: block; }
 .block              { display: block; }
+.inline-block       { display: inline-block; }
 .overflow-hidden    { overflow: hidden; }
 .overflow-hidden    { overflow: hidden; }
 .relative           { position: relative; }
 .relative           { position: relative; }
 .flex               { display: flex; }
 .flex               { display: flex; }
@@ -1174,6 +1460,7 @@ body {
 .flex-nowrap        { flex-wrap: nowrap; }
 .flex-nowrap        { flex-wrap: nowrap; }
 .justify-between    { justify-content: space-between; }
 .justify-between    { justify-content: space-between; }
 .justify-stretch    { justify-content: stretch; }
 .justify-stretch    { justify-content: stretch; }
+.justify-evenly     { justify-content: space-evenly; }
 .justify-center     { justify-content: center; }
 .justify-center     { justify-content: center; }
 .justify-end        { justify-content: end; }
 .justify-end        { justify-content: end; }
 .uppercase          { text-transform: uppercase; }
 .uppercase          { text-transform: uppercase; }
@@ -1185,11 +1472,17 @@ body {
 .gap-7              { gap: 0.7rem; }
 .gap-7              { gap: 0.7rem; }
 .gap-10             { gap: 1rem; }
 .gap-10             { gap: 1rem; }
 .gap-15             { gap: 1.5rem; }
 .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-top-3       { margin-top: 0.3rem; }
 .margin-top-3       { margin-top: 0.3rem; }
 .margin-top-5       { margin-top: 0.5rem; }
 .margin-top-5       { margin-top: 0.5rem; }
 .margin-top-7       { margin-top: 0.7rem; }
 .margin-top-7       { margin-top: 0.7rem; }
 .margin-top-10      { margin-top: 1rem; }
 .margin-top-10      { margin-top: 1rem; }
 .margin-top-15      { margin-top: 1.5rem; }
 .margin-top-15      { margin-top: 1.5rem; }
+.margin-top-auto    { margin-top: auto; }
 .margin-block-3     { margin-block: 0.3rem; }
 .margin-block-3     { margin-block: 0.3rem; }
 .margin-block-5     { margin-block: 0.5rem; }
 .margin-block-5     { margin-block: 0.5rem; }
 .margin-block-7     { margin-block: 0.7rem; }
 .margin-block-7     { margin-block: 0.7rem; }
@@ -1201,3 +1494,4 @@ body {
 .margin-bottom-10   { margin-bottom: 1rem; }
 .margin-bottom-10   { margin-bottom: 1rem; }
 .margin-bottom-15   { margin-bottom: 1.5rem; }
 .margin-bottom-15   { margin-bottom: 1.5rem; }
 .margin-bottom-auto { margin-bottom: auto; }
 .margin-bottom-auto { margin-bottom: auto; }
+.scale-half         { transform: scale(0.5); }

+ 455 - 29
internal/assets/static/main.js

@@ -21,10 +21,10 @@ function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
 };
 };
 
 
 
 
-async function fetchPageContents (pageSlug) {
+async function fetchPageContent(pageData) {
     // TODO: handle non 200 status codes/time outs
     // TODO: handle non 200 status codes/time outs
     // TODO: add retries
     // TODO: add retries
-    const response = await fetch(`/api/pages/${pageSlug}/content/`);
+    const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/`);
     const content = await response.text();
     const content = await response.text();
 
 
     return content;
     return content;
@@ -33,8 +33,13 @@ async function fetchPageContents (pageSlug) {
 function setupCarousels() {
 function setupCarousels() {
     const carouselElements = document.getElementsByClassName("carousel-container");
     const carouselElements = document.getElementsByClassName("carousel-container");
 
 
+    if (carouselElements.length == 0) {
+        return;
+    }
+
     for (let i = 0; i < carouselElements.length; i++) {
     for (let i = 0; i < carouselElements.length; i++) {
         const carousel = carouselElements[i];
         const carousel = carouselElements[i];
+        carousel.classList.add("show-right-cutoff");
         const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
         const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
 
 
         const determineSideCutoffs = () => {
         const determineSideCutoffs = () => {
@@ -54,9 +59,9 @@ function setupCarousels() {
         const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
         const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
 
 
         itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
         itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
-        document.addEventListener("resize", determineSideCutoffsRateLimited);
+        window.addEventListener("resize", determineSideCutoffsRateLimited);
 
 
-        determineSideCutoffs();
+        afterContentReady(determineSideCutoffs);
     }
     }
 }
 }
 
 
@@ -98,7 +103,108 @@ function updateRelativeTimeForElements(elements)
         if (timestamp === undefined)
         if (timestamp === undefined)
             continue
             continue
 
 
-        element.innerText = relativeTimeSince(timestamp);
+        element.textContent = relativeTimeSince(timestamp);
+    }
+}
+
+function setupSearchBoxes() {
+    const searchWidgets = document.getElementsByClassName("search");
+
+    if (searchWidgets.length == 0) {
+        return;
+    }
+
+    for (let i = 0; i < searchWidgets.length; i++) {
+        const widget = searchWidgets[i];
+        const defaultSearchUrl = widget.dataset.defaultSearchUrl;
+        const newTab = widget.dataset.newTab === "true";
+        const inputElement = widget.getElementsByClassName("search-input")[0];
+        const bangElement = widget.getElementsByClassName("search-bang")[0];
+        const bangs = widget.querySelectorAll(".search-bangs > input");
+        const bangsMap = {};
+        const kbdElement = widget.getElementsByTagName("kbd")[0];
+        let currentBang = null;
+
+        for (let j = 0; j < bangs.length; j++) {
+            const bang = bangs[j];
+            bangsMap[bang.dataset.shortcut] = bang;
+        }
+
+        const handleKeyDown = (event) => {
+            if (event.key == "Escape") {
+                inputElement.blur();
+                return;
+            }
+
+            if (event.key == "Enter") {
+                const input = inputElement.value.trim();
+                let query;
+                let searchUrlTemplate;
+
+                if (currentBang != null) {
+                    query = input.slice(currentBang.dataset.shortcut.length + 1);
+                    searchUrlTemplate = currentBang.dataset.url;
+                } else {
+                    query = input;
+                    searchUrlTemplate = defaultSearchUrl;
+                }
+                if (query.length == 0 && currentBang == null) {
+                    return;
+                }
+
+                const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
+
+                if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
+                    window.open(url, '_blank').focus();
+                } else {
+                    window.location.href = url;
+                }
+
+                return;
+            }
+        };
+
+        const changeCurrentBang = (bang) => {
+            currentBang = bang;
+            bangElement.textContent = bang != null ? bang.dataset.title : "";
+        }
+
+        const handleInput = (event) => {
+            const value = event.target.value.trim();
+            if (value in bangsMap) {
+                changeCurrentBang(bangsMap[value]);
+                return;
+            }
+
+            const words = value.split(" ");
+            if (words.length >= 2 && words[0] in bangsMap) {
+                changeCurrentBang(bangsMap[words[0]]);
+                return;
+            }
+
+            changeCurrentBang(null);
+        };
+
+        inputElement.addEventListener("focus", () => {
+            document.addEventListener("keydown", handleKeyDown);
+            document.addEventListener("input", handleInput);
+        });
+        inputElement.addEventListener("blur", () => {
+            document.removeEventListener("keydown", handleKeyDown);
+            document.removeEventListener("input", handleInput);
+        });
+
+        document.addEventListener("keydown", (event) => {
+            if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
+            if (event.key != "s") return;
+
+            inputElement.focus();
+            event.preventDefault();
+        });
+
+        kbdElement.addEventListener("mousedown", () => {
+            requestAnimationFrame(() => inputElement.focus());
+        });
     }
     }
 }
 }
 
 
@@ -107,6 +213,8 @@ function setupDynamicRelativeTime() {
     const updateInterval = 60 * 1000;
     const updateInterval = 60 * 1000;
     let lastUpdateTime = Date.now();
     let lastUpdateTime = Date.now();
 
 
+    updateRelativeTimeForElements(elements);
+
     const updateElementsAndTimestamp = () => {
     const updateElementsAndTimestamp = () => {
         updateRelativeTimeForElements(elements);
         updateRelativeTimeForElements(elements);
         lastUpdateTime = Date.now();
         lastUpdateTime = Date.now();
@@ -142,6 +250,46 @@ function setupDynamicRelativeTime() {
     });
     });
 }
 }
 
 
+function setupGroups() {
+    const groups = document.getElementsByClassName("widget-type-group");
+
+    if (groups.length == 0) {
+        return;
+    }
+
+    for (let g = 0; g < groups.length; g++) {
+        const group = groups[g];
+        const titles = group.getElementsByClassName("widget-header")[0].children;
+        const tabs = group.getElementsByClassName("widget-group-contents")[0].children;
+        let current = 0;
+
+        for (let t = 0; t < titles.length; t++) {
+            const title = titles[t];
+            title.addEventListener("click", () => {
+                if (t == current) {
+                    return;
+                }
+
+                for (let i = 0; i < titles.length; i++) {
+                    titles[i].classList.remove("widget-group-title-current");
+                    tabs[i].classList.remove("widget-group-content-current");
+                }
+
+                if (current < t) {
+                    tabs[t].dataset.direction = "right";
+                } else {
+                    tabs[t].dataset.direction = "left";
+                }
+
+                current = t;
+
+                title.classList.add("widget-group-title-current");
+                tabs[t].classList.add("widget-group-content-current");
+            });
+        }
+    }
+}
+
 function setupLazyImages() {
 function setupLazyImages() {
     const images = document.querySelectorAll("img[loading=lazy]");
     const images = document.querySelectorAll("img[loading=lazy]");
 
 
@@ -153,39 +301,317 @@ function setupLazyImages() {
         image.classList.add("finished-transition");
         image.classList.add("finished-transition");
     }
     }
 
 
-    for (let i = 0; i < images.length; i++) {
-        const image = images[i];
+    afterContentReady(() => {
+        setTimeout(() => {
+            for (let i = 0; i < images.length; i++) {
+                const image = images[i];
+
+                if (image.complete) {
+                    image.classList.add("cached");
+                    setTimeout(() => imageFinishedTransition(image), 1);
+                } else {
+                    // TODO: also handle error event
+                    image.addEventListener("load", () => {
+                        image.classList.add("loaded");
+                        setTimeout(() => imageFinishedTransition(image), 400);
+                    });
+                }
+            }
+        }, 1);
+    });
+}
+
+function attachExpandToggleButton(collapsibleContainer) {
+    const showMoreText = "Show more";
+    const showLessText = "Show less";
+
+    let expanded = false;
+    const button = document.createElement("button");
+    const icon = document.createElement("span");
+    icon.classList.add("expand-toggle-button-icon");
+    const textNode = document.createTextNode(showMoreText);
+    button.classList.add("expand-toggle-button");
+    button.append(textNode, icon);
+    button.addEventListener("click", () => {
+        expanded = !expanded;
+
+        if (expanded) {
+            collapsibleContainer.classList.add("container-expanded");
+            button.classList.add("container-expanded");
+            textNode.nodeValue = showLessText;
+            return;
+        }
+
+        const topBefore = button.getClientRects()[0].top;
+
+        collapsibleContainer.classList.remove("container-expanded");
+        button.classList.remove("container-expanded");
+        textNode.nodeValue = showMoreText;
+
+        const topAfter = button.getClientRects()[0].top;
+
+        if (topAfter > 0)
+            return;
+
+        window.scrollBy({
+            top: topAfter - topBefore,
+            behavior: "instant"
+        });
+    });
+
+    collapsibleContainer.after(button);
+
+    return button;
+};
+
 
 
-        if (image.complete) {
-            image.classList.add("cached");
-            setTimeout(() => imageFinishedTransition(image), 5);
+function setupCollapsibleLists() {
+    const collapsibleLists = document.querySelectorAll(".list.collapsible-container");
+
+    if (collapsibleLists.length == 0) {
+        return;
+    }
+
+    for (let i = 0; i < collapsibleLists.length; i++) {
+        const list = collapsibleLists[i];
+
+        if (list.dataset.collapseAfter === undefined) {
+            continue;
+        }
+
+        const collapseAfter = parseInt(list.dataset.collapseAfter);
+
+        if (collapseAfter == -1) {
+            continue;
+        }
+
+        if (list.children.length <= collapseAfter) {
+            continue;
+        }
+
+        attachExpandToggleButton(list);
+
+        for (let c = collapseAfter; c < list.children.length; c++) {
+            const child = list.children[c];
+            child.classList.add("collapsible-item");
+            child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
+        }
+    }
+}
+
+function setupCollapsibleGrids() {
+    const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container");
+
+    if (collapsibleGridElements.length == 0) {
+        return;
+    }
+
+    for (let i = 0; i < collapsibleGridElements.length; i++) {
+        const gridElement = collapsibleGridElements[i];
+
+        if (gridElement.dataset.collapseAfterRows === undefined) {
+            continue;
+        }
+
+        const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows);
+
+        if (collapseAfterRows == -1) {
+            continue;
+        }
+
+        const getCardsPerRow = () => {
+            return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row'));
+        };
+
+        const button = attachExpandToggleButton(gridElement);
+
+        let cardsPerRow = 2;
+
+        const resolveCollapsibleItems = () => {
+            const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
+
+            if (hideItemsAfterIndex >= gridElement.children.length) {
+                button.style.display = "none";
+            } else {
+                button.style.removeProperty("display");
+            }
+
+            let row = 0;
+
+            for (let i = 0; i < gridElement.children.length; i++) {
+                const child = gridElement.children[i];
+
+                if (i >= hideItemsAfterIndex) {
+                    child.classList.add("collapsible-item");
+                    child.style.animationDelay = (row * 40).toString() + "ms";
+
+                    if (i % cardsPerRow + 1 == cardsPerRow) {
+                        row++;
+                    }
+                } else {
+                    child.classList.remove("collapsible-item");
+                    child.style.removeProperty("animation-delay");
+                }
+            }
+        };
+
+        afterContentReady(() => {
+            cardsPerRow = getCardsPerRow();
+            resolveCollapsibleItems();
+        });
+
+        window.addEventListener("resize", () => {
+            const newCardsPerRow = getCardsPerRow();
+
+            if (cardsPerRow == newCardsPerRow) {
+                return;
+            }
+
+            cardsPerRow = newCardsPerRow;
+            resolveCollapsibleItems();
+        });
+    }
+}
+
+const contentReadyCallbacks = [];
+
+function afterContentReady(callback) {
+    contentReadyCallbacks.push(callback);
+}
+
+const weekDayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
+
+function makeSettableTimeElement(element, hourFormat) {
+    const fragment = document.createDocumentFragment();
+    const hour = document.createElement('span');
+    const minute = document.createElement('span');
+    const amPm = document.createElement('span');
+    fragment.append(hour, document.createTextNode(':'), minute);
+
+    if (hourFormat == '12h') {
+        fragment.append(document.createTextNode(' '), amPm);
+    }
+
+    element.append(fragment);
+
+    return (date) => {
+        const hours = date.getHours();
+
+        if (hourFormat == '12h') {
+            amPm.textContent = hours < 12 ? 'AM' : 'PM';
+            hour.textContent = hours % 12 || 12;
         } else {
         } else {
-            // TODO: also handle error event
-            image.addEventListener("load", () => {
-                image.classList.add("loaded");
-                setTimeout(() => imageFinishedTransition(image), 500);
-            });
+            hour.textContent = hours < 10 ? '0' + hours : hours;
         }
         }
+
+        const minutes = date.getMinutes();
+        minute.textContent = minutes < 10 ? '0' + minutes : minutes;
+    };
+};
+
+function timeInZone(now, zone) {
+    let timeInZone;
+
+    try {
+        timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone }));
+    } catch (e) {
+        // TODO: indicate to the user that this is an invalid timezone
+        console.error(e);
+        timeInZone = now
     }
     }
+
+    const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
+
+    return { time: timeInZone, diffInHours: diffInHours };
 }
 }
 
 
-async function setupPage() {
-    const pageElement = document.getElementById("page");
-    const pageContents = await fetchPageContents(pageData.slug);
+function setupClocks() {
+    const clocks = document.getElementsByClassName('clock');
+
+    if (clocks.length == 0) {
+        return;
+    }
+
+    const updateCallbacks = [];
+
+    for (var i = 0; i < clocks.length; i++) {
+        const clock = clocks[i];
+        const hourFormat = clock.dataset.hourFormat;
+        const localTimeContainer = clock.querySelector('[data-local-time]');
+        const localDateElement = localTimeContainer.querySelector('[data-date]');
+        const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]');
+        const localYearElement = localTimeContainer.querySelector('[data-year]');
+        const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]');
+
+        const setLocalTime = makeSettableTimeElement(
+            localTimeContainer.querySelector('[data-time]'),
+            hourFormat
+        );
+
+        updateCallbacks.push((now) => {
+            setLocalTime(now);
+            localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()];
+            localWeekdayElement.textContent = weekDayNames[now.getDay()];
+            localYearElement.textContent = now.getFullYear();
+        });
+
+        for (var z = 0; z < timeZoneContainers.length; z++) {
+            const timeZoneContainer = timeZoneContainers[z];
+            const diffElement = timeZoneContainer.querySelector('[data-time-diff]');
+
+            const setZoneTime = makeSettableTimeElement(
+                timeZoneContainer.querySelector('[data-time]'),
+                hourFormat
+            );
+
+            updateCallbacks.push((now) => {
+                const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
+                setZoneTime(time);
+                diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
+            });
+        }
+    }
 
 
-    pageElement.innerHTML = pageContents;
+    const updateClocks = () => {
+        const now = new Date();
 
 
-    setTimeout(() => {
-        document.body.classList.add("animate-element-transition");
-    }, 150);
+        for (var i = 0; i < updateCallbacks.length; i++)
+            updateCallbacks[i](now);
 
 
-    setTimeout(setupLazyImages, 5);
-    setupCarousels();
-    setupDynamicRelativeTime();
+        setTimeout(updateClocks, (60 - now.getSeconds()) * 1000);
+    };
+
+    updateClocks();
 }
 }
 
 
-if (document.readyState === "loading") {
-    document.addEventListener("DOMContentLoaded", setupPage);
-} else {
-    setupPage();
+async function setupPage() {
+    const pageElement = document.getElementById("page");
+    const pageContentElement = document.getElementById("page-content");
+    const pageContent = await fetchPageContent(pageData);
+
+    pageContentElement.innerHTML = pageContent;
+
+    try {
+        setupClocks()
+        setupCarousels();
+        setupSearchBoxes();
+        setupCollapsibleLists();
+        setupCollapsibleGrids();
+        setupGroups();
+        setupDynamicRelativeTime();
+        setupLazyImages();
+    } finally {
+        pageElement.classList.add("content-ready");
+
+        for (let i = 0; i < contentReadyCallbacks.length; i++) {
+            contentReadyCallbacks[i]();
+        }
+
+        setTimeout(() => {
+            document.body.classList.add("page-columns-transitioned");
+        }, 300);
+    }
 }
 }
+
+setupPage();

+ 14 - 0
internal/assets/static/manifest.json

@@ -0,0 +1,14 @@
+{
+    "name": "Glance",
+    "display": "standalone",
+    "background_color": "#151519",
+    "scope": "/",
+    "start_url": "/",
+    "icons": [
+        {
+            "src": "app-icon.png",
+            "type": "image/png",
+            "sizes": "512x512"
+        }
+    ]
+}

+ 7 - 1
internal/assets/templates.go

@@ -15,6 +15,7 @@ var (
 	PageTemplate                  = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
 	PageTemplate                  = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
 	PageContentTemplate           = compileTemplate("content.html")
 	PageContentTemplate           = compileTemplate("content.html")
 	CalendarTemplate              = compileTemplate("calendar.html", "widget-base.html")
 	CalendarTemplate              = compileTemplate("calendar.html", "widget-base.html")
+	ClockTemplate                 = compileTemplate("clock.html", "widget-base.html")
 	BookmarksTemplate             = compileTemplate("bookmarks.html", "widget-base.html")
 	BookmarksTemplate             = compileTemplate("bookmarks.html", "widget-base.html")
 	IFrameTemplate                = compileTemplate("iframe.html", "widget-base.html")
 	IFrameTemplate                = compileTemplate("iframe.html", "widget-base.html")
 	WeatherTemplate               = compileTemplate("weather.html", "widget-base.html")
 	WeatherTemplate               = compileTemplate("weather.html", "widget-base.html")
@@ -22,16 +23,21 @@ var (
 	RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
 	RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
 	RedditCardsVerticalTemplate   = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
 	RedditCardsVerticalTemplate   = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
 	ReleasesTemplate              = compileTemplate("releases.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")
 	VideosTemplate                = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
 	VideosGridTemplate            = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
 	VideosGridTemplate            = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
-	StocksTemplate                = compileTemplate("stocks.html", "widget-base.html")
+	MarketsTemplate               = compileTemplate("markets.html", "widget-base.html")
 	RSSListTemplate               = compileTemplate("rss-list.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")
 	RSSHorizontalCardsTemplate    = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
 	RSSHorizontalCards2Template   = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
 	RSSHorizontalCards2Template   = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
 	MonitorTemplate               = compileTemplate("monitor.html", "widget-base.html")
 	MonitorTemplate               = compileTemplate("monitor.html", "widget-base.html")
 	TwitchGamesListTemplate       = compileTemplate("twitch-games-list.html", "widget-base.html")
 	TwitchGamesListTemplate       = compileTemplate("twitch-games-list.html", "widget-base.html")
 	TwitchChannelsTemplate        = compileTemplate("twitch-channels.html", "widget-base.html")
 	TwitchChannelsTemplate        = compileTemplate("twitch-channels.html", "widget-base.html")
 	RepositoryTemplate            = compileTemplate("repository.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")
 )
 )
 
 
 var globalTemplateFunctions = template.FuncMap{
 var globalTemplateFunctions = template.FuncMap{

+ 17 - 0
internal/assets/templates/change-detection.html

@@ -0,0 +1,17 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
+    {{ range .ChangeDetections }}
+    <li>
+        <a class="size-h4 block text-truncate color-highlight" href="{{ .URL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
+        <ul class="list-horizontal-text">
+            <li {{ dynamicRelativeTimeAttrs .LastChanged }}></li>
+            <li class="shrink min-width-0"><a class="visited-indicator" href="{{ .DiffURL }}" target="_blank" rel="noreferrer">diff:{{ .PreviousHash }}</a></li>
+        </ul>
+    </li>
+    {{ else }}
+    <li>No watches configured</li>
+    {{ end}}
+</ul>
+{{ end }}

+ 30 - 0
internal/assets/templates/clock.html

@@ -0,0 +1,30 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+<div class="clock" data-hour-format="{{ .HourFormat }}">
+    <div class="flex justify-between items-center" data-local-time>
+        <div>
+            <div class="color-highlight size-h1" data-date></div>
+            <div data-year></div>
+        </div>
+        <div class="text-right">
+            <div class="clock-time size-h1" data-time></div>
+            <div data-weekday></div>
+        </div>
+    </div>
+    {{ if gt (len .Timezones) 0 }}
+    <hr class="margin-block-10">
+    <ul class="list list-gap-4">
+        {{ range .Timezones }}
+        <li class="flex items-center gap-15" data-time-in-zone="{{ .Timezone }}">
+            <div class="grow min-width-0">
+                <div class="text-truncate">{{ if ne .Label "" }}{{ .Label }}{{ else }}{{ .Timezone }}{{ end }}</div>
+            </div>
+            <div class="color-subdue" data-time-diff></div>
+            <div class="size-h4 clock-time shrink-0 text-right" data-time></div>
+        </li>
+        {{ end }}
+    </ul>
+    {{ end }}
+</div>
+{{ end }}

+ 12 - 4
internal/assets/templates/document.html

@@ -5,10 +5,18 @@
     <title>{{ block "document-title" . }}{{ end }}</title>
     <title>{{ block "document-title" . }}{{ end }}</title>
     <meta charset="UTF-8">
     <meta charset="UTF-8">
     <meta name="color-scheme" content="dark">
     <meta name="color-scheme" content="dark">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <link rel="icon" type="image/png" href="/static/favicon.png" />
-    <link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
-    <script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
+    <meta name="apple-mobile-web-app-capable" content="yes">
+    <meta name="mobile-web-app-capable" content="yes">
+    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
+    <meta name="apple-mobile-web-app-title" content="Glance">
+    <meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
+    <link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
+    <link rel="icon" type="image/png" sizes="50x50" href="{{ .App.AssetPath "favicon.png" }}">
+    <link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
+    <link rel="icon" type="image/png" href="{{ .App.AssetPath "favicon.png" }}" />
+    <link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
+    <script type="module" src="{{ .App.AssetPath "main.js" }}"></script>
     {{ block "document-head-after" . }}{{ end }}
     {{ block "document-head-after" . }}{{ end }}
 </head>
 </head>
 <body>
 <body>

+ 5 - 0
internal/assets/templates/extension.html

@@ -0,0 +1,5 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+{{ .Extension.Content }}
+{{ end }}

+ 23 - 17
internal/assets/templates/forum-posts.html

@@ -1,14 +1,14 @@
 {{ template "widget-base.html" . }}
 {{ template "widget-base.html" . }}
 
 
 {{ define "widget-content" }}
 {{ define "widget-content" }}
-<ul class="list list-gap-14 list-collapsible">
-    {{ range $i, $post := .Posts }}
-    <li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
-        <div class="forum-post-list-item thumbnail-container">
+<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
+    {{ range .Posts }}
+    <li>
+        <div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
             {{ if $.ShowThumbnails }}
             {{ if $.ShowThumbnails }}
-                {{ if ne $post.ThumbnailUrl "" }}
-                <img class="forum-post-list-thumbnail thumbnail" src="{{ $post.ThumbnailUrl }}" alt="" loading="lazy">
-                {{ else if $post.HasTargetUrl }}
+                {{ if ne .ThumbnailUrl "" }}
+                <img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
+                {{ else if .HasTargetUrl }}
                 <svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
                 <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" />
                     <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>
                 </svg>
@@ -18,14 +18,23 @@
                 </svg>
                 </svg>
                 {{ end }}
                 {{ end }}
             {{ end }}
             {{ end }}
-            <div class="grow">
-                <a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
+            <div class="grow min-width-0">
+                <a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
+                {{ if gt (len .Tags) 0 }}
+                <div class="inline-block forum-post-tags-container">
+                    <ul class="attachments">
+                    {{ range .Tags }}
+                        <li>{{ . }}</li>
+                    {{ end }}
+                    </ul>
+                </div>
+                {{ end }}
                 <ul class="list-horizontal-text">
                 <ul class="list-horizontal-text">
-                    <li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li>
-                    <li>{{ $post.Score | formatNumber }} points</li>
-                    <li>{{ $post.CommentCount | formatNumber }} comments</li>
-                    {{ if $post.HasTargetUrl }}
-                    <li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li>
+                    <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
+                    <li>{{ .Score | formatNumber }} points</li>
+                    <li>{{ .CommentCount | formatNumber }} comments</li>
+                    {{ if .HasTargetUrl }}
+                    <li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
                     {{ end }}
                     {{ end }}
                 </ul>
                 </ul>
             </div>
             </div>
@@ -33,7 +42,4 @@
     </li>
     </li>
     {{ end }}
     {{ end }}
 </ul>
 </ul>
-{{ if gt (len .Posts) $.CollapseAfter }}
-<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
-{{ end }}
 {{ end }}
 {{ end }}

+ 20 - 0
internal/assets/templates/group.html

@@ -0,0 +1,20 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
+
+{{ define "widget-content" }}
+<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>
+        {{ end }}
+    </div>
+</div>
+
+<div class="widget-group-contents">
+{{ range $i, $widget := .Widgets }}
+    <div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}">{{ .Render }}</div>
+{{ end }}
+</div>
+
+{{ end }}

+ 9 - 9
internal/assets/templates/stocks.html → internal/assets/templates/markets.html

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

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

@@ -22,13 +22,13 @@
 
 
 {{ define "site" }}
 {{ define "site" }}
 {{ if .IconUrl }}
 {{ if .IconUrl }}
-<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
+<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
 {{ end }}
 {{ end }}
 <div>
 <div>
-    <a class="size-h3 color-highlight" href="{{ .Url }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
+    <a class="size-h3 color-highlight" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
     <ul class="list-horizontal-text">
     <ul class="list-horizontal-text">
         {{ if not .Status.Error }}
         {{ if not .Status.Error }}
-        <li>{{ .StatusText }}</li>
+        <li title="{{ .Status.Code }}">{{ .StatusText }}</li>
         <li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
         <li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
         {{ else if .Status.TimedOut }}
         {{ else if .Status.TimedOut }}
         <li class="color-negative">Timed Out</li>
         <li class="color-negative">Timed Out</li>
@@ -37,7 +37,7 @@
         {{ end }}
         {{ end }}
     </ul>
     </ul>
 </div>
 </div>
-{{ if eq .StatusStyle "good" }}
+{{ if eq .StatusStyle "ok" }}
 <div class="monitor-site-status-icon">
 <div class="monitor-site-status-icon">
     <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
     <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" />
         <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" />

+ 41 - 36
internal/assets/templates/page.html

@@ -1,16 +1,18 @@
 {{ template "document.html" . }}
 {{ template "document.html" . }}
 
 
-{{ define "document-title" }}{{ .Page.Title }} - {{ .App.Config.Branding.Name }}{{ end }}
+{{ define "document-title" }}{{ .Page.Title }}{{ end }}
 
 
 {{ define "document-head-before" }}
 {{ define "document-head-before" }}
 <script>
 <script>
     const pageData = {
     const pageData = {
         slug: "{{ .Page.Slug }}",
         slug: "{{ .Page.Slug }}",
+        baseURL: "{{ .App.Config.Server.BaseURL }}",
     };
     };
 </script>
 </script>
 {{ end }}
 {{ end }}
 
 
-{{ define "document-root-attrs" }}{{ if .App.Config.Theme.Light }}class="light-scheme"{{ end }}{{ end }}
+{{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }}{{ end }}"{{ end }}
+
 {{ define "document-head-after" }}
 {{ define "document-head-after" }}
 {{ template "page-style-overrides.gotmpl" . }}
 {{ template "page-style-overrides.gotmpl" . }}
 {{ if ne "" .App.Config.Theme.CustomCSSFile }}
 {{ if ne "" .App.Config.Theme.CustomCSSFile }}
@@ -20,52 +22,55 @@
 
 
 {{ define "navigation-links" }}
 {{ define "navigation-links" }}
 {{ range .App.Config.Pages }}
 {{ range .App.Config.Pages }}
-<a href="/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
+<a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
 {{ end }}
 {{ end }}
 {{ end }}
 {{ end }}
 
 
 {{ define "document-body" }}
 {{ define "document-body" }}
-<div class="header-container content-bounds">
-    <div class="header flex padding-inline-widget widget-content-frame">
-        <!-- TODO: Replace G with actual logo, first need an actual logo -->
-        <div class="logo">{{ .App.Config.Branding.ShortName }}</div>
-        <div class="nav flex grow">
-            {{ template "navigation-links" . }}
+<div class="flex flex-column height-100">
+    {{ if not .Page.HideDesktopNavigation }}
+    <div class="header-container content-bounds">
+        <div class="header flex padding-inline-widget widget-content-frame">
+            <!-- TODO: Replace G with actual logo, first need an actual logo -->
+            <div class="logo">G</div>
+            <div class="nav flex grow">
+                {{ template "navigation-links" . }}
+            </div>
         </div>
         </div>
     </div>
     </div>
-</div>
+    {{ end }}
 
 
-<div class="mobile-navigation">
-    <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>
-        {{ end }}
-        <label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
-    </div>
-    <div class="mobile-navigation-page-links">
-        {{ template "navigation-links" . }}
+    <div class="mobile-navigation">
+        <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>
+            {{ end }}
+            <label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
+        </div>
+        <div class="mobile-navigation-page-links">
+            {{ template "navigation-links" . }}
+        </div>
     </div>
     </div>
-</div>
 
 
-<div class="content-bounds">
-    <div class="page" id="page">
-        <div class="page-loading-container">
-            <!-- TODO: add a bigger/better loading indicator -->
-            <div class="loading-icon"></div>
+    <div class="content-bounds grow">
+        <div class="page" id="page">
+            <div class="page-content" id="page-content"></div>
+            <div class="page-loading-container">
+                <!-- TODO: add a bigger/better loading indicator -->
+                <div class="loading-icon"></div>
+            </div>
         </div>
         </div>
     </div>
     </div>
-</div>
 
 
-{{  if .App.Config.Branding.Show }}
-<div class="footer flex items-center flex-column">
-    <div>
-        <span class="size-h3">Glance</span> ({{ .App.Version }})
+    {{ if not .App.Config.Branding.Show }}
+    <div class="footer flex items-center flex-column">
+        <div>
+            <a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
+        </div>
     </div>
     </div>
-    <ul class="list-horizontal-text margin-top-5 size-h5 color-primary">
-        <li><a href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a></li>
-        <li><a href="https://github.com/glanceapp/glance/discussions" target="_blank" rel="noreferrer">Submit feedback</a></li>
-    </ul>
+    {{ end }}
+
+    <div class="mobile-navigation-offset"></div>
 </div>
 </div>
 {{ end }}
 {{ end }}
-{{ end }}

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

@@ -20,7 +20,7 @@
                 {{ end }}
                 {{ end }}
                 <a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <ul class="list-horizontal-text margin-top-7">
                 <ul class="list-horizontal-text margin-top-7">
-                    <li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
+                    <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
                     <li>{{ .Score | formatNumber }} points</li>
                     <li>{{ .Score | formatNumber }} points</li>
                 </ul>
                 </ul>
             </div>
             </div>

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

@@ -19,7 +19,7 @@
             {{ end }}
             {{ end }}
             <a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
             <a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
             <ul class="list-horizontal-text margin-top-7">
             <ul class="list-horizontal-text margin-top-7">
-                <li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
+                <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
                 <li>{{ .Score | formatNumber }} points</li>
                 <li>{{ .Score | formatNumber }} points</li>
             </ul>
             </ul>
         </div>
         </div>

+ 3 - 6
internal/assets/templates/releases.html

@@ -1,12 +1,12 @@
 {{ template "widget-base.html" . }}
 {{ template "widget-base.html" . }}
 
 
 {{ define "widget-content" }}
 {{ define "widget-content" }}
-<ul class="list list-gap-10 list-collapsible">
+<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
     {{ range $i, $release := .Releases }}
     {{ range $i, $release := .Releases }}
-    <li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
+    <li>
         <a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
         <a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
         <ul class="list-horizontal-text">
         <ul class="list-horizontal-text">
-            <li title="{{ $release.TimeReleased | formatTime }}" {{ dynamicRelativeTimeAttrs $release.TimeReleased }}>{{ $release.TimeReleased | relativeTime }}</li>
+            <li {{ dynamicRelativeTimeAttrs $release.TimeReleased }}></li>
             <li>{{ $release.Version }}</li>
             <li>{{ $release.Version }}</li>
             {{ if gt $release.Downvotes 3 }}
             {{ if gt $release.Downvotes 3 }}
             <li>{{ $release.Downvotes | formatNumber }} ⚠</li>
             <li>{{ $release.Downvotes | formatNumber }} ⚠</li>
@@ -15,7 +15,4 @@
     </li>
     </li>
     {{ end }}
     {{ end }}
 </ul>
 </ul>
-{{ if gt (len .Releases) $.CollapseAfter }}
-<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
-{{ end }}
 {{ end }}
 {{ end }}

+ 2 - 2
internal/assets/templates/repository.html

@@ -13,7 +13,7 @@
 <div class="flex gap-7 size-h5 margin-top-3">
 <div class="flex gap-7 size-h5 margin-top-3">
     <ul class="list list-gap-2">
     <ul class="list list-gap-2">
         {{ range .RepositoryDetails.PullRequests }}
         {{ range .RepositoryDetails.PullRequests }}
-        <li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li>
+        <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
         {{ end }}
         {{ end }}
     </ul>
     </ul>
     <ul class="list list-gap-2 min-width-0">
     <ul class="list list-gap-2 min-width-0">
@@ -30,7 +30,7 @@
 <div class="flex gap-7 size-h5 margin-top-3">
 <div class="flex gap-7 size-h5 margin-top-3">
     <ul class="list list-gap-2">
     <ul class="list list-gap-2">
         {{ range .RepositoryDetails.Issues }}
         {{ range .RepositoryDetails.Issues }}
-        <li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li>
+        <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
         {{ end }}
         {{ end }}
     </ul>
     </ul>
     <ul class="list list-gap-2 min-width-0">
     <ul class="list list-gap-2 min-width-0">

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

@@ -0,0 +1,40 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+<ul class="list list-gap-24 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
+    {{ range .Items }}
+    <li class="flex gap-15 items-start row-reverse-on-mobile thumbnail-parent">
+        <div class="thumbnail-container rss-detailed-thumbnail">
+            {{ if ne "" .ImageURL }}
+            <img class="thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
+            {{ else }}
+            <svg class="scale-half hide-on-mobile" stroke="var(--color-text-subdue)" 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="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
+            </svg>
+            {{ end }}
+        </div>
+        <div class="grow min-width-0">
+            <a class="size-h3 color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
+            <ul class="list-horizontal-text flex-nowrap">
+                <li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
+                <li class="min-width-0">
+                    <a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
+                </li>
+            </ul>
+            {{ if ne "" .Description }}
+            <p class="rss-detailed-description text-truncate-2-lines margin-top-10">{{ .Description }}</p>
+            {{ end }}
+            {{ if gt (len .Categories) 0 }}
+            <ul class="attachments margin-top-10">
+            {{ range .Categories }}
+                <li>{{ . }}</li>
+            {{ end }}
+            </ul>
+            {{ end }}
+        </div>
+    </li>
+    {{ else }}
+    <li>{{ .NoItemsMessage }}</li>
+    {{ end }}
+</ul>
+{{ end }}

+ 7 - 3
internal/assets/templates/rss-horizontal-cards-2.html

@@ -3,10 +3,11 @@
 {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
 {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
 
 
 {{ define "widget-content" }}
 {{ define "widget-content" }}
+{{ if gt (len .Items) 0 }}
 <div class="carousel-container">
 <div class="carousel-container">
     <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
     <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
         {{ range .Items }}
         {{ range .Items }}
-        <div class="card rss-card-2 widget-content-frame thumbnail-container">
+        <div class="card rss-card-2 widget-content-frame thumbnail-parent">
             {{ if ne "" .ImageURL }}
             {{ if ne "" .ImageURL }}
             <img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
             <img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
             {{ else }}
             {{ else }}
@@ -17,12 +18,15 @@
             <div class="rss-card-2-content padding-inline-widget">
             <div class="rss-card-2-content padding-inline-widget">
                 <a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <ul class="list-horizontal-text flex-nowrap margin-top-5">
                 <ul class="list-horizontal-text flex-nowrap margin-top-5">
-                    <li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
-                    <li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
+                    <li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
+                    <li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
                 </ul>
                 </ul>
             </div>
             </div>
         </div>
         </div>
         {{ end }}
         {{ end }}
     </div>
     </div>
 </div>
 </div>
+{{ else }}
+<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
+{{ end }}
 {{ end }}
 {{ end }}

+ 7 - 3
internal/assets/templates/rss-horizontal-cards.html

@@ -3,10 +3,11 @@
 {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
 {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
 
 
 {{ define "widget-content" }}
 {{ define "widget-content" }}
+{{ if gt (len .Items) 0 }}
 <div class="carousel-container">
 <div class="carousel-container">
     <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
     <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
         {{ range .Items }}
         {{ range .Items }}
-        <div class="card widget-content-frame thumbnail-container">
+        <div class="card widget-content-frame thumbnail-parent">
             {{ if ne "" .ImageURL }}
             {{ if ne "" .ImageURL }}
             <img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
             <img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
             {{ else }}
             {{ else }}
@@ -17,12 +18,15 @@
             <div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
             <div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
                 <a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <ul class="list-horizontal-text flex-nowrap margin-top-7">
                 <ul class="list-horizontal-text flex-nowrap margin-top-7">
-                    <li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
-                    <li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
+                    <li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
+                    <li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
                 </ul>
                 </ul>
             </div>
             </div>
         </div>
         </div>
         {{ end }}
         {{ end }}
     </div>
     </div>
 </div>
 </div>
+{{ else }}
+<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
+{{ end }}
 {{ end }}
 {{ end }}

+ 10 - 11
internal/assets/templates/rss-list.html

@@ -1,20 +1,19 @@
 {{ template "widget-base.html" . }}
 {{ template "widget-base.html" . }}
 
 
 {{ define "widget-content" }}
 {{ define "widget-content" }}
-<ul class="list list-gap-14 list-collapsible">
-    {{ range $i, $item := .Items }}
-    <li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
+<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
+    {{ range .Items }}
+    <li>
         <a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
         <a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
-        <ul class="list-horizontal-text">
-            <li title="{{ $item.PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs $item.PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
-            {{ if gt (len $.FeedRequests) 1 }}
-                <li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li>
-            {{ end }}
+        <ul class="list-horizontal-text flex-nowrap">
+            <li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
+            <li class="min-width-0">
+                <a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
+            </li>
         </ul>
         </ul>
     </li>
     </li>
+    {{ else }}
+    <li>{{ .NoItemsMessage }}</li>
     {{ end }}
     {{ end }}
 </ul>
 </ul>
-    {{ if gt (len .Items) $.CollapseAfter }}
-        <label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
-    {{ end }}
 {{ end }}
 {{ end }}

+ 24 - 0
internal/assets/templates/search.html

@@ -0,0 +1,24 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
+
+{{ define "widget-content" }}
+<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}">
+    <div class="search-bangs">
+        {{ range .Bangs }}
+        <input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
+        {{ end }}
+    </div>
+
+    <div class="search-icon-container">
+        <svg class="search-icon" stroke="var(--color-text-subdue)" 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="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
+        </svg>
+    </div>
+
+    <input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
+
+    <div class="search-bang"></div>
+    <kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
+</div>
+{{ end }}

+ 17 - 16
internal/assets/templates/twitch-channels.html

@@ -1,27 +1,31 @@
 {{ template "widget-base.html" . }}
 {{ template "widget-base.html" . }}
 
 
 {{ define "widget-content" }}
 {{ define "widget-content" }}
-<ul class="list list-gap-14 list-collapsible">
-    {{ range $i, $channel := .Channels }}
-    <li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
-        <div class="{{ if $channel.IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container">
+<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
+    {{ 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 $channel.Exists }}
-                <img class="twitch-channel-avatar thumbnail" src="{{ $channel.AvatarUrl }}" alt="" loading="lazy">
+                {{ if .Exists }}
+                <a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
+                    <img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
+                </a>
                 {{ else }}
                 {{ else }}
                 <svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
                 <svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
                     <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
                     <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
                 </svg>
                 </svg>
                 {{ end }}
                 {{ end }}
             </div>
             </div>
-            <div class="shrink min-width-0">
-                <a href="https://twitch.tv/{{ $channel.Login }}" class="size-h3{{ if $channel.IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ $channel.Name }}</a>
-                {{ if $channel.Exists }}
-                    {{ if $channel.IsLive }}
-                    <a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ $channel.CategorySlug }}" target="_blank" rel="noreferrer">{{ $channel.Category }}</a>
+            <div class="min-width-0">
+                <a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
+                {{ if .Exists }}
+                    {{ if .IsLive }}
+                        {{ if .Category }}
+                            <a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
+                        {{ end }}
                     <ul class="list-horizontal-text">
                     <ul class="list-horizontal-text">
-                        <li title="{{ $channel.LiveSince | formatTime }}" {{ dynamicRelativeTimeAttrs $channel.LiveSince }}>{{ $channel.LiveSince | relativeTime }}</li>
-                        <li>{{ $channel.ViewersCount | formatViewerCount }} viewers</li>
+                        <li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
+                        <li>{{ .ViewersCount | formatViewerCount }} viewers</li>
                     </ul>
                     </ul>
                     {{ else }}
                     {{ else }}
                     <div>Offline</div>
                     <div>Offline</div>
@@ -34,7 +38,4 @@
     </li>
     </li>
     {{ end }}
     {{ end }}
 </ul>
 </ul>
-{{ if gt (len .Channels) $.CollapseAfter }}
-<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
-{{ end }}
 {{ end }}
 {{ end }}

+ 11 - 15
internal/assets/templates/twitch-games-list.html

@@ -1,26 +1,25 @@
 {{ template "widget-base.html" . }}
 {{ template "widget-base.html" . }}
 
 
 {{ define "widget-content" }}
 {{ define "widget-content" }}
-<ul class="list list-gap-14 list-collapsible">
-    {{ range $i, $category := .Categories }}
-    {{ $shouldCollapseItem := shouldCollapse $i $.CollapseAfter }}
-    <li class="twitch-category thumbnail-container{{ if $shouldCollapseItem }} list-collapsible-item{{ end }}" {{ if $shouldCollapseItem }}style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
-        <div class="flex gap-10 items-center">
-            <img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ $category.AvatarUrl }}" alt="">
-            <div class="shrink min-width-0">
-                <a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ $category.Slug }}" target="_blank" rel="noreferrer">{{ $category.Name }}</a>
+<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
+    {{ range .Categories }}
+    <li class="twitch-category thumbnail-parent">
+        <div class="flex gap-10 items-start">
+            <img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
+            <div class="min-width-0">
+                <a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
                 <ul class="list-horizontal-text">
                 <ul class="list-horizontal-text">
-                    <li>{{ $category.ViewersCount | formatViewerCount }} viewers</li>
-                    {{ if $category.IsNew }}
+                    <li>{{ .ViewersCount | formatViewerCount }} viewers</li>
+                    {{ if .IsNew }}
                     <li class="color-primary">NEW</li>
                     <li class="color-primary">NEW</li>
                     {{ end }}
                     {{ end }}
                 </ul>
                 </ul>
                 <ul class="list-horizontal-text flex-nowrap">
                 <ul class="list-horizontal-text flex-nowrap">
-                    {{ range $i, $tag := $category.Tags }}
+                    {{ range $i, $tag := .Tags }}
                         {{ if eq $i 0 }}
                         {{ if eq $i 0 }}
                         <li class="shrink-0">{{ $tag.Name }}</li>
                         <li class="shrink-0">{{ $tag.Name }}</li>
                         {{ else }}
                         {{ else }}
-                        <li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li>
+                        <li class="text-truncate min-width-0">{{ $tag.Name }}</li>
                         {{ end }}
                         {{ end }}
                     {{ end }}
                     {{ end }}
                 </ul>
                 </ul>
@@ -29,7 +28,4 @@
     </li>
     </li>
     {{ end }}
     {{ end }}
 </ul>
 </ul>
-{{ if gt (len .Categories) $.CollapseAfter }}
-<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
-{{ end }}
 {{ end }}
 {{ end }}

+ 2 - 2
internal/assets/templates/video-card-contents.html

@@ -3,8 +3,8 @@
 <div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
 <div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
     <a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
     <a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
     <ul class="list-horizontal-text flex-nowrap margin-top-7">
     <ul class="list-horizontal-text flex-nowrap margin-top-7">
-        <li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
-        <li class="shrink min-width-0">
+        <li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
+        <li class="min-width-0">
             <a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
             <a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
         </li>
         </li>
     </ul>
     </ul>

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

@@ -3,9 +3,9 @@
 {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
 {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
 
 
 {{ define "widget-content" }}
 {{ define "widget-content" }}
-<div class="cards-grid">
+<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
     {{ range .Videos }}
     {{ range .Videos }}
-    <div class="card widget-content-frame thumbnail-container">
+    <div class="card widget-content-frame thumbnail-parent">
         {{ template "video-card-contents" . }}
         {{ template "video-card-contents" . }}
     </div>
     </div>
     {{ end }}
     {{ end }}

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

@@ -6,7 +6,7 @@
 <div class="carousel-container">
 <div class="carousel-container">
     <div class="cards-horizontal carousel-items-container">
     <div class="cards-horizontal carousel-items-container">
         {{ range .Videos }}
         {{ range .Videos }}
-        <div class="card widget-content-frame thumbnail-container">
+        <div class="card widget-content-frame thumbnail-parent">
             {{ template "video-card-contents" . }}
             {{ template "video-card-contents" . }}
         </div>
         </div>
         {{ end }}
         {{ end }}

+ 5 - 3
internal/assets/templates/widget-base.html

@@ -1,13 +1,15 @@
-<div class="widget widget-type-{{ .GetType }}">
+<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
+    {{ if not .HideHeader}}
     <div class="widget-header">
     <div class="widget-header">
-        <div class="uppercase">{{ .Title }}</div>
+        {{ if ne "" .TitleURL}}<a href="{{ .TitleURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
         {{ if and .Error .ContentAvailable }}
         {{ if and .Error .ContentAvailable }}
         <div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
         <div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
         {{ else if .Notice }}
         {{ else if .Notice }}
         <div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
         <div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
         {{ end }}
         {{ end }}
     </div>
     </div>
-    <div class="widget-content {{ if .ContentAvailable }}{{ block "widget-content-classes" . }}{{ end }}{{ end }}">
+    {{ end }}
+    <div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
         {{ if .ContentAvailable }}
         {{ if .ContentAvailable }}
             {{ block "widget-content" . }}{{ end }}
             {{ block "widget-content" . }}{{ end }}
         {{ else }}
         {{ else }}

+ 139 - 0
internal/feed/changedetection.go

@@ -0,0 +1,139 @@
+package feed
+
+import (
+	"fmt"
+	"log/slog"
+	"net/http"
+	"sort"
+	"strings"
+	"time"
+)
+
+type ChangeDetectionWatch struct {
+	Title        string
+	URL          string
+	LastChanged  time.Time
+	DiffURL      string
+	PreviousHash string
+}
+
+type ChangeDetectionWatches []ChangeDetectionWatch
+
+func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches {
+	sort.Slice(r, func(i, j int) bool {
+		return r[i].LastChanged.After(r[j].LastChanged)
+	})
+
+	return r
+}
+
+type changeDetectionResponseJson struct {
+	Title        string `json:"title"`
+	URL          string `json:"url"`
+	LastChanged  int64  `json:"last_changed"`
+	DateCreated  int64  `json:"date_created"`
+	PreviousHash string `json:"previous_md5"`
+}
+
+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)
+
+	if err != nil {
+		return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
+	}
+
+	uuids := make([]string, 0, len(uuidsMap))
+
+	for uuid := range uuidsMap {
+		uuids = append(uuids, uuid)
+	}
+
+	return uuids, nil
+}
+
+func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
+	watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
+
+	if len(requestedWatchIDs) == 0 {
+		return watches, nil
+	}
+
+	requests := make([]*http.Request, len(requestedWatchIDs))
+
+	for i, repository := range requestedWatchIDs {
+		request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch/%s", instanceURL, repository), nil)
+
+		if token != "" {
+			request.Header.Add("x-api-key", token)
+		}
+
+		requests[i] = request
+	}
+
+	task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient)
+	job := newJob(task, requests).withWorkers(15)
+	responses, errs, err := workerPoolDo(job)
+
+	if err != nil {
+		return nil, err
+	}
+
+	var failed int
+
+	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)
+			continue
+		}
+
+		watchJson := responses[i]
+
+		watch := ChangeDetectionWatch{
+			URL:     watchJson.URL,
+			DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
+		}
+
+		if watchJson.LastChanged == 0 {
+			watch.LastChanged = time.Unix(watchJson.DateCreated, 0)
+		} else {
+			watch.LastChanged = time.Unix(watchJson.LastChanged, 0)
+		}
+
+		if watchJson.Title != "" {
+			watch.Title = watchJson.Title
+		} else {
+			watch.Title = strings.TrimPrefix(strings.Trim(stripURLScheme(watchJson.URL), "/"), "www.")
+		}
+
+		if watchJson.PreviousHash != "" {
+			var hashLength = 8
+
+			if len(watchJson.PreviousHash) < hashLength {
+				hashLength = len(watchJson.PreviousHash)
+			}
+
+			watch.PreviousHash = watchJson.PreviousHash[0:hashLength]
+		}
+
+		watches = append(watches, watch)
+	}
+
+	if len(watches) == 0 {
+		return nil, ErrNoContent
+	}
+
+	watches.SortByNewest()
+
+	if failed > 0 {
+		return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed)
+	}
+
+	return watches, nil
+}

+ 97 - 0
internal/feed/extension.go

@@ -0,0 +1,97 @@
+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
+}

+ 4 - 23
internal/feed/github.go

@@ -8,12 +8,10 @@ import (
 	"time"
 	"time"
 )
 )
 
 
-type githubReleaseResponseJson struct {
+type githubReleaseLatestResponseJson struct {
 	TagName     string `json:"tag_name"`
 	TagName     string `json:"tag_name"`
 	PublishedAt string `json:"published_at"`
 	PublishedAt string `json:"published_at"`
 	HtmlUrl     string `json:"html_url"`
 	HtmlUrl     string `json:"html_url"`
-	Draft       bool   `json:"draft"`
-	PreRelease  bool   `json:"prerelease"`
 	Reactions   struct {
 	Reactions   struct {
 		Downvotes int `json:"-1"`
 		Downvotes int `json:"-1"`
 	} `json:"reactions"`
 	} `json:"reactions"`
@@ -39,7 +37,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
 	requests := make([]*http.Request, len(repositories))
 	requests := make([]*http.Request, len(repositories))
 
 
 	for i, repository := range repositories {
 	for i, repository := range repositories {
-		request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=10", repository), nil)
+		request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repository), nil)
 
 
 		if token != "" {
 		if token != "" {
 			request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
 			request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
@@ -48,7 +46,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
 		requests[i] = request
 		requests[i] = request
 	}
 	}
 
 
-	task := decodeJsonFromRequestTask[[]githubReleaseResponseJson](defaultClient)
+	task := decodeJsonFromRequestTask[githubReleaseLatestResponseJson](defaultClient)
 	job := newJob(task, requests).withWorkers(15)
 	job := newJob(task, requests).withWorkers(15)
 	responses, errs, err := workerPoolDo(job)
 	responses, errs, err := workerPoolDo(job)
 
 
@@ -65,24 +63,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
 			continue
 			continue
 		}
 		}
 
 
-		releases := responses[i]
-
-		if len(releases) < 1 {
-			failed++
-			slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
-			continue
-		}
-
-		var liveRelease *githubReleaseResponseJson
-
-		for i := range releases {
-			release := &releases[i]
-
-			if !release.Draft && !release.PreRelease {
-				liveRelease = release
-				break
-			}
-		}
+		liveRelease := &responses[i]
 
 
 		if liveRelease == nil {
 		if liveRelease == nil {
 			slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
 			slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)

+ 91 - 0
internal/feed/lobsters.go

@@ -0,0 +1,91 @@
+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
+}

+ 33 - 7
internal/feed/monitor.go

@@ -7,6 +7,12 @@ import (
 	"time"
 	"time"
 )
 )
 
 
+type SiteStatusRequest struct {
+	URL           string `yaml:"url"`
+	CheckURL      string `yaml:"check-url"`
+	AllowInsecure bool   `yaml:"allow-insecure"`
+}
+
 type SiteStatus struct {
 type SiteStatus struct {
 	Code         int
 	Code         int
 	TimedOut     bool
 	TimedOut     bool
@@ -14,14 +20,34 @@ type SiteStatus struct {
 	Error        error
 	Error        error
 }
 }
 
 
-func getSiteStatusTask(request *http.Request) (SiteStatus, 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)
 	ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
 	defer cancel()
 	defer cancel()
 	request = request.WithContext(ctx)
 	request = request.WithContext(ctx)
-	start := time.Now()
-	response, err := http.DefaultClient.Do(request)
-	took := time.Since(start)
-	status := SiteStatus{ResponseTime: took}
+	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 err != nil {
 		if errors.Is(err, context.DeadlineExceeded) {
 		if errors.Is(err, context.DeadlineExceeded) {
@@ -29,7 +55,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
 		}
 		}
 
 
 		status.Error = err
 		status.Error = err
-		return status, err
+		return status, nil
 	}
 	}
 
 
 	defer response.Body.Close()
 	defer response.Body.Close()
@@ -39,7 +65,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
 	return status, nil
 	return status, nil
 }
 }
 
 
-func FetchStatusesForRequests(requests []*http.Request) ([]SiteStatus, error) {
+func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
 	job := newJob(getSiteStatusTask, requests).withWorkers(20)
 	job := newJob(getSiteStatusTask, requests).withWorkers(20)
 	results, _, err := workerPoolDo(job)
 	results, _, err := workerPoolDo(job)
 
 

+ 12 - 7
internal/feed/primitives.go

@@ -16,6 +16,7 @@ type ForumPost struct {
 	Score           int
 	Score           int
 	Engagement      float64
 	Engagement      float64
 	TimePosted      time.Time
 	TimePosted      time.Time
+	Tags            []string
 }
 }
 
 
 type ForumPosts []ForumPost
 type ForumPosts []ForumPost
@@ -84,20 +85,24 @@ var currencyToSymbol = map[string]string{
 	"PHP": "₱",
 	"PHP": "₱",
 }
 }
 
 
-type Stock struct {
-	Name           string  `yaml:"name"`
-	Symbol         string  `yaml:"symbol"`
-	ChartLink      string  `yaml:"chart-link"`
-	SymbolLink     string  `yaml:"symbol-link"`
+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:"-"`
 	Currency       string  `yaml:"-"`
 	Price          float64 `yaml:"-"`
 	Price          float64 `yaml:"-"`
 	PercentChange  float64 `yaml:"-"`
 	PercentChange  float64 `yaml:"-"`
 	SvgChartPoints string  `yaml:"-"`
 	SvgChartPoints string  `yaml:"-"`
 }
 }
 
 
-type Stocks []Stock
+type Markets []Market
 
 
-func (t Stocks) SortByAbsChange() {
+func (t Markets) SortByAbsChange() {
 	sort.Slice(t, func(i, j int) bool {
 	sort.Slice(t, func(i, j int) bool {
 		return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
 		return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
 	})
 	})

+ 13 - 1
internal/feed/requests.go

@@ -2,6 +2,7 @@ package feed
 
 
 import (
 import (
 	"context"
 	"context"
+	"crypto/tls"
 	"encoding/json"
 	"encoding/json"
 	"encoding/xml"
 	"encoding/xml"
 	"fmt"
 	"fmt"
@@ -11,8 +12,19 @@ import (
 	"time"
 	"time"
 )
 )
 
 
+const defaultClientTimeout = 5 * time.Second
+
 var defaultClient = &http.Client{
 var defaultClient = &http.Client{
-	Timeout: 5 * time.Second,
+	Timeout: defaultClientTimeout,
+}
+
+var insecureClientTransport = &http.Transport{
+	TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+}
+
+var defaultInsecureClient = &http.Client{
+	Timeout:   defaultClientTimeout,
+	Transport: insecureClientTransport,
 }
 }
 
 
 type RequestDoer interface {
 type RequestDoer interface {

+ 130 - 5
internal/feed/rss.go

@@ -3,11 +3,16 @@ package feed
 import (
 import (
 	"context"
 	"context"
 	"fmt"
 	"fmt"
+	"html"
 	"log/slog"
 	"log/slog"
+	"net/url"
+	"regexp"
 	"sort"
 	"sort"
+	"strings"
 	"time"
 	"time"
 
 
 	"github.com/mmcdole/gofeed"
 	"github.com/mmcdole/gofeed"
+	gofeedext "github.com/mmcdole/gofeed/extensions"
 )
 )
 
 
 type RSSFeedItem struct {
 type RSSFeedItem struct {
@@ -16,12 +21,48 @@ type RSSFeedItem struct {
 	Title       string
 	Title       string
 	Link        string
 	Link        string
 	ImageURL    string
 	ImageURL    string
+	Categories  []string
+	Description string
 	PublishedAt time.Time
 	PublishedAt time.Time
 }
 }
 
 
+// doesn't cover all cases but works the vast majority of the time
+var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
+var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
+
+func sanitizeFeedDescription(description string) string {
+	if description == "" {
+		return ""
+	}
+
+	description = strings.ReplaceAll(description, "\n", " ")
+	description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
+	description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
+	description = strings.TrimSpace(description)
+	description = html.UnescapeString(description)
+
+	return description
+}
+
+func shortenFeedDescriptionLen(description string, maxLen int) string {
+	description, _ = limitStringLength(description, 1000)
+	description = sanitizeFeedDescription(description)
+	description, limited := limitStringLength(description, maxLen)
+
+	if limited {
+		description += "…"
+	}
+
+	return description
+}
+
 type RSSFeedRequest struct {
 type RSSFeedRequest struct {
-	Url   string `yaml:"url"`
-	Title string `yaml:"title"`
+	Url             string `yaml:"url"`
+	Title           string `yaml:"title"`
+	HideCategories  bool   `yaml:"hide-categories"`
+	HideDescription bool   `yaml:"hide-description"`
+	ItemLinkPrefix  string `yaml:"item-link-prefix"`
+	IsDetailed      bool   `yaml:"-"`
 }
 }
 
 
 type RSSFeedItems []RSSFeedItem
 type RSSFeedItems []RSSFeedItem
@@ -53,8 +94,60 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
 
 
 		rssItem := RSSFeedItem{
 		rssItem := RSSFeedItem{
 			ChannelURL: feed.Link,
 			ChannelURL: feed.Link,
-			Title:      item.Title,
-			Link:       item.Link,
+		}
+
+		if request.ItemLinkPrefix != "" {
+			rssItem.Link = request.ItemLinkPrefix + item.Link
+		} else if strings.HasPrefix(item.Link, "http://") || strings.HasPrefix(item.Link, "https://") {
+			rssItem.Link = item.Link
+		} else {
+			parsedUrl, err := url.Parse(feed.Link)
+
+			if err != nil {
+				parsedUrl, err = url.Parse(request.Url)
+			}
+
+			if err == nil {
+				var link string
+
+				if item.Link[0] == '/' {
+					link = item.Link
+				} else {
+					link = "/" + item.Link
+				}
+
+				rssItem.Link = parsedUrl.Scheme + "://" + parsedUrl.Host + link
+			}
+		}
+
+		if item.Title != "" {
+			rssItem.Title = item.Title
+		} else {
+			rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
+		}
+
+		if request.IsDetailed {
+			if !request.HideDescription && item.Description != "" && item.Title != "" {
+				rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
+			}
+
+			if !request.HideCategories {
+				var categories = make([]string, 0, 6)
+
+				for _, category := range item.Categories {
+					if len(categories) == 6 {
+						break
+					}
+
+					if len(category) == 0 || len(category) > 30 {
+						continue
+					}
+
+					categories = append(categories, category)
+				}
+
+				rssItem.Categories = categories
+			}
 		}
 		}
 
 
 		if request.Title != "" {
 		if request.Title != "" {
@@ -65,6 +158,8 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
 
 
 		if item.Image != nil {
 		if item.Image != nil {
 			rssItem.ImageURL = item.Image.URL
 			rssItem.ImageURL = item.Image.URL
+		} else if url := findThumbnailInItemExtensions(item); url != "" {
+			rssItem.ImageURL = url
 		} else if feed.Image != nil {
 		} else if feed.Image != nil {
 			rssItem.ImageURL = feed.Image.URL
 			rssItem.ImageURL = feed.Image.URL
 		}
 		}
@@ -81,6 +176,36 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
 	return items, nil
 	return items, nil
 }
 }
 
 
+func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
+	for _, exts := range extensions {
+		for _, ext := range exts {
+			if ext.Name == "thumbnail" || ext.Name == "image" {
+				if url, ok := ext.Attrs["url"]; ok {
+					return url
+				}
+			}
+
+			if ext.Children != nil {
+				if url := recursiveFindThumbnailInExtensions(ext.Children); url != "" {
+					return url
+				}
+			}
+		}
+	}
+
+	return ""
+}
+
+func findThumbnailInItemExtensions(item *gofeed.Item) string {
+	media, ok := item.Extensions["media"]
+
+	if !ok {
+		return ""
+	}
+
+	return recursiveFindThumbnailInExtensions(media)
+}
+
 func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
 func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
 	job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
 	job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
 	feeds, errs, err := workerPoolDo(job)
 	feeds, errs, err := workerPoolDo(job)
@@ -103,7 +228,7 @@ func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
 		entries = append(entries, feeds[i]...)
 		entries = append(entries, feeds[i]...)
 	}
 	}
 
 
-	if len(entries) == 0 {
+	if failed == len(requests) {
 		return nil, ErrNoContent
 		return nil, ErrNoContent
 	}
 	}
 
 

+ 11 - 3
internal/feed/twitch.go

@@ -44,6 +44,12 @@ func (channels TwitchChannels) SortByViewers() {
 	})
 	})
 }
 }
 
 
+func (channels TwitchChannels) SortByLive() {
+	sort.SliceStable(channels, func(i, j int) bool {
+		return channels[i].IsLive && !channels[j].IsLive
+	})
+}
+
 type twitchOperationResponse struct {
 type twitchOperationResponse struct {
 	Data       json.RawMessage
 	Data       json.RawMessage
 	Extensions struct {
 	Extensions struct {
@@ -198,9 +204,11 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
 		result.IsLive = true
 		result.IsLive = true
 		result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
 		result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
 
 
-		if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil && streamMetadata.UserOrNull.Stream.Game != nil {
-			result.Category = streamMetadata.UserOrNull.Stream.Game.Name
-			result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
+		if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
+			if streamMetadata.UserOrNull.Stream.Game != nil {
+				result.Category = streamMetadata.UserOrNull.Stream.Game.Name
+				result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
+			}
 			startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt)
 			startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt)
 
 
 			if err == nil {
 			if err == nil {

+ 18 - 0
internal/feed/utils.go

@@ -4,6 +4,7 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"net/url"
 	"net/url"
+	"regexp"
 	"slices"
 	"slices"
 	"strings"
 	"strings"
 )
 )
@@ -77,3 +78,20 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
 
 
 	return values
 	return values
 }
 }
+
+
+var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
+
+func stripURLScheme(url string) string {
+	return urlSchemePattern.ReplaceAllString(url, "")
+}
+
+func limitStringLength(s string, max int) (string, bool) {
+	asRunes := []rune(s)
+
+	if len(asRunes) > max {
+		return string(asRunes[:max]), true
+	}
+
+	return s, false
+}

+ 19 - 22
internal/feed/yahoo.go

@@ -6,7 +6,7 @@ import (
 	"net/http"
 	"net/http"
 )
 )
 
 
-type stockResponseJson struct {
+type marketResponseJson struct {
 	Chart struct {
 	Chart struct {
 		Result []struct {
 		Result []struct {
 			Meta struct {
 			Meta struct {
@@ -25,30 +25,30 @@ type stockResponseJson struct {
 }
 }
 
 
 // TODO: allow changing chart time frame
 // TODO: allow changing chart time frame
-const stockChartDays = 21
+const marketChartDays = 21
 
 
-func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
-	requests := make([]*http.Request, 0, len(stockRequests))
+func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
+	requests := make([]*http.Request, 0, len(marketRequests))
 
 
-	for i := range stockRequests {
-		request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", stockRequests[i].Symbol), nil)
+	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)
 		requests = append(requests, request)
 	}
 	}
 
 
-	job := newJob(decodeJsonFromRequestTask[stockResponseJson](defaultClient), requests)
+	job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
 	responses, errs, err := workerPoolDo(job)
 	responses, errs, err := workerPoolDo(job)
 
 
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
 		return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
 	}
 	}
 
 
-	stocks := make(Stocks, 0, len(responses))
+	markets := make(Markets, 0, len(responses))
 	var failed int
 	var failed int
 
 
 	for i := range responses {
 	for i := range responses {
 		if errs[i] != nil {
 		if errs[i] != nil {
 			failed++
 			failed++
-			slog.Error("Failed to fetch stock data", "symbol", stockRequests[i].Symbol, "error", errs[i])
+			slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
 			continue
 			continue
 		}
 		}
 
 
@@ -56,14 +56,14 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
 
 
 		if len(response.Chart.Result) == 0 {
 		if len(response.Chart.Result) == 0 {
 			failed++
 			failed++
-			slog.Error("Stock response contains no data", "symbol", stockRequests[i].Symbol)
+			slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
 			continue
 			continue
 		}
 		}
 
 
 		prices := response.Chart.Result[0].Indicators.Quote[0].Close
 		prices := response.Chart.Result[0].Indicators.Quote[0].Close
 
 
-		if len(prices) > stockChartDays {
-			prices = prices[len(prices)-stockChartDays:]
+		if len(prices) > marketChartDays {
+			prices = prices[len(prices)-marketChartDays:]
 		}
 		}
 
 
 		previous := response.Chart.Result[0].Meta.RegularMarketPrice
 		previous := response.Chart.Result[0].Meta.RegularMarketPrice
@@ -80,13 +80,10 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
 			currency = response.Chart.Result[0].Meta.Currency
 			currency = response.Chart.Result[0].Meta.Currency
 		}
 		}
 
 
-		stocks = append(stocks, Stock{
-			Name:       stockRequests[i].Name,
-			Symbol:     response.Chart.Result[0].Meta.Symbol,
-			SymbolLink: stockRequests[i].SymbolLink,
-			ChartLink:  stockRequests[i].ChartLink,
-			Price:      response.Chart.Result[0].Meta.RegularMarketPrice,
-			Currency:   currency,
+		markets = append(markets, Market{
+			MarketRequest: marketRequests[i],
+			Price:         response.Chart.Result[0].Meta.RegularMarketPrice,
+			Currency:      currency,
 			PercentChange: percentChange(
 			PercentChange: percentChange(
 				response.Chart.Result[0].Meta.RegularMarketPrice,
 				response.Chart.Result[0].Meta.RegularMarketPrice,
 				previous,
 				previous,
@@ -95,13 +92,13 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
 		})
 		})
 	}
 	}
 
 
-	if len(stocks) == 0 {
+	if len(markets) == 0 {
 		return nil, ErrNoContent
 		return nil, ErrNoContent
 	}
 	}
 
 
 	if failed > 0 {
 	if failed > 0 {
-		return stocks, fmt.Errorf("%w: could not fetch data for %d stock(s)", ErrPartialContent, failed)
+		return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed)
 	}
 	}
 
 
-	return stocks, nil
+	return markets, nil
 }
 }

+ 11 - 9
internal/feed/youtube.go

@@ -10,7 +10,7 @@ import (
 )
 )
 
 
 type youtubeFeedResponseXml struct {
 type youtubeFeedResponseXml struct {
-	Channel     string `xml:"title"`
+	Channel     string `xml:"author>name"`
 	ChannelLink struct {
 	ChannelLink struct {
 		Href string `xml:"href,attr"`
 		Href string `xml:"href,attr"`
 	} `xml:"link"`
 	} `xml:"link"`
@@ -39,11 +39,19 @@ func parseYoutubeFeedTime(t string) time.Time {
 	return parsedTime
 	return parsedTime
 }
 }
 
 
-func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (Videos, error) {
+func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) {
 	requests := make([]*http.Request, 0, len(channelIds))
 	requests := make([]*http.Request, 0, len(channelIds))
 
 
 	for i := range channelIds {
 	for i := range channelIds {
-		request, _ := http.NewRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id="+channelIds[i], nil)
+		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)
 		requests = append(requests, request)
 	}
 	}
 
 
@@ -70,12 +78,6 @@ func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (V
 
 
 		for j := range response.Videos {
 		for j := range response.Videos {
 			video := &response.Videos[j]
 			video := &response.Videos[j]
-
-			// TODO: figure out a better way of skipping shorts
-			if strings.Contains(video.Title, "#shorts") {
-				continue
-			}
-
 			var videoUrl string
 			var videoUrl string
 
 
 			if videoUrlTemplate == "" {
 			if videoUrlTemplate == "" {

+ 22 - 2
internal/glance/config.go

@@ -33,6 +33,16 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
 		return nil, err
 		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
+				}
+			}
+		}
+	}
+
 	return config, nil
 	return config, nil
 }
 }
 
 
@@ -54,12 +64,22 @@ func configIsValid(config *Config) error {
 			return fmt.Errorf("Page %d has no title", i+1)
 			return fmt.Errorf("Page %d has no title", 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)
+		}
+
 		if len(config.Pages[i].Columns) == 0 {
 		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 len(config.Pages[i].Columns) > 3 {
-			return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
+		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)
+			}
+		} 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))
+			}
 		}
 		}
 
 
 		columnSizesCount := make(map[string]int)
 		columnSizesCount := make(map[string]int)

+ 71 - 15
internal/glance/glance.go

@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"net/http"
 	"path/filepath"
 	"path/filepath"
 	"regexp"
 	"regexp"
+	"strconv"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
 	"time"
 	"time"
@@ -24,6 +25,7 @@ type Application struct {
 	Version    string
 	Version    string
 	Config     Config
 	Config     Config
 	slugToPage map[string]*Page
 	slugToPage map[string]*Page
+	widgetByID map[uint64]widget.Widget
 }
 }
 
 
 type Theme struct {
 type Theme struct {
@@ -38,10 +40,12 @@ type Theme struct {
 }
 }
 
 
 type Server struct {
 type Server struct {
-	Host            string    `yaml:"host"`
-	Port            uint16    `yaml:"port"`
-	AssetsPath      string    `yaml:"assets-path"`
-	StartedAt       time.Time `yaml:"-"`
+	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
 }
 }
 
 
 type Branding struct {
 type Branding struct {
@@ -61,11 +65,13 @@ type templateData struct {
 }
 }
 
 
 type Page struct {
 type Page struct {
-	Title            string   `yaml:"name"`
-	Slug             string   `yaml:"slug"`
-	ShowMobileHeader bool     `yaml:"show-mobile-header"`
-	Columns          []Column `yaml:"columns"`
-	mu               sync.Mutex
+	Title                 string   `yaml:"name"`
+	Slug                  string   `yaml:"slug"`
+	Width                 string   `yaml:"width"`
+	ShowMobileHeader      bool     `yaml:"show-mobile-header"`
+	HideDesktopNavigation bool     `yaml:"hide-desktop-navigation"`
+	Columns               []Column `yaml:"columns"`
+	mu                    sync.Mutex
 }
 }
 
 
 func (p *Page) UpdateOutdatedWidgets() {
 func (p *Page) UpdateOutdatedWidgets() {
@@ -111,16 +117,34 @@ func NewApplication(config *Config) (*Application, error) {
 		Version:    buildVersion,
 		Version:    buildVersion,
 		Config:     *config,
 		Config:     *config,
 		slugToPage: make(map[string]*Page),
 		slugToPage: make(map[string]*Page),
+		widgetByID: make(map[uint64]widget.Widget),
 	}
 	}
 
 
 	app.slugToPage[""] = &config.Pages[0]
 	app.slugToPage[""] = &config.Pages[0]
 
 
-	for i := range config.Pages {
-		if config.Pages[i].Slug == "" {
-			config.Pages[i].Slug = titleToSlug(config.Pages[i].Title)
+	for p := range config.Pages {
+		if config.Pages[p].Slug == "" {
+			config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
 		}
 		}
 
 
-		app.slugToPage[config.Pages[i].Slug] = &config.Pages[i]
+		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
+			}
+		}
+	}
+
+	config = &app.Config
+
+	config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
+
+	if config.Server.BaseURL != "" &&
+		config.Theme.CustomCSSFile != "" &&
+		strings.HasPrefix(config.Theme.CustomCSSFile, "/assets/") {
+		config.Theme.CustomCSSFile = config.Server.BaseURL + config.Theme.CustomCSSFile
 	}
 	}
 
 
 	return app, nil
 	return app, nil
@@ -195,15 +219,47 @@ func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.H
 	})
 	})
 }
 }
 
 
+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)
+		return
+	}
+
+	widget, exists := a.widgetByID[widgetID]
+
+	if !exists {
+		a.HandleNotFound(w, r)
+		return
+	}
+
+	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) Serve() error {
 func (a *Application) Serve() error {
+	a.Config.Server.AssetsHash = assets.PublicFSHash
+
 	// TODO: add gzip support, static files must have their gzipped contents cached
 	// TODO: add gzip support, static files must have their gzipped contents cached
 	// TODO: add HTTPS support
 	// TODO: add HTTPS support
 	mux := http.NewServeMux()
 	mux := http.NewServeMux()
 
 
 	mux.HandleFunc("GET /{$}", a.HandlePageRequest)
 	mux.HandleFunc("GET /{$}", a.HandlePageRequest)
 	mux.HandleFunc("GET /{page}", a.HandlePageRequest)
 	mux.HandleFunc("GET /{page}", a.HandlePageRequest)
+
 	mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
 	mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
-	mux.Handle("GET /static/{path...}", http.StripPrefix("/static/", FileServerWithCache(http.FS(assets.PublicFS), 2*time.Hour)))
+	mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
+
+	mux.Handle(
+		fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
+		http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 8*time.Hour)),
+	)
 
 
 	if a.Config.Server.AssetsPath != "" {
 	if a.Config.Server.AssetsPath != "" {
 		absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
 		absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
@@ -223,7 +279,7 @@ func (a *Application) Serve() error {
 	}
 	}
 
 
 	a.Config.Server.StartedAt = time.Now()
 	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)
 
 
-	slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port)
 	return server.ListenAndServe()
 	return server.ListenAndServe()
 }
 }

+ 1 - 1
internal/glance/main.go

@@ -36,7 +36,7 @@ func Main() int {
 			return 1
 			return 1
 		}
 		}
 
 
-		if app.Serve() != nil {
+		if err := app.Serve(); err != nil {
 			fmt.Printf("http server error: %v\n", err)
 			fmt.Printf("http server error: %v\n", err)
 			return 1
 			return 1
 		}
 		}

+ 2 - 6
internal/widget/bookmarks.go

@@ -2,7 +2,6 @@ package widget
 
 
 import (
 import (
 	"html/template"
 	"html/template"
-	"strings"
 
 
 	"github.com/glanceapp/glance/internal/assets"
 	"github.com/glanceapp/glance/internal/assets"
 )
 )
@@ -34,11 +33,8 @@ func (widget *Bookmarks) Initialize() error {
 				continue
 				continue
 			}
 			}
 
 
-			if strings.HasPrefix(widget.Groups[g].Links[l].Icon, "si:") {
-				icon := strings.TrimPrefix(widget.Groups[g].Links[l].Icon, "si:")
-				widget.Groups[g].Links[l].IsSimpleIcon = true
-				widget.Groups[g].Links[l].Icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
-			}
+			link := &widget.Groups[g].Links[l]
+			link.Icon, link.IsSimpleIcon = toSimpleIconIfPrefixed(link.Icon)
 		}
 		}
 	}
 	}
 
 

+ 66 - 0
internal/widget/changedetection.go

@@ -0,0 +1,66 @@
+package widget
+
+import (
+	"context"
+	"html/template"
+	"time"
+
+	"github.com/glanceapp/glance/internal/assets"
+	"github.com/glanceapp/glance/internal/feed"
+)
+
+type ChangeDetection struct {
+	widgetBase       `yaml:",inline"`
+	ChangeDetections feed.ChangeDetectionWatches `yaml:"-"`
+	WatchUUIDs       []string                    `yaml:"watches"`
+	InstanceURL      string                      `yaml:"instance-url"`
+	Token            OptionalEnvString           `yaml:"token"`
+	Limit            int                         `yaml:"limit"`
+	CollapseAfter    int                         `yaml:"collapse-after"`
+}
+
+func (widget *ChangeDetection) 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 *ChangeDetection) Update(ctx context.Context) {
+	if len(widget.WatchUUIDs) == 0 {
+		uuids, err := feed.FetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token))
+
+		if !widget.canContinueUpdateAfterHandlingErr(err) {
+			return
+		}
+
+		widget.WatchUUIDs = uuids
+	}
+
+	watches, err := feed.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 *ChangeDetection) Render() template.HTML {
+	return widget.render(widget, assets.ChangeDetectionTemplate)
+}

+ 50 - 0
internal/widget/clock.go

@@ -0,0 +1,50 @@
+package widget
+
+import (
+	"errors"
+	"fmt"
+	"html/template"
+	"time"
+
+	"github.com/glanceapp/glance/internal/assets"
+)
+
+type Clock struct {
+	widgetBase `yaml:",inline"`
+	cachedHTML template.HTML `yaml:"-"`
+	HourFormat string        `yaml:"hour-format"`
+	Timezones  []struct {
+		Timezone string `yaml:"timezone"`
+		Label    string `yaml:"label"`
+	} `yaml:"timezones"`
+}
+
+func (widget *Clock) 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")
+	}
+
+	for t := range widget.Timezones {
+		if widget.Timezones[t].Timezone == "" {
+			return errors.New("missing timezone value for clock widget")
+		}
+
+		_, 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)
+		}
+	}
+
+	widget.cachedHTML = widget.render(widget, assets.ClockTemplate)
+
+	return nil
+}
+
+func (widget *Clock) Render() template.HTML {
+	return widget.cachedHTML
+}

+ 59 - 0
internal/widget/extension.go

@@ -0,0 +1,59 @@
+package widget
+
+import (
+	"context"
+	"errors"
+	"html/template"
+	"net/url"
+	"time"
+
+	"github.com/glanceapp/glance/internal/assets"
+	"github.com/glanceapp/glance/internal/feed"
+)
+
+type Extension struct {
+	widgetBase `yaml:",inline"`
+	URL        string            `yaml:"url"`
+	Parameters map[string]string `yaml:"parameters"`
+	AllowHtml  bool              `yaml:"allow-potentially-dangerous-html"`
+	Extension  feed.Extension    `yaml:"-"`
+	cachedHTML template.HTML     `yaml:"-"`
+}
+
+func (widget *Extension) Initialize() error {
+	widget.withTitle("Extension").withCacheDuration(time.Minute * 30)
+
+	if widget.URL == "" {
+		return errors.New("no extension URL specified")
+	}
+
+	_, err := url.Parse(widget.URL)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (widget *Extension) Update(ctx context.Context) {
+	extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
+		URL:        widget.URL,
+		Parameters: widget.Parameters,
+		AllowHtml:  widget.AllowHtml,
+	})
+
+	widget.canContinueUpdateAfterHandlingErr(err)
+
+	widget.Extension = extension
+
+	if extension.Title != "" {
+		widget.Title = extension.Title
+	}
+
+	widget.cachedHTML = widget.render(widget, assets.ExtensionTemplate)
+}
+
+func (widget *Extension) Render() template.HTML {
+	return widget.cachedHTML
+}

+ 12 - 0
internal/widget/fields.go

@@ -6,6 +6,7 @@ import (
 	"os"
 	"os"
 	"regexp"
 	"regexp"
 	"strconv"
 	"strconv"
+	"strings"
 	"time"
 	"time"
 
 
 	"gopkg.in/yaml.v3"
 	"gopkg.in/yaml.v3"
@@ -150,3 +151,14 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
 
 
 	return nil
 	return nil
 }
 }
+
+func toSimpleIconIfPrefixed(icon string) (string, bool) {
+	if !strings.HasPrefix(icon, "si:") {
+		return icon, false
+	}
+
+	icon = strings.TrimPrefix(icon, "si:")
+	icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
+
+	return icon, true
+}

+ 70 - 0
internal/widget/group.go

@@ -0,0 +1,70 @@
+package widget
+
+import (
+	"context"
+	"errors"
+	"html/template"
+	"sync"
+	"time"
+
+	"github.com/glanceapp/glance/internal/assets"
+)
+
+type Group struct {
+	widgetBase `yaml:",inline"`
+	Widgets    Widgets `yaml:"widgets"`
+}
+
+func (widget *Group) 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 allowed")
+		}
+
+		if err := widget.Widgets[i].Initialize(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (widget *Group) 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 *Group) RequiresUpdate(now *time.Time) bool {
+	for i := range widget.Widgets {
+		if widget.Widgets[i].RequiresUpdate(now) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func (widget *Group) Render() template.HTML {
+	return widget.render(widget, assets.GroupTemplate)
+}

+ 4 - 1
internal/widget/hacker-news.go

@@ -21,7 +21,10 @@ type HackerNews struct {
 }
 }
 
 
 func (widget *HackerNews) Initialize() error {
 func (widget *HackerNews) Initialize() error {
-	widget.withTitle("Hacker News").withCacheDuration(30 * time.Minute)
+	widget.
+		withTitle("Hacker News").
+		withTitleURL("https://news.ycombinator.com/").
+		withCacheDuration(30 * time.Minute)
 
 
 	if widget.Limit <= 0 {
 	if widget.Limit <= 0 {
 		widget.Limit = 15
 		widget.Limit = 15

+ 20 - 0
internal/widget/html.go

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

+ 64 - 0
internal/widget/lobsters.go

@@ -0,0 +1,64 @@
+package widget
+
+import (
+	"context"
+	"html/template"
+	"time"
+
+	"github.com/glanceapp/glance/internal/assets"
+	"github.com/glanceapp/glance/internal/feed"
+)
+
+type Lobsters struct {
+	widgetBase     `yaml:",inline"`
+	Posts          feed.ForumPosts `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 *Lobsters) 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 *Lobsters) Update(ctx context.Context) {
+	posts, err := feed.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 *Lobsters) Render() template.HTML {
+	return widget.render(widget, assets.ForumPostsTemplate)
+}

+ 17 - 22
internal/widget/monitor.go

@@ -2,9 +2,7 @@ package widget
 
 
 import (
 import (
 	"context"
 	"context"
-	"fmt"
 	"html/template"
 	"html/template"
-	"net/http"
 	"strconv"
 	"strconv"
 	"time"
 	"time"
 
 
@@ -37,22 +35,23 @@ func statusCodeToText(status int) string {
 
 
 func statusCodeToStyle(status int) string {
 func statusCodeToStyle(status int) string {
 	if status == 200 {
 	if status == 200 {
-		return "good"
+		return "ok"
 	}
 	}
 
 
-	return "bad"
+	return "error"
 }
 }
 
 
 type Monitor struct {
 type Monitor struct {
 	widgetBase `yaml:",inline"`
 	widgetBase `yaml:",inline"`
 	Sites      []struct {
 	Sites      []struct {
-		Title       string            `yaml:"title"`
-		Url         OptionalEnvString `yaml:"url"`
-		IconUrl     string            `yaml:"icon"`
-		SameTab     bool              `yaml:"same-tab"`
-		Status      *feed.SiteStatus  `yaml:"-"`
-		StatusText  string            `yaml:"-"`
-		StatusStyle string            `yaml:"-"`
+		*feed.SiteStatusRequest `yaml:",inline"`
+		Status                  *feed.SiteStatus `yaml:"-"`
+		Title                   string           `yaml:"title"`
+		IconUrl                 string           `yaml:"icon"`
+		IsSimpleIcon            bool             `yaml:"-"`
+		SameTab                 bool             `yaml:"same-tab"`
+		StatusText              string           `yaml:"-"`
+		StatusStyle             string           `yaml:"-"`
 	} `yaml:"sites"`
 	} `yaml:"sites"`
 	Style string `yaml:"style"`
 	Style string `yaml:"style"`
 }
 }
@@ -60,25 +59,21 @@ type Monitor struct {
 func (widget *Monitor) Initialize() error {
 func (widget *Monitor) Initialize() error {
 	widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
 	widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
 
 
+	for i := range widget.Sites {
+		widget.Sites[i].IconUrl, widget.Sites[i].IsSimpleIcon = toSimpleIconIfPrefixed(widget.Sites[i].IconUrl)
+	}
+
 	return nil
 	return nil
 }
 }
 
 
 func (widget *Monitor) Update(ctx context.Context) {
 func (widget *Monitor) Update(ctx context.Context) {
-	requests := make([]*http.Request, len(widget.Sites))
+	requests := make([]*feed.SiteStatusRequest, len(widget.Sites))
 
 
 	for i := range widget.Sites {
 	for i := range widget.Sites {
-		request, err := http.NewRequest("GET", string(widget.Sites[i].Url), nil)
-
-		if err != nil {
-			message := fmt.Errorf("failed to create http request for %s: %s", widget.Sites[i].Url, err)
-			widget.withNotice(message)
-			continue
-		}
-
-		requests[i] = request
+		requests[i] = widget.Sites[i].SiteStatusRequest
 	}
 	}
 
 
-	statuses, err := feed.FetchStatusesForRequests(requests)
+	statuses, err := feed.FetchStatusForSites(requests)
 
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 		return
 		return

+ 4 - 1
internal/widget/reddit.go

@@ -54,7 +54,10 @@ func (widget *Reddit) Initialize() error {
 		}
 		}
 	}
 	}
 
 
-	widget.withTitle("/r/" + widget.Subreddit).withCacheDuration(30 * time.Minute)
+	widget.
+		withTitle("/r/" + widget.Subreddit).
+		withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
+		withCacheDuration(30 * time.Minute)
 
 
 	return nil
 	return nil
 }
 }

+ 13 - 0
internal/widget/rss.go

@@ -18,6 +18,7 @@ type RSS struct {
 	Items           feed.RSSFeedItems     `yaml:"-"`
 	Items           feed.RSSFeedItems     `yaml:"-"`
 	Limit           int                   `yaml:"limit"`
 	Limit           int                   `yaml:"limit"`
 	CollapseAfter   int                   `yaml:"collapse-after"`
 	CollapseAfter   int                   `yaml:"collapse-after"`
+	NoItemsMessage  string                `yaml:"-"`
 }
 }
 
 
 func (widget *RSS) Initialize() error {
 func (widget *RSS) Initialize() error {
@@ -39,6 +40,14 @@ func (widget *RSS) Initialize() error {
 		widget.CardHeight = 0
 		widget.CardHeight = 0
 	}
 	}
 
 
+	if widget.Style == "detailed-list" {
+		for i := range widget.FeedRequests {
+			widget.FeedRequests[i].IsDetailed = true
+		}
+	}
+
+	widget.NoItemsMessage = "No items were returned from the feeds."
+
 	return nil
 	return nil
 }
 }
 
 
@@ -65,5 +74,9 @@ func (widget *RSS) Render() template.HTML {
 		return widget.render(widget, assets.RSSHorizontalCards2Template)
 		return widget.render(widget, assets.RSSHorizontalCards2Template)
 	}
 	}
 
 
+	if widget.Style == "detailed-list" {
+		return widget.render(widget, assets.RSSDetailedListTemplate)
+	}
+
 	return widget.render(widget, assets.RSSListTemplate)
 	return widget.render(widget, assets.RSSListTemplate)
 }
 }

+ 68 - 0
internal/widget/search.go

@@ -0,0 +1,68 @@
+package widget
+
+import (
+	"fmt"
+	"html/template"
+	"strings"
+
+	"github.com/glanceapp/glance/internal/assets"
+)
+
+type SearchBang struct {
+	Title    string
+	Shortcut string
+	URL      string
+}
+
+type Search struct {
+	widgetBase   `yaml:",inline"`
+	cachedHTML   template.HTML `yaml:"-"`
+	SearchEngine string        `yaml:"search-engine"`
+	Bangs        []SearchBang  `yaml:"bangs"`
+	NewTab       bool          `yaml:"new-tab"`
+	Autofocus    bool          `yaml:"autofocus"`
+}
+
+func convertSearchUrl(url string) string {
+	// Go's template is being stubborn and continues to escape the curlies in the
+	// URL regardless of what the type of the variable is so this is my way around it
+	return strings.ReplaceAll(url, "{QUERY}", "!QUERY!")
+}
+
+var searchEngines = map[string]string{
+	"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
+	"google":     "https://www.google.com/search?q={QUERY}",
+}
+
+func (widget *Search) Initialize() error {
+	widget.withTitle("Search").withError(nil)
+
+	if widget.SearchEngine == "" {
+		widget.SearchEngine = "duckduckgo"
+	}
+
+	if url, ok := searchEngines[widget.SearchEngine]; ok {
+		widget.SearchEngine = url
+	}
+
+	widget.SearchEngine = convertSearchUrl(widget.SearchEngine)
+
+	for i := range widget.Bangs {
+		if widget.Bangs[i].Shortcut == "" {
+			return fmt.Errorf("Search bang %d has no shortcut", i+1)
+		}
+
+		if widget.Bangs[i].URL == "" {
+			return fmt.Errorf("Search bang %d has no URL", i+1)
+		}
+
+		widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL)
+	}
+
+	widget.cachedHTML = widget.render(widget, assets.SearchTemplate)
+	return nil
+}
+
+func (widget *Search) Render() template.HTML {
+	return widget.cachedHTML
+}

+ 19 - 14
internal/widget/stocks.go

@@ -9,34 +9,39 @@ import (
 	"github.com/glanceapp/glance/internal/feed"
 	"github.com/glanceapp/glance/internal/feed"
 )
 )
 
 
-// TODO: rename to Markets at some point
-type Stocks struct {
-	widgetBase `yaml:",inline"`
-	Stocks     feed.Stocks `yaml:"stocks"`
-	Sort       string      `yaml:"sort-by"`
-	Style      string      `yaml:"style"`
+type Markets struct {
+	widgetBase     `yaml:",inline"`
+	StocksRequests []feed.MarketRequest `yaml:"stocks"`
+	MarketRequests []feed.MarketRequest `yaml:"markets"`
+	Sort           string               `yaml:"sort-by"`
+	Style          string               `yaml:"style"`
+	Markets        feed.Markets         `yaml:"-"`
 }
 }
 
 
-func (widget *Stocks) Initialize() error {
-	widget.withTitle("Stocks").withCacheDuration(time.Hour)
+func (widget *Markets) Initialize() error {
+	widget.withTitle("Markets").withCacheDuration(time.Hour)
+
+	if len(widget.MarketRequests) == 0 {
+		widget.MarketRequests = widget.StocksRequests
+	}
 
 
 	return nil
 	return nil
 }
 }
 
 
-func (widget *Stocks) Update(ctx context.Context) {
-	stocks, err := feed.FetchStocksDataFromYahoo(widget.Stocks)
+func (widget *Markets) Update(ctx context.Context) {
+	markets, err := feed.FetchMarketsDataFromYahoo(widget.MarketRequests)
 
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 		return
 		return
 	}
 	}
 
 
 	if widget.Sort == "absolute-change" {
 	if widget.Sort == "absolute-change" {
-		stocks.SortByAbsChange()
+		markets.SortByAbsChange()
 	}
 	}
 
 
-	widget.Stocks = stocks
+	widget.Markets = markets
 }
 }
 
 
-func (widget *Stocks) Render() template.HTML {
-	return widget.render(widget, assets.StocksTemplate)
+func (widget *Markets) Render() template.HTML {
+	return widget.render(widget, assets.MarketsTemplate)
 }
 }

+ 15 - 2
internal/widget/twitch-channels.go

@@ -14,15 +14,23 @@ type TwitchChannels struct {
 	ChannelsRequest []string             `yaml:"channels"`
 	ChannelsRequest []string             `yaml:"channels"`
 	Channels        []feed.TwitchChannel `yaml:"-"`
 	Channels        []feed.TwitchChannel `yaml:"-"`
 	CollapseAfter   int                  `yaml:"collapse-after"`
 	CollapseAfter   int                  `yaml:"collapse-after"`
+	SortBy          string               `yaml:"sort-by"`
 }
 }
 
 
 func (widget *TwitchChannels) Initialize() error {
 func (widget *TwitchChannels) Initialize() error {
-	widget.withTitle("Twitch Channels").withCacheDuration(time.Minute * 10)
+	widget.
+		withTitle("Twitch Channels").
+		withTitleURL("https://www.twitch.tv/directory/following").
+		withCacheDuration(time.Minute * 10)
 
 
 	if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
 	if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
 		widget.CollapseAfter = 5
 		widget.CollapseAfter = 5
 	}
 	}
 
 
+	if widget.SortBy != "viewers" && widget.SortBy != "live" {
+		widget.SortBy = "viewers"
+	}
+
 	return nil
 	return nil
 }
 }
 
 
@@ -33,7 +41,12 @@ func (widget *TwitchChannels) Update(ctx context.Context) {
 		return
 		return
 	}
 	}
 
 
-	channels.SortByViewers()
+	if widget.SortBy == "viewers" {
+		channels.SortByViewers()
+	} else if widget.SortBy == "live" {
+		channels.SortByLive()
+	}
+
 	widget.Channels = channels
 	widget.Channels = channels
 }
 }
 
 

+ 4 - 1
internal/widget/twitch-top-games.go

@@ -18,7 +18,10 @@ type TwitchGames struct {
 }
 }
 
 
 func (widget *TwitchGames) Initialize() error {
 func (widget *TwitchGames) Initialize() error {
-	widget.withTitle("Top games on Twitch").withCacheDuration(time.Minute * 10)
+	widget.
+		withTitle("Top games on Twitch").
+		withTitleURL("https://www.twitch.tv/directory?sort=VIEWER_COUNT").
+		withCacheDuration(time.Minute * 10)
 
 
 	if widget.Limit <= 0 {
 	if widget.Limit <= 0 {
 		widget.Limit = 10
 		widget.Limit = 10

+ 13 - 7
internal/widget/videos.go

@@ -10,12 +10,14 @@ import (
 )
 )
 
 
 type Videos struct {
 type Videos struct {
-	widgetBase       `yaml:",inline"`
-	Videos           feed.Videos `yaml:"-"`
-	VideoUrlTemplate string      `yaml:"video-url-template"`
-	Style            string      `yaml:"style"`
-	Channels         []string    `yaml:"channels"`
-	Limit            int         `yaml:"limit"`
+	widgetBase        `yaml:",inline"`
+	Videos            feed.Videos `yaml:"-"`
+	VideoUrlTemplate  string      `yaml:"video-url-template"`
+	Style             string      `yaml:"style"`
+	CollapseAfterRows int         `yaml:"collapse-after-rows"`
+	Channels          []string    `yaml:"channels"`
+	Limit             int         `yaml:"limit"`
+	IncludeShorts     bool        `yaml:"include-shorts"`
 }
 }
 
 
 func (widget *Videos) Initialize() error {
 func (widget *Videos) Initialize() error {
@@ -25,11 +27,15 @@ func (widget *Videos) Initialize() error {
 		widget.Limit = 25
 		widget.Limit = 25
 	}
 	}
 
 
+	if widget.CollapseAfterRows == 0 || widget.CollapseAfterRows < -1 {
+		widget.CollapseAfterRows = 4
+	}
+
 	return nil
 	return nil
 }
 }
 
 
 func (widget *Videos) Update(ctx context.Context) {
 func (widget *Videos) Update(ctx context.Context) {
-	videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate)
+	videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts)
 
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 		return
 		return

+ 26 - 10
internal/widget/weather.go

@@ -14,17 +14,30 @@ type Weather struct {
 	Location     string          `yaml:"location"`
 	Location     string          `yaml:"location"`
 	ShowAreaName bool            `yaml:"show-area-name"`
 	ShowAreaName bool            `yaml:"show-area-name"`
 	HideLocation bool            `yaml:"hide-location"`
 	HideLocation bool            `yaml:"hide-location"`
+	HourFormat   string          `yaml:"hour-format"`
 	Units        string          `yaml:"units"`
 	Units        string          `yaml:"units"`
 	Place        *feed.PlaceJson `yaml:"-"`
 	Place        *feed.PlaceJson `yaml:"-"`
 	Weather      *feed.Weather   `yaml:"-"`
 	Weather      *feed.Weather   `yaml:"-"`
 	TimeLabels   [12]string      `yaml:"-"`
 	TimeLabels   [12]string      `yaml:"-"`
 }
 }
 
 
-var timeLabels = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
+var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
+var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"}
 
 
 func (widget *Weather) Initialize() error {
 func (widget *Weather) Initialize() error {
 	widget.withTitle("Weather").withCacheOnTheHour()
 	widget.withTitle("Weather").withCacheOnTheHour()
-	widget.TimeLabels = timeLabels
+
+	if widget.Location == "" {
+		return fmt.Errorf("location must be specified for weather widget")
+	}
+
+	if widget.HourFormat == "" || widget.HourFormat == "12h" {
+		widget.TimeLabels = timeLabels12h
+	} else if widget.HourFormat == "24h" {
+		widget.TimeLabels = timeLabels24h
+	} else {
+		return fmt.Errorf("invalid hour format '%s' for weather widget, must be either 12h or 24h", widget.HourFormat)
+	}
 
 
 	if widget.Units == "" {
 	if widget.Units == "" {
 		widget.Units = "metric"
 		widget.Units = "metric"
@@ -32,18 +45,21 @@ func (widget *Weather) Initialize() error {
 		return fmt.Errorf("invalid units '%s' for weather, must be either metric or imperial", widget.Units)
 		return fmt.Errorf("invalid units '%s' for weather, must be either metric or imperial", widget.Units)
 	}
 	}
 
 
-	place, err := feed.FetchPlaceFromName(widget.Location)
-
-	if err != nil {
-		return fmt.Errorf("failed fetching data for %s: %v", widget.Location, err)
-	}
-
-	widget.Place = place
-
 	return nil
 	return nil
 }
 }
 
 
 func (widget *Weather) Update(ctx context.Context) {
 func (widget *Weather) Update(ctx context.Context) {
+	if widget.Place == nil {
+		place, err := feed.FetchPlaceFromName(widget.Location)
+
+		if err != nil {
+			widget.withError(err).scheduleEarlyUpdate()
+			return
+		}
+
+		widget.Place = place
+	}
+
 	weather, err := feed.FetchWeatherForPlace(widget.Place, widget.Units)
 	weather, err := feed.FetchWeatherForPlace(widget.Place, widget.Units)
 
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 	if !widget.canContinueUpdateAfterHandlingErr(err) {

+ 71 - 19
internal/widget/widget.go

@@ -8,6 +8,8 @@ import (
 	"html/template"
 	"html/template"
 	"log/slog"
 	"log/slog"
 	"math"
 	"math"
+	"net/http"
+	"sync/atomic"
 	"time"
 	"time"
 
 
 	"github.com/glanceapp/glance/internal/feed"
 	"github.com/glanceapp/glance/internal/feed"
@@ -15,39 +17,61 @@ import (
 	"gopkg.in/yaml.v3"
 	"gopkg.in/yaml.v3"
 )
 )
 
 
+var uniqueID atomic.Uint64
+
 func New(widgetType string) (Widget, error) {
 func New(widgetType string) (Widget, error) {
+	var widget Widget
+
 	switch widgetType {
 	switch widgetType {
 	case "calendar":
 	case "calendar":
-		return &Calendar{}, nil
+		widget = &Calendar{}
+	case "clock":
+		widget = &Clock{}
 	case "weather":
 	case "weather":
-		return &Weather{}, nil
+		widget = &Weather{}
 	case "bookmarks":
 	case "bookmarks":
-		return &Bookmarks{}, nil
+		widget = &Bookmarks{}
 	case "iframe":
 	case "iframe":
-		return &IFrame{}, nil
+		widget = &IFrame{}
+	case "html":
+		widget = &HTML{}
 	case "hacker-news":
 	case "hacker-news":
-		return &HackerNews{}, nil
+		widget = &HackerNews{}
 	case "releases":
 	case "releases":
-		return &Releases{}, nil
+		widget = &Releases{}
 	case "videos":
 	case "videos":
-		return &Videos{}, nil
-	case "stocks":
-		return &Stocks{}, nil
+		widget = &Videos{}
+	case "markets", "stocks":
+		widget = &Markets{}
 	case "reddit":
 	case "reddit":
-		return &Reddit{}, nil
+		widget = &Reddit{}
 	case "rss":
 	case "rss":
-		return &RSS{}, nil
+		widget = &RSS{}
 	case "monitor":
 	case "monitor":
-		return &Monitor{}, nil
+		widget = &Monitor{}
 	case "twitch-top-games":
 	case "twitch-top-games":
-		return &TwitchGames{}, nil
+		widget = &TwitchGames{}
 	case "twitch-channels":
 	case "twitch-channels":
-		return &TwitchChannels{}, nil
+		widget = &TwitchChannels{}
+	case "lobsters":
+		widget = &Lobsters{}
+	case "change-detection":
+		widget = &ChangeDetection{}
 	case "repository":
 	case "repository":
-		return &Repository{}, nil
+		widget = &Repository{}
+	case "search":
+		widget = &Search{}
+	case "extension":
+		widget = &Extension{}
+	case "group":
+		widget = &Group{}
 	default:
 	default:
 		return nil, fmt.Errorf("unknown widget type: %s", widgetType)
 		return nil, fmt.Errorf("unknown widget type: %s", widgetType)
 	}
 	}
+
+	widget.SetID(uniqueID.Add(1))
+
+	return widget, nil
 }
 }
 
 
 type Widgets []Widget
 type Widgets []Widget
@@ -78,10 +102,6 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
 			return err
 			return err
 		}
 		}
 
 
-		if err = widget.Initialize(); err != nil {
-			return err
-		}
-
 		*w = append(*w, widget)
 		*w = append(*w, widget)
 	}
 	}
 
 
@@ -94,6 +114,10 @@ type Widget interface {
 	Update(context.Context)
 	Update(context.Context)
 	Render() template.HTML
 	Render() template.HTML
 	GetType() string
 	GetType() string
+	GetID() uint64
+	SetID(uint64)
+	HandleRequest(w http.ResponseWriter, r *http.Request)
+	SetHideHeader(bool)
 }
 }
 
 
 type cacheType int
 type cacheType int
@@ -105,8 +129,11 @@ const (
 )
 )
 
 
 type widgetBase struct {
 type widgetBase struct {
+	ID                  uint64        `yaml:"-"`
 	Type                string        `yaml:"type"`
 	Type                string        `yaml:"type"`
 	Title               string        `yaml:"title"`
 	Title               string        `yaml:"title"`
+	TitleURL            string        `yaml:"title-url"`
+	CSSClass            string        `yaml:"css-class"`
 	CustomCacheDuration DurationField `yaml:"cache"`
 	CustomCacheDuration DurationField `yaml:"cache"`
 	ContentAvailable    bool          `yaml:"-"`
 	ContentAvailable    bool          `yaml:"-"`
 	Error               error         `yaml:"-"`
 	Error               error         `yaml:"-"`
@@ -116,6 +143,7 @@ type widgetBase struct {
 	cacheType           cacheType     `yaml:"-"`
 	cacheType           cacheType     `yaml:"-"`
 	nextUpdate          time.Time     `yaml:"-"`
 	nextUpdate          time.Time     `yaml:"-"`
 	updateRetriedTimes  int           `yaml:"-"`
 	updateRetriedTimes  int           `yaml:"-"`
+	HideHeader          bool          `yaml:"-"`
 }
 }
 
 
 func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
 func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
@@ -134,6 +162,22 @@ func (w *widgetBase) Update(ctx context.Context) {
 
 
 }
 }
 
 
+func (w *widgetBase) GetID() uint64 {
+	return w.ID
+}
+
+func (w *widgetBase) SetID(id uint64) {
+	w.ID = id
+}
+
+func (w *widgetBase) SetHideHeader(value bool) {
+	w.HideHeader = value
+}
+
+func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) {
+	http.Error(w, "not implemented", http.StatusNotImplemented)
+}
+
 func (w *widgetBase) GetType() string {
 func (w *widgetBase) GetType() string {
 	return w.Type
 	return w.Type
 }
 }
@@ -173,6 +217,14 @@ func (w *widgetBase) withTitle(title string) *widgetBase {
 	return w
 	return w
 }
 }
 
 
+func (w *widgetBase) withTitleURL(titleURL string) *widgetBase {
+	if w.TitleURL == "" {
+		w.TitleURL = titleURL
+	}
+
+	return w
+}
+
 func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase {
 func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase {
 	w.cacheType = cacheTypeDuration
 	w.cacheType = cacheTypeDuration
 
 

+ 0 - 245
scripts/build-and-ship/main.go

@@ -1,245 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"os"
-	"os/exec"
-	"path"
-	"strings"
-)
-
-// bunch of spaget but it does the job for now
-// TODO: tidy up and add a proper build system with CI
-
-const buildPath = "./build"
-const archivesPath = "./build/archives"
-const executableName = "glance"
-const ownerAndRepo = "glanceapp/glance"
-const moduleName = "github.com/" + ownerAndRepo
-
-type archiveType int
-
-const (
-	archiveTypeTarGz archiveType = iota
-	archiveTypeZip
-)
-
-type buildInfo struct {
-	version string
-}
-
-type buildTarget struct {
-	os        string
-	arch      string
-	armV      int
-	extension string
-	archive   archiveType
-}
-
-var buildTargets = []buildTarget{
-	{
-		os:        "windows",
-		arch:      "amd64",
-		extension: ".exe",
-		archive:   archiveTypeZip,
-	},
-	{
-		os:        "windows",
-		arch:      "arm64",
-		extension: ".exe",
-		archive:   archiveTypeZip,
-	},
-	{
-		os:   "linux",
-		arch: "amd64",
-	},
-	{
-		os:   "linux",
-		arch: "arm64",
-	},
-	{
-		os:   "linux",
-		arch: "arm",
-		armV: 6,
-	},
-	{
-		os:   "linux",
-		arch: "arm",
-		armV: 7,
-	},
-	{
-		os:   "openbsd",
-		arch: "amd64",
-	},
-	{
-		os:   "openbsd",
-		arch: "386",
-	},
-}
-
-func main() {
-	cwd, err := os.Getwd()
-
-	if err != nil {
-		fmt.Println(err)
-		os.Exit(1)
-	}
-
-	_, err = os.Stat(buildPath)
-
-	if err == nil {
-		fmt.Println("Cleaning up build path")
-		os.RemoveAll(buildPath)
-	}
-
-	os.Mkdir(buildPath, 0755)
-	os.Mkdir(archivesPath, 0755)
-
-	version, err := getVersionFromGit()
-
-	if err != nil {
-		fmt.Println(version, err)
-		os.Exit(1)
-	}
-
-	info := buildInfo{
-		version: version,
-	}
-
-	for _, target := range buildTargets {
-		fmt.Printf("Building for %s/%s\n", target.os, target.arch)
-		if err := build(cwd, info, target); err != nil {
-			fmt.Println(err)
-			os.Exit(1)
-		}
-	}
-
-	versionTag := fmt.Sprintf("%s:%s", ownerAndRepo, version)
-	latestTag := fmt.Sprintf("%s:latest", ownerAndRepo)
-
-	fmt.Println("Building docker image")
-
-	output, err := exec.Command(
-		"sudo", "docker", "build",
-		"--platform=linux/amd64,linux/arm64,linux/arm/v7",
-		"-t", versionTag,
-		"-t", latestTag,
-		".",
-	).CombinedOutput()
-
-	if err != nil {
-		fmt.Println(string(output))
-		fmt.Println(err)
-		os.Exit(1)
-	}
-
-	var input string
-	fmt.Print("Push docker image? [y/n]: ")
-	fmt.Scanln(&input)
-
-	if input != "y" {
-		os.Exit(0)
-	}
-
-	output, err = exec.Command(
-		"sudo", "docker", "push", versionTag,
-	).CombinedOutput()
-
-	if err != nil {
-		fmt.Printf("Failed pushing %s:\n", versionTag)
-		fmt.Println(string(output))
-		fmt.Println(err)
-		os.Exit(1)
-	}
-
-	output, err = exec.Command(
-		"sudo", "docker", "push", latestTag,
-	).CombinedOutput()
-
-	if err != nil {
-		fmt.Printf("Failed pushing %s:\n", latestTag)
-		fmt.Println(string(output))
-		fmt.Println(err)
-		os.Exit(1)
-	}
-}
-
-func getVersionFromGit() (string, error) {
-	output, err := exec.Command("git", "describe", "--tags", "--abbrev=0").CombinedOutput()
-
-	if err == nil {
-		return strings.TrimSpace(string(output)), err
-	}
-
-	return string(output), err
-}
-
-func archiveFile(name string, target string, t archiveType) error {
-	var output []byte
-	var err error
-
-	if t == archiveTypeZip {
-		output, err = exec.Command("zip", "-j", path.Join(archivesPath, name+".zip"), target).CombinedOutput()
-	} else if t == archiveTypeTarGz {
-		output, err = exec.Command("tar", "-C", buildPath, "-czf", path.Join(archivesPath, name+".tar.gz"), name).CombinedOutput()
-	}
-
-	if err != nil {
-		fmt.Println(string(output))
-		return err
-	}
-
-	return nil
-}
-
-func build(workingDir string, info buildInfo, target buildTarget) error {
-	var name string
-
-	if target.arch != "arm" {
-		name = fmt.Sprintf("%s-%s-%s%s", executableName, target.os, target.arch, target.extension)
-	} else {
-		name = fmt.Sprintf("%s-%s-%sv%d", executableName, target.os, target.arch, target.armV)
-	}
-
-	binaryPath := path.Join(buildPath, name)
-
-	glancePackage := moduleName + "/internal/glance"
-
-	flags := "-s -w"
-	flags += fmt.Sprintf(" -X %s.buildVersion=%s", glancePackage, info.version)
-
-	cmd := exec.Command(
-		"go",
-		"build",
-		"--trimpath",
-		"--ldflags",
-		flags,
-		"-o",
-		binaryPath,
-	)
-
-	cmd.Dir = workingDir
-	env := append(os.Environ(), "GOOS="+target.os, "GOARCH="+target.arch, "CGO_ENABLED=0")
-
-	if target.arch == "arm" {
-		env = append(env, fmt.Sprintf("GOARM=%d", target.armV))
-	}
-
-	cmd.Env = env
-	output, err := cmd.CombinedOutput()
-
-	if err != nil {
-		fmt.Println(err)
-		fmt.Println(string(output))
-		return err
-	}
-
-	os.Chmod(binaryPath, 0755)
-
-	fmt.Println("Creating archive")
-	if err := archiveFile(name, binaryPath, target.archive); err != nil {
-		return err
-	}
-
-	return nil
-}