Browse Source

Merge branch 'main' into main

develoopeer 8 tháng trước cách đây
mục cha
commit
2eb1821941
85 tập tin đã thay đổi với 2983 bổ sung866 xóa
  1. 1 0
      .github/FUNDING.yml
  2. 39 0
      .github/workflows/release.yaml
  3. 1 1
      .gitignore
  4. 69 0
      .goreleaser.yaml
  5. 7 5
      Dockerfile
  6. 8 0
      Dockerfile.goreleaser
  7. 0 14
      Dockerfile.single-platform
  8. 2 2
      README.md
  9. 269 20
      docs/configuration.md
  10. BIN
      docs/images/dns-stats-widget-preview.png
  11. BIN
      docs/images/gaming-page-preview.png
  12. BIN
      docs/images/group-widget-preview.png
  13. BIN
      docs/images/markets-page-preview.png
  14. BIN
      docs/images/mobile-preview.png
  15. BIN
      docs/images/readme-main-image.png
  16. BIN
      docs/images/releases-widget-preview.png
  17. BIN
      docs/images/startpage-preview.png
  18. 222 0
      docs/preconfigured-pages.md
  19. 4 4
      go.mod
  20. 6 6
      go.sum
  21. 41 0
      internal/assets/files.go
  22. 1 0
      internal/assets/static/icons/codeberg.svg
  23. 1 0
      internal/assets/static/icons/dockerhub.svg
  24. 1 0
      internal/assets/static/icons/github.svg
  25. 1 0
      internal/assets/static/icons/gitlab.svg
  26. 66 43
      internal/assets/static/js/main.js
  27. 182 0
      internal/assets/static/js/popover.js
  28. 25 0
      internal/assets/static/js/utils.js
  29. 452 52
      internal/assets/static/main.css
  30. 1 1
      internal/assets/static/manifest.json
  31. 2 13
      internal/assets/templates.go
  32. 14 28
      internal/assets/templates/bookmarks.html
  33. 30 30
      internal/assets/templates/calendar.html
  34. 85 0
      internal/assets/templates/dns-stats.html
  35. 5 6
      internal/assets/templates/document.html
  36. 6 2
      internal/assets/templates/forum-posts.html
  37. 20 0
      internal/assets/templates/group.html
  38. 16 30
      internal/assets/templates/markets.html
  39. 13 13
      internal/assets/templates/monitor.html
  40. 45 32
      internal/assets/templates/page.html
  41. 12 7
      internal/assets/templates/releases.html
  42. 17 0
      internal/assets/templates/repository.html
  43. 2 0
      internal/assets/templates/rss-detailed-list.html
  44. 4 0
      internal/assets/templates/rss-horizontal-cards-2.html
  45. 4 0
      internal/assets/templates/rss-horizontal-cards.html
  46. 4 2
      internal/assets/templates/rss-list.html
  47. 2 2
      internal/assets/templates/search.html
  48. 6 2
      internal/assets/templates/twitch-channels.html
  49. 22 20
      internal/assets/templates/weather.html
  50. 5 3
      internal/assets/templates/widget-base.html
  51. 120 0
      internal/feed/adguard.go
  52. 39 0
      internal/feed/codeberg.go
  53. 102 0
      internal/feed/dockerhub.go
  54. 71 93
      internal/feed/github.go
  55. 48 0
      internal/feed/gitlab.go
  56. 21 11
      internal/feed/lobsters.go
  57. 8 1
      internal/feed/monitor.go
  58. 136 0
      internal/feed/pihole.go
  59. 30 5
      internal/feed/primitives.go
  60. 36 4
      internal/feed/reddit.go
  61. 72 0
      internal/feed/releases.go
  62. 72 23
      internal/feed/rss.go
  63. 5 3
      internal/feed/twitch.go
  64. 21 1
      internal/feed/utils.go
  65. 14 14
      internal/feed/youtube.go
  66. 26 5
      internal/glance/config.go
  67. 97 12
      internal/glance/glance.go
  68. 0 1
      internal/widget/bookmarks.go
  69. 77 0
      internal/widget/dns-stats.go
  70. 4 0
      internal/widget/fields.go
  71. 76 0
      internal/widget/group.go
  72. 4 1
      internal/widget/hacker-news.go
  73. 10 2
      internal/widget/lobsters.go
  74. 0 1
      internal/widget/markets.go
  75. 8 2
      internal/widget/monitor.go
  76. 6 1
      internal/widget/reddit.go
  77. 59 7
      internal/widget/releases.go
  78. 6 0
      internal/widget/repository-overview.go
  79. 14 11
      internal/widget/rss.go
  80. 2 0
      internal/widget/search.go
  81. 4 1
      internal/widget/twitch-channels.go
  82. 4 1
      internal/widget/twitch-top-games.go
  83. 2 1
      internal/widget/videos.go
  84. 76 24
      internal/widget/widget.go
  85. 0 303
      scripts/build-and-ship/main.go

+ 1 - 0
.github/FUNDING.yml

@@ -0,0 +1 @@
+github: [glanceapp]

+ 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

+ 1 - 1
.gitignore

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

+ 69 - 0
.goreleaser.yaml

@@ -0,0 +1,69 @@
+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:
+      - &armv7_image "{{ .ProjectName }}:{{ .Tag }}-armv7"
+    build_flag_templates:
+      - --platform=linux/arm/v7
+    goarch: arm
+    goarm: 7
+    use: buildx
+    dockerfile: Dockerfile.goreleaser
+
+docker_manifests:
+  - name_template: "{{ .ProjectName }}:{{ .Tag }}"
+    image_templates: &multiarch_images
+      - *amd64_image
+      - *arm64v8_image
+      - *armv7_image
+  - name_template: "{{ .ProjectName }}:latest"
+    skip_push: auto
+    image_templates: *multiarch_images

+ 7 - 5
Dockerfile

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

+ 8 - 0
Dockerfile.goreleaser

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

+ 0 - 14
Dockerfile.single-platform

@@ -1,14 +0,0 @@
-FROM golang:1.22.3-alpine3.19 AS builder
-
-WORKDIR /app
-COPY . /app
-RUN CGO_ENABLED=0 go build .
-
-
-FROM alpine:3.19
-
-WORKDIR /app
-COPY --from=builder /app/glance .
-
-EXPOSE 8080/tcp
-ENTRYPOINT ["/app/glance"]

+ 2 - 2
README.md

@@ -1,6 +1,6 @@
 <p align="center"><em>What if you could see everything at a...</em></p>
 <h1 align="center">Glance</h1>
-<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="docs/themes.md">Themes</a></p>
+<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="docs/preconfigured-pages.md">Preconfigured pages</a> • <a href="docs/themes.md">Themes</a> • <a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a></p>
 
 ![example homepage](docs/images/readme-main-image.png)
 
@@ -101,7 +101,7 @@ Build the image:
 **Make sure to replace "owner" with your name or organization.**
 
 ```bash
-docker build -t owner/glance:latest -f Dockerfile.single-platform .
+docker build -t owner/glance:latest .
 ```
 
 Push the image to your registry:

+ 269 - 20
docs/configuration.md

@@ -3,6 +3,7 @@
 - [Intro](#intro)
 - [Preconfigured page](#preconfigured-page)
 - [Server](#server)
+- [Branding](#branding)
 - [Theme](#theme)
   - [Themes](#themes)
 - [Pages & Columns](#pages--columns)
@@ -13,10 +14,12 @@
   - [Lobsters](#lobsters)
   - [Reddit](#reddit)
   - [Search](#search-widget)
+  - [Group](#group)
   - [Extension](#extension)
   - [Weather](#weather)
   - [Monitor](#monitor)
   - [Releases](#releases)
+  - [DNS Stats](#dns-stats)
   - [Repository](#repository)
   - [Bookmarks](#bookmarks)
   - [Calendar](#calendar)
@@ -123,6 +126,7 @@ server:
 | ---- | ---- | -------- | ------- |
 | host | string | no |  |
 | port | number | no | 8080 |
+| base-url | string | no | |
 | assets-path | string | no |  |
 
 #### `host`
@@ -131,6 +135,13 @@ The address which the server will listen on. Setting it to `localhost` means tha
 #### `port`
 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.
+
+> [!IMPORTANT]
+> You need to strip the `base-url` prefix before forwarding the request to the Glance server.
+> In Caddy you can do this using [`handle_path`](https://caddyserver.com/docs/caddyfile/directives/handle_path) or [`uri strip_prefix`](https://caddyserver.com/docs/caddyfile/directives/uri).
+
 #### `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.
 
@@ -168,6 +179,42 @@ To be able to point to an asset from your assets path, use the `/assets/` path l
 icon: /assets/gitea-icon.png
 ```
 
+## Branding
+You can adjust the various parts of the branding through a top level `branding` property. Example:
+
+```yaml
+branding:
+  custom-footer: |
+    <p>Powered by <a href="https://github.com/glanceapp/glance">Glance</a></p>
+  logo-url: /assets/logo.png
+  favicon-url: /assets/logo.png
+```
+
+### Properties
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| hide-footer | bool | no | false |
+| custom-footer | string | no |  |
+| logo-text | string | no | G |
+| logo-url | string | no | |
+| favicon-url | string | no | |
+
+#### `hide-footer`
+Hides the footer when set to `true`.
+
+#### `custom-footer`
+Specify custom HTML to use for the footer.
+
+#### `logo-text`
+Specify custom text to use instead of the "G" found in the navigation.
+
+#### `logo-url`
+Specify a URL to a custom image to use instead of the "G" found in the navigation. If both `logo-text` and `logo-url` are set, only `logo-url` will be used.
+
+#### `favicon-url`
+Specify a URL to a custom image to use for the favicon.
+
 ## Theme
 Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers.
 
@@ -234,6 +281,8 @@ theme:
 > .widget-type-rss a {
 >     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
@@ -261,6 +310,9 @@ pages:
 | ---- | ---- | -------- | ------- |
 | title | string | yes | |
 | slug | string | no | |
+| width | string | no | |
+| center-vertically | boolean | no | false |
+| hide-desktop-navigation | boolean | no | false |
 | show-mobile-header | boolean | no | false |
 | columns | array | yes | |
 
@@ -270,6 +322,23 @@ The name of the page which gets shown in the navigation bar.
 #### `slug`
 The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title.
 
+#### `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`.
+
+#### `center-vertically`
+When set to `true`, vertically centers the content on the page. Has no effect if the content is taller than the height of the viewport.
+
+#### `hide-desktop-navigation`
+Whether to show the navigation links at the top of the page on desktop.
+
 #### `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.
 
@@ -354,7 +423,9 @@ pages:
 | ---- | ---- | -------- |
 | type | string | yes |
 | title | string | no |
+| title-url | string | no |
 | cache | string | no |
+| css-class | string | no |
 
 #### `type`
 Used to specify the widget.
@@ -362,6 +433,9 @@ Used to specify the widget.
 #### `title`
 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`
 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:
 
@@ -376,6 +450,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.
 
+#### `css-class`
+Set custom CSS classes for the specific widget instance.
+
 ### RSS
 Display a list of articles from multiple RSS feeds.
 
@@ -402,10 +479,18 @@ Example:
 | thumbnail-height | float | no | 10 |
 | card-height | float | no | 27 |
 | limit | integer | no | 25 |
+| single-line-titles | boolean | no | false |
 | collapse-after | integer | no | 5 |
 
 ##### `style`
-Used to change the appearance of the widget. Possible values are `vertical-list` and `horizontal-cards` where the former is intended to be used within a small column and the latter a full column. Below are previews of each style.
+Used to change the appearance of the widget. Possible values are:
+
+* `vertical-list` - suitable for `full` and `small` columns
+* `detailed-list` - suitable for `full` columns
+* `horizontal-cards` - suitable for `full` columns
+* `horizontal-cards-2` - suitable for `full` columns
+
+Below is a preview of each style:
 
 `vertical-list`
 
@@ -447,6 +532,9 @@ If an RSS feed isn't returning item links with a base domain and Glance has fail
 ##### `limit`
 The maximum number of articles to show.
 
+##### `single-line-titles`
+When set to `true`, truncates the title of each post if it exceeds one line. Only applies when the style is set to `vertical-list`.
+
 ##### `collapse-after`
 How many articles are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
 
@@ -473,6 +561,7 @@ Preview:
 | limit | integer | no | 25 |
 | 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} |
 
 ##### `channels`
@@ -572,11 +661,23 @@ Preview:
 #### 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.
 
@@ -609,6 +710,7 @@ Example:
 | subreddit | string | yes |  |
 | style | string | no | vertical-list |
 | show-thumbnails | boolean | no | false |
+| show-flairs | boolean | no | false |
 | limit | integer | no | 15 |
 | collapse-after | integer | no | 5 |
 | comments-url-template | string | no | https://www.reddit.com/{POST-PATH} |
@@ -645,6 +747,9 @@ Shows or hides thumbnails next to the post. This only works if the `style` is `v
 >
 > Thumbnails don't work for some subreddits due to Reddit's API not returning the thumbnail URL. No workaround for this yet.
 
+##### `show-flairs`
+Shows post flairs when set to `true`.
+
 ##### `limit`
 The maximum number of posts to show.
 
@@ -724,10 +829,16 @@ Preview:
 | <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`
@@ -738,6 +849,12 @@ Either a value from the table below or a URL to a custom search engine. Use `{QU
 | 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.
+
+##### `autofocus`
+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:
 
@@ -772,6 +889,50 @@ 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).
 
@@ -901,13 +1062,13 @@ You can hover over the "ERROR" text to view more information.
 
 #### Properties
 
-| Name | Type | Required |
-| ---- | ---- | -------- |
-| sites | array | yes |
-| style | string | no |
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| sites | array | yes | |
+| show-failing-only | boolean | no | false |
 
-##### `style`
-To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
+##### `show-failing-only`
+Shows only a list of failing sites when set to `true`.
 
 ##### `sites`
 
@@ -917,6 +1078,7 @@ Properties for each site:
 | ---- | ---- | -------- | ------- |
 | title | string | yes | |
 | url | string | yes | |
+| check-url | string | no | |
 | icon | string | no | |
 | allow-insecure | boolean | no | false |
 | same-tab | boolean | no | false |
@@ -927,7 +1089,11 @@ The title used to indicate the site.
 
 `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`
 
@@ -952,17 +1118,20 @@ Whether to ignore invalid/self-signed certificates.
 Whether to open the link in the same or a new tab.
 
 ### Releases
-Display a list of releases for specific repositories on Github. Draft releases and prereleases will not be shown.
+Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
 
 Example:
 
 ```yaml
 - type: releases
+  show-source-icon: true
   repositories:
-    - immich-app/immich
     - go-gitea/gitea
-    - dani-garcia/vaultwarden
     - jellyfin/jellyfin
+    - glanceapp/glance
+    - codeberg:redict/redict
+    - gitlab:fdroid/fdroidclient
+    - dockerhub:gotify/server
 ```
 
 Preview:
@@ -974,12 +1143,42 @@ Preview:
 | Name | Type | Required | Default |
 | ---- | ---- | -------- | ------- |
 | repositories | array | yes |  |
+| show-source-icon | boolean | no | false |  |
 | token | string | no | |
+| gitlab-token | string | no | |
 | limit | integer | no | 10 |
 | collapse-after | integer | no | 5 |
 
 ##### `repositories`
-A list of repositores for which to fetch the latest release for. Only the name/repo is required, not the full URL.
+A list of repositores to fetch the latest release for. Only the name/repo is required, not the full URL. A prefix can be specified for repositories hosted elsewhere such as GitLab, Codeberg and Docker Hub. Example:
+
+```yaml
+repositories:
+  - gitlab:inkscape/inkscape
+  - dockerhub:glanceapp/glance
+  - codeberg:redict/redict
+```
+
+Official images on Docker Hub can be specified by ommiting the owner:
+
+```yaml
+repositories:
+  - dockerhub:nginx
+  - dockerhub:node
+  - dockerhub:alpine
+```
+
+You can also specify specific tags for Docker Hub images:
+
+```yaml
+repositories:
+  - dockerhub:nginx:latest
+  - dockerhub:nginx:stable-alpine
+```
+
+
+##### `show-source-icon`
+Shows an icon of the source (GitHub/GitLab/Codeberg/Docker Hub) next to the repository name when set to `true`.
 
 ##### `token`
 Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if you're tracking lots of repositories or your cache time is low. To circumvent this you can [create a read only token from your Github account](https://github.com/settings/personal-access-tokens/new) and provide it here.
@@ -1004,12 +1203,65 @@ and then use it in your `glance.yml` like this:
 
 This way you can safely check your `glance.yml` in version control without exposing the token.
 
+##### `gitlab-token`
+Same as the above but used when fetching GitLab releases.
+
 ##### `limit`
 The maximum number of releases to show.
 
 #### `collapse-after`
 How many releases are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
 
+### DNS Stats
+Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home or Pi-hole.
+
+Example:
+
+```yaml
+- type: dns-stats
+  service: adguard
+  url: https://adguard.domain.com/
+  username: admin
+  password: ${ADGUARD_PASSWORD}
+```
+
+Preview:
+
+![](images/dns-stats-widget-preview.png)
+
+> [!NOTE]
+>
+> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole it will be the total number of blocked domains from all adlists.
+
+#### Properties
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| service | string | no | pihole |
+| url | string | yes |  |
+| username | string | when service is `adguard` |  |
+| password | string | when service is `adguard` |  |
+| token | string | when service is `pihole` |  |
+| hour-format | string | no | 12h |
+
+##### `service`
+Either `adguard` or `pihole`.
+
+##### `url`
+The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
+
+##### `username`
+Only required when using AdGuard Home. The username used to log into the admin dashboard. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
+
+##### `password`
+Only required when using AdGuard Home. The password used to log into the admin dashboard. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
+
+##### `token`
+Only required when using Pi-hole. The API token which can be found in `Settings -> API -> Show API token`. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
+
+##### `hour-format`
+Whether to display the relative time in the graph in `12h` or `24h` format.
+
 ### Repository
 Display general information about a repository as well as a list of the latest open pull requests and issues.
 
@@ -1020,6 +1272,7 @@ Example:
   repository: glanceapp/glance
   pull-requests-limit: 5
   issues-limit: 3
+  commits-limit: 3
 ```
 
 Preview:
@@ -1034,6 +1287,7 @@ Preview:
 | token | string | no | |
 | pull-requests-limit | integer | no | 3 |
 | issues-limit | integer | no | 3 |
+| commits-limit | integer | no | -1 |
 
 ##### `repository`
 The owner and repository name that will have their information displayed.
@@ -1047,6 +1301,9 @@ The maximum number of latest open pull requests to show. Set to `-1` to not show
 ##### `issues-limit`
 The maximum number of latest open issues to show. Set to `-1` to not show any.
 
+##### `commits-limit`
+The maximum number of lastest commits to show from the default branch. Set to `-1` to not show any.
+
 ### Bookmarks
 Display a list of links which can be grouped.
 
@@ -1096,14 +1353,10 @@ Preview:
 | Name | Type | Required |
 | ---- | ---- | -------- |
 | groups | array | yes |
-| style | string | no |
 
 ##### `groups`
 An array of groups which can optionally have a title and a custom color.
 
-##### `style`
-To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
-
 ###### Properties for each group
 | Name | Type | Required | Default |
 | ---- | ---- | -------- | ------- |
@@ -1281,7 +1534,6 @@ Preview:
 | ---- | ---- | -------- |
 | markets | array | yes |
 | sort-by | string | no |
-| style | string | no |
 
 ##### `markets`
 An array of markets for which to display information about.
@@ -1289,9 +1541,6 @@ An array of markets for which to display information about.
 ##### `sort-by`
 By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change.
 
-##### `style`
-To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
-
 ###### Properties for each stock
 | Name | Type | Required |
 | ---- | ---- | -------- |

BIN
docs/images/dns-stats-widget-preview.png


BIN
docs/images/gaming-page-preview.png


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


BIN
docs/images/markets-page-preview.png


BIN
docs/images/mobile-preview.png


BIN
docs/images/readme-main-image.png


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


BIN
docs/images/startpage-preview.png


+ 222 - 0
docs/preconfigured-pages.md

@@ -0,0 +1,222 @@
+# Preconfigured pages
+
+Don't want to spend time configuring pages from scratch? No problem! Simply copy the config from the ones below.
+
+Pull requests with your page configurations are welcome!
+
+## Startpage
+
+![](images/startpage-preview.png)
+
+<details>
+<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
+
+```yaml
+- name: Startpage
+  width: slim
+  hide-desktop-navigation: true
+  center-vertically: true
+  columns:
+    - size: full
+      widgets:
+        - type: search
+          autofocus: true
+
+        - type: monitor
+          cache: 1m
+          title: Services
+          sites:
+            - title: Jellyfin
+              url: https://yourdomain.com/
+              icon: si:jellyfin
+            - title: Gitea
+              url: https://yourdomain.com/
+              icon: si:gitea
+            - title: qBittorrent # only for Linux ISOs, of course
+              url: https://yourdomain.com/
+              icon: si:qbittorrent
+            - title: Immich
+              url: https://yourdomain.com/
+              icon: si:immich
+            - title: AdGuard Home
+              url: https://yourdomain.com/
+              icon: si:adguard
+            - title: Vaultwarden
+              url: https://yourdomain.com/
+              icon: si:vaultwarden
+
+        - type: bookmarks
+          groups:
+            - title: General
+              links:
+                - title: Gmail
+                  url: https://mail.google.com/mail/u/0/
+                - title: Amazon
+                  url: https://www.amazon.com/
+                - title: Github
+                  url: https://github.com/
+            - title: Entertainment
+              links:
+                - title: YouTube
+                  url: https://www.youtube.com/
+                - title: Prime Video
+                  url: https://www.primevideo.com/
+                - title: Disney+
+                  url: https://www.disneyplus.com/
+            - title: Social
+              links:
+                - title: Reddit
+                  url: https://www.reddit.com/
+                - title: Twitter
+                  url: https://twitter.com/
+                - title: Instagram
+                  url: https://www.instagram.com/
+```
+</details>
+
+## Markets
+
+![](images/markets-page-preview.png)
+
+<details>
+<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
+
+```yaml
+  - name: Markets
+    columns:
+      - size: small
+        widgets:
+          - type: markets
+            title: Indices
+            markets:
+              - symbol: SPY
+                name: S&P 500
+              - symbol: DX-Y.NYB
+                name: Dollar Index
+
+          - type: markets
+            title: Crypto
+            markets:
+              - symbol: BTC-USD
+                name: Bitcoin
+              - symbol: ETH-USD
+                name: Ethereum
+
+          - type: markets
+            title: Stocks
+            sort-by: absolute-change
+            markets:
+              - symbol: NVDA
+                name: NVIDIA
+              - symbol: AAPL
+                name: Apple
+              - symbol: MSFT
+                name: Microsoft
+              - symbol: GOOGL
+                name: Google
+              - symbol: AMD
+                name: AMD
+              - symbol: RDDT
+                name: Reddit
+              - symbol: AMZN
+                name: Amazon
+              - symbol: TSLA
+                name: Tesla
+              - symbol: INTC
+                name: Intel
+              - symbol: META
+                name: Meta
+
+      - size: full
+        widgets:
+          - type: rss
+            title: News
+            style: horizontal-cards
+            feeds:
+              - url: https://feeds.bloomberg.com/markets/news.rss
+                title: Bloomberg
+              - url: https://moxie.foxbusiness.com/google-publisher/markets.xml
+                title: Fox Business
+              - url: https://moxie.foxbusiness.com/google-publisher/technology.xml
+                title: Fox Business
+
+          - type: group
+            widgets:
+              - type: reddit
+                show-thumbnails: true
+                subreddit: technology
+              - type: reddit
+                show-thumbnails: true
+                subreddit: wallstreetbets
+
+          - type: videos
+            style: grid-cards
+            collapse-after-rows: 3
+            channels:
+              - UCvSXMi2LebwJEM1s4bz5IBA # New Money
+              - UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan
+              - UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve
+
+      - size: small
+        widgets:
+          - type: rss
+            title: News
+            limit: 30
+            collapse-after: 13
+            feeds:
+              - url: https://www.ft.com/technology?format=rss
+                title: Financial Times
+              - url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml
+                title: Wall Street Journal
+```
+</details>
+
+## Gaming
+
+![](images/gaming-page-preview.png)
+
+<details>
+<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
+
+```yaml
+- name: Gaming
+  columns:
+    - size: small
+      widgets:
+        - type: twitch-top-games
+          limit: 20
+          collapse-after: 13
+          exclude:
+            - just-chatting
+            - pools-hot-tubs-and-beaches
+            - music
+            - art
+            - asmr
+
+    - size: full
+      widgets:
+        - type: group
+          widgets:
+            - type: reddit
+              show-thumbnails: true
+              subreddit: pcgaming
+            - type: reddit
+              subreddit: games
+
+        - type: videos
+          style: grid-cards
+          collapse-after-rows: 3
+          channels:
+            - UCNvzD7Z-g64bPXxGzaQaa4g # gameranx
+            - UCZ7AeeVbyslLM_8-nVy2B8Q # Skill Up
+            - UCHDxYLv8iovIbhrfl16CNyg # GameLinked
+            - UC9PBzalIcEQCsiIkq36PyUA # Digital Foundry
+
+    - size: small
+      widgets:
+        - type: reddit
+          subreddit: gamingnews
+          limit: 7
+          style: vertical-cards
+```
+</details>

+ 4 - 4
go.mod

@@ -1,20 +1,20 @@
 module github.com/glanceapp/glance
 
-go 1.22.3
+go 1.22.5
 
 require (
 	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
 )
 
 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/arran4/golang-ical v0.3.1 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/mmcdole/goxpp v1.1.1 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
-	golang.org/x/net v0.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/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
 github.com/arran4/golang-ical v0.3.1 h1:v13B3eQZ9VDHTAvT6M11vVzxYgcYmjyPBE2eAZl3VZk=
@@ -42,8 +42,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
-golang.org/x/net v0.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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -63,8 +63,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 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
 
 import (
+	"crypto/md5"
 	"embed"
+	"encoding/hex"
+	"io"
 	"io/fs"
+	"log/slog"
+	"strconv"
+	"time"
 )
 
 //go:embed static
@@ -13,3 +19,38 @@ var _templateFS embed.FS
 
 var PublicFS, _ = fs.Sub(_publicFS, "static")
 var TemplateFS, _ = fs.Sub(_templateFS, "templates")
+
+func getFSHash(files fs.FS) string {
+	hash := md5.New()
+
+	err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+
+		if d.IsDir() {
+			return nil
+		}
+
+		file, err := files.Open(path)
+
+		if err != nil {
+			return err
+		}
+
+		if _, err := io.Copy(hash, file); err != nil {
+			return err
+		}
+
+		return nil
+	})
+
+	if err == nil {
+		return hex.EncodeToString(hash.Sum(nil))[:10]
+	}
+
+	slog.Warn("Could not compute assets cache", "err", err)
+	return strconv.FormatInt(time.Now().Unix(), 10)
+}
+
+var PublicFSHash = getFSHash(PublicFS)

+ 1 - 0
internal/assets/static/icons/codeberg.svg

@@ -0,0 +1 @@
+<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.955.49A12 12 0 0 0 0 12.49a12 12 0 0 0 1.832 6.373L11.838 5.928a.187.14 0 0 1 .324 0l10.006 12.935A12 12 0 0 0 24 12.49a12 12 0 0 0-12-12 12 12 0 0 0-.045 0zm.375 6.467l4.416 16.553a12 12 0 0 0 5.137-4.213z"/></svg>

+ 1 - 0
internal/assets/static/icons/dockerhub.svg

@@ -0,0 +1 @@
+<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></svg>

+ 1 - 0
internal/assets/static/icons/github.svg

@@ -0,0 +1 @@
+<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

+ 1 - 0
internal/assets/static/icons/gitlab.svg

@@ -0,0 +1 @@
+<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/></svg>

+ 66 - 43
internal/assets/static/main.js → internal/assets/static/js/main.js

@@ -1,30 +1,10 @@
-function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
-    let debounceTimeout;
-    let timesDebounced = 0;
-
-    return function () {
-        if (timesDebounced == maxDebounceTimes) {
-            clearTimeout(debounceTimeout);
-            timesDebounced = 0;
-            callback();
-            return;
-        }
-
-        clearTimeout(debounceTimeout);
-        timesDebounced++;
-
-        debounceTimeout = setTimeout(() => {
-            timesDebounced = 0;
-            callback();
-        }, debounceDelay);
-    };
-};
-
+import { setupPopovers } from './popover.js';
+import { throttledDebounce, isElementVisible } from './utils.js';
 
-async function fetchPageContent(pageSlug) {
+async function fetchPageContent(pageData) {
     // TODO: handle non 200 status codes/time outs
     // 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();
 
     return content;
@@ -107,7 +87,7 @@ function updateRelativeTimeForElements(elements)
     }
 }
 
-function setupSearchboxes() {
+function setupSearchBoxes() {
     const searchWidgets = document.getElementsByClassName("search");
 
     if (searchWidgets.length == 0) {
@@ -117,6 +97,7 @@ function setupSearchboxes() {
     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");
@@ -147,14 +128,13 @@ function setupSearchboxes() {
                     query = input;
                     searchUrlTemplate = defaultSearchUrl;
                 }
-
-                if (query.length == 0) {
+                if (query.length == 0 && currentBang == null) {
                     return;
                 }
 
                 const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
 
-                if (event.ctrlKey) {
+                if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
                     window.open(url, '_blank').focus();
                 } else {
                     window.location.href = url;
@@ -170,9 +150,13 @@ function setupSearchboxes() {
         }
 
         const handleInput = (event) => {
-            const value = event.target.value.trimStart();
-            const words = value.split(" ");
+            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;
@@ -246,6 +230,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() {
     const images = document.querySelectorAll("img[loading=lazy]");
 
@@ -381,7 +405,7 @@ function setupCollapsibleGrids() {
 
         const button = attachExpandToggleButton(gridElement);
 
-        let cardsPerRow = 2;
+        let cardsPerRow;
 
         const resolveCollapsibleItems = () => {
             const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
@@ -411,12 +435,11 @@ function setupCollapsibleGrids() {
             }
         };
 
-        afterContentReady(() => {
-            cardsPerRow = getCardsPerRow();
-            resolveCollapsibleItems();
-        });
+        const observer = new ResizeObserver(() => {
+            if (!isElementVisible(gridElement)) {
+                return;
+            }
 
-        window.addEventListener("resize", () => {
             const newCardsPerRow = getCardsPerRow();
 
             if (cardsPerRow == newCardsPerRow) {
@@ -426,6 +449,8 @@ function setupCollapsibleGrids() {
             cardsPerRow = newCardsPerRow;
             resolveCollapsibleItems();
         });
+
+        afterContentReady(() => observer.observe(gridElement));
     }
 }
 
@@ -544,16 +569,18 @@ function setupClocks() {
 async function setupPage() {
     const pageElement = document.getElementById("page");
     const pageContentElement = document.getElementById("page-content");
-    const pageContent = await fetchPageContent(pageData.slug);
+    const pageContent = await fetchPageContent(pageData);
 
     pageContentElement.innerHTML = pageContent;
 
     try {
+        setupPopovers();
         setupClocks()
         setupCarousels();
-        setupSearchboxes();
+        setupSearchBoxes();
         setupCollapsibleLists();
         setupCollapsibleGrids();
+        setupGroups();
         setupDynamicRelativeTime();
         setupLazyImages();
     } finally {
@@ -569,8 +596,4 @@ async function setupPage() {
     }
 }
 
-if (document.readyState === "loading") {
-    document.addEventListener("DOMContentLoaded", setupPage);
-} else {
-    setupPage();
-}
+setupPage();

+ 182 - 0
internal/assets/static/js/popover.js

@@ -0,0 +1,182 @@
+const defaultShowDelayMs = 200;
+const defaultHideDelayMs = 500;
+const defaultMaxWidth = "300px";
+const defaultDistanceFromTarget = "0px"
+const htmlContentSelector = "[data-popover-html]";
+
+let activeTarget = null;
+let pendingTarget = null;
+let cleanupOnHidePopover = null;
+let togglePopoverTimeout = null;
+
+const containerElement = document.createElement("div");
+const containerComputedStyle = getComputedStyle(containerElement);
+containerElement.addEventListener("mouseenter", clearTogglePopoverTimeout);
+containerElement.addEventListener("mouseleave", handleMouseLeave);
+containerElement.classList.add("popover-container");
+
+const frameElement = document.createElement("div");
+frameElement.classList.add("popover-frame");
+
+const contentElement = document.createElement("div");
+contentElement.classList.add("popover-content");
+
+frameElement.append(contentElement);
+containerElement.append(frameElement);
+document.body.append(containerElement);
+
+const observer = new ResizeObserver(repositionContainer);
+
+function handleMouseEnter(event) {
+    clearTogglePopoverTimeout();
+    const target = event.target;
+    pendingTarget = target;
+    const showDelay = target.dataset.popoverShowDelay || defaultShowDelayMs;
+
+    if (activeTarget !== null) {
+        if (activeTarget !== target) {
+            hidePopover();
+            requestAnimationFrame(() => requestAnimationFrame(showPopover));
+        }
+
+        return;
+    }
+
+    togglePopoverTimeout = setTimeout(showPopover, showDelay);
+}
+
+function handleMouseLeave(event) {
+    clearTogglePopoverTimeout();
+    const target = activeTarget || event.target;
+    togglePopoverTimeout = setTimeout(hidePopover, target.dataset.popoverHideDelay || defaultHideDelayMs);
+}
+
+function clearTogglePopoverTimeout() {
+    clearTimeout(togglePopoverTimeout);
+}
+
+function showPopover() {
+    activeTarget = pendingTarget;
+    pendingTarget = null;
+
+    const popoverType = activeTarget.dataset.popoverType;
+
+    if (popoverType === "text") {
+        const text = activeTarget.dataset.popoverText;
+        if (text === undefined || text === "") return;
+        contentElement.textContent = text;
+    } else if (popoverType === "html") {
+        const htmlContent = activeTarget.querySelector(htmlContentSelector);
+        if (htmlContent === null) return;
+        /**
+         * The reason for all of the below shenanigans is that I want to preserve
+         * all attached event listeners of the original HTML content. This is so I don't have to
+         * re-setup events for things like lazy images, they'd just work as expected.
+         */
+        const placeholder = document.createComment("");
+        htmlContent.replaceWith(placeholder);
+        contentElement.replaceChildren(htmlContent);
+        htmlContent.removeAttribute("data-popover-html");
+        cleanupOnHidePopover = () => {
+            htmlContent.setAttribute("data-popover-html", "");
+            placeholder.replaceWith(htmlContent);
+            placeholder.remove();
+        };
+    } else {
+        return;
+    }
+
+    const contentMaxWidth = activeTarget.dataset.popoverMaxWidth || defaultMaxWidth;
+
+    if (activeTarget.dataset.popoverTextAlign !== undefined) {
+        contentElement.style.textAlign = activeTarget.dataset.popoverTextAlign;
+    } else {
+        contentElement.style.removeProperty("text-align");
+    }
+
+    contentElement.style.maxWidth = contentMaxWidth;
+    containerElement.style.display = "block";
+    activeTarget.classList.add("popover-active");
+    document.addEventListener("keydown", handleHidePopoverOnEscape);
+    window.addEventListener("resize", repositionContainer);
+    observer.observe(containerElement);
+}
+
+function repositionContainer() {
+    const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
+        ? activeTarget.querySelector(activeTarget.dataset.popoverAnchor).getBoundingClientRect()
+        : activeTarget.getBoundingClientRect();
+
+    const containerBounds = containerElement.getBoundingClientRect();
+    const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline"));
+    const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverOffset || 0.5);
+    const position = activeTarget.dataset.popoverPosition || "below";
+    const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2));
+
+    if (left < 0) {
+        containerElement.style.left = 0;
+        containerElement.style.removeProperty("right");
+        containerElement.style.setProperty("--triangle-offset", targetBounds.left - containerInlinePadding + targetBoundsWidthOffset + "px");
+    } else if (left + containerBounds.width > window.innerWidth) {
+        containerElement.style.removeProperty("left");
+        containerElement.style.right = 0;
+        containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + "px");
+    } else {
+        containerElement.style.removeProperty("right");
+        containerElement.style.left = left + "px";
+        containerElement.style.removeProperty("--triangle-offset");
+    }
+
+    const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;
+    const topWhenAbove = targetBounds.top + window.scrollY - containerBounds.height;
+    const topWhenBelow = targetBounds.top + window.scrollY + targetBounds.height;
+
+    if (
+        position === "above" && topWhenAbove > window.scrollY ||
+        (position === "below" && topWhenBelow + containerBounds.height > window.scrollY + window.innerHeight)
+    ) {
+        containerElement.classList.add("position-above");
+        frameElement.style.removeProperty("margin-top");
+        frameElement.style.marginBottom = distanceFromTarget;
+        containerElement.style.top = topWhenAbove + "px";
+    } else {
+        containerElement.classList.remove("position-above");
+        frameElement.style.removeProperty("margin-bottom");
+        frameElement.style.marginTop = distanceFromTarget;
+        containerElement.style.top = topWhenBelow + "px";
+    }
+}
+
+function hidePopover() {
+    if (activeTarget === null) return;
+
+    activeTarget.classList.remove("popover-active");
+    containerElement.style.display = "none";
+    document.removeEventListener("keydown", handleHidePopoverOnEscape);
+    window.removeEventListener("resize", repositionContainer);
+    observer.unobserve(containerElement);
+
+    if (cleanupOnHidePopover !== null) {
+        cleanupOnHidePopover();
+        cleanupOnHidePopover = null;
+    }
+
+    activeTarget = null;
+}
+
+function handleHidePopoverOnEscape(event) {
+    if (event.key === "Escape") {
+        hidePopover();
+    }
+}
+
+export function setupPopovers() {
+    const targets = document.querySelectorAll("[data-popover-type]");
+
+    for (let i = 0; i < targets.length; i++) {
+        const target = targets[i];
+
+        target.addEventListener("mouseenter", handleMouseEnter);
+        target.addEventListener("mouseleave", handleMouseLeave);
+    }
+}

+ 25 - 0
internal/assets/static/js/utils.js

@@ -0,0 +1,25 @@
+export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
+    let debounceTimeout;
+    let timesDebounced = 0;
+
+    return function () {
+        if (timesDebounced == maxDebounceTimes) {
+            clearTimeout(debounceTimeout);
+            timesDebounced = 0;
+            callback();
+            return;
+        }
+
+        clearTimeout(debounceTimeout);
+        timesDebounced++;
+
+        debounceTimeout = setTimeout(() => {
+            timesDebounced = 0;
+            callback();
+        }, debounceDelay);
+    };
+};
+
+export function isElementVisible(element) {
+    return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
+}

+ 452 - 52
internal/assets/static/main.css

@@ -34,6 +34,11 @@
     --color-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm))));
     --color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
     --color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
+    --color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
+    --color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
+    --color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
+    --color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 27% * var(--cm))));
+    --color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
 
     --ths: var(--bgh), calc(var(--bgs) * var(--tsm));
     --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
@@ -54,8 +59,9 @@
     --scheme: 100% -;
 }
 
-.size-title-dynamic {
-    font-size: var(--font-size-h4);
+.page {
+    height: 100%;
+    padding-block: var(--widget-gap);
 }
 
 .page-content, .page.content-ready .page-loading-container {
@@ -66,6 +72,10 @@
     display: block;
 }
 
+.page-column-small .size-title-dynamic {
+    font-size: var(--font-size-h4);
+}
+
 .page-column-full .size-title-dynamic {
     font-size: var(--font-size-h3);
 }
@@ -74,12 +84,18 @@
     color: var(--color-primary);
 }
 
-.text-truncate {
+.text-truncate,
+.single-line-titles .title
+{
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
 }
 
+.single-line-titles .title {
+    display: block;
+}
+
 .text-truncate-2-lines, .text-truncate-3-lines {
     overflow: hidden;
     text-overflow: ellipsis;
@@ -87,8 +103,8 @@
     -webkit-box-orient: vertical;
 }
 
-.text-truncate-3-lines { -webkit-line-clamp: 3; }
-.text-truncate-2-lines { -webkit-line-clamp: 2; }
+.text-truncate-3-lines { line-clamp: 3; -webkit-line-clamp: 3; }
+.text-truncate-2-lines { line-clamp: 2; -webkit-line-clamp: 2; }
 
 .visited-indicator:not(.text-truncate)::after,
 .visited-indicator.text-truncate::before,
@@ -110,20 +126,20 @@
     color: var(--color-primary);
 }
 
-.list        { --list-half-gap: 0rem; }
-.list-gap-2  { --list-half-gap: 0.1rem; }
-.list-gap-4  { --list-half-gap: 0.2rem; }
-.list-gap-10 { --list-half-gap: 0.5rem; }
-.list-gap-14 { --list-half-gap: 0.7rem; }
-.list-gap-20 { --list-half-gap: 1rem; }
-.list-gap-24 { --list-half-gap: 1.2rem; }
-.list-gap-34 { --list-half-gap: 1.7rem; }
+.page-columns-transitioned .list-with-transition > * { animation: collapsibleItemReveal .25s backwards; }
+.list-with-transition > *:nth-child(2) { animation-delay: 30ms; }
+.list-with-transition > *:nth-child(3) { animation-delay: 60ms; }
+.list-with-transition > *:nth-child(4) { animation-delay: 90ms; }
+.list-with-transition > *:nth-child(5) { animation-delay: 120ms; }
+.list-with-transition > *:nth-child(6) { animation-delay: 150ms; }
+.list-with-transition > *:nth-child(7) { animation-delay: 180ms; }
+.list-with-transition > *:nth-child(8) { animation-delay: 210ms; }
 
 .list > *:not(:first-child) {
     margin-top: calc(var(--list-half-gap) * 2);
 }
 
-.list-with-separator > *:not(:first-child) {
+.list.list-with-separator > *:not(:first-child) {
     margin-top: var(--list-half-gap);
     border-top: 1px solid var(--color-separator);
     padding-top: var(--list-half-gap);
@@ -184,6 +200,56 @@
     transform: rotate(-90deg);
 }
 
+.widget-group-header {
+    overflow-x: auto;
+    scrollbar-width: thin;
+}
+
+.widget-group-title {
+    background: none;
+    font: inherit;
+    border: none;
+    text-transform: uppercase;
+    border-bottom: 1px dotted transparent;
+    cursor: pointer;
+    flex-shrink: 0;
+    transition: color .3s, border-color .3s;
+    color: var(--color-text-subdue);
+    line-height: calc(1.6em - 1px);
+}
+
+.widget-group-title:hover:not(.widget-group-title-current) {
+    color: var(--color-text-base);
+}
+
+.widget-group-title-current {
+    border-bottom-color: var(--color-text-base-muted);
+    color: var(--color-text-base);
+}
+
+.widget-group-content {
+    animation: widgetGroupContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
+}
+
+.widget-group-content[data-direction="right"] {
+    --direction: 5px;
+}
+
+.widget-group-content[data-direction="left"] {
+    --direction: -5px;
+}
+
+@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;
 }
@@ -260,9 +326,14 @@ html {
     scroll-behavior: smooth;
 }
 
+html, body {
+    height: 100%;
+}
+
 a {
     text-decoration: none;
     color: inherit;
+    overflow-wrap: break-word;
 }
 
 ul {
@@ -292,7 +363,6 @@ body {
 .page-columns {
     display: flex;
     gap: var(--widget-gap);
-    margin: var(--widget-gap) 0;
     animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
 }
 
@@ -304,13 +374,19 @@ body {
 }
 
 .page-loading-container {
-    margin: 50px auto;
-    width: fit-content;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
     animation: loadingContainerEntrance 200ms backwards;
     animation-delay: 150ms;
     font-size: 2rem;
 }
 
+.page-loading-container > .loading-icon {
+    translate: 0 -250%;
+}
+
 @keyframes loadingContainerEntrance {
     from {
         opacity: 0;
@@ -333,12 +409,6 @@ body {
     }
 }
 
-@keyframes loadingIconSpin {
-    to {
-        transform: rotate(360deg);
-    }
-}
-
 .notice-icon {
     width: 0.7rem;
     height: 0.7rem;
@@ -370,23 +440,155 @@ kbd:active {
     box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
 }
 
+.popover-container, [data-popover-html] {
+    display: none;
+}
+
+.popover-container {
+    --triangle-size: 10px;
+    --triangle-offset: 50%;
+    --triangle-margin: calc(var(--triangle-size) + 3px);
+    --entrance-y-offset: 8px;
+    --entrance-direction: calc(var(--entrance-y-offset) * -1);
+
+    z-index: 20;
+    position: absolute;
+    padding-top: var(--triangle-margin);
+    padding-inline: var(--content-bounds-padding);
+}
+
+.popover-container.position-above {
+    --entrance-direction: var(--entrance-y-offset);
+    padding-top: 0;
+    padding-bottom: var(--triangle-margin);
+}
+
+.popover-frame {
+    --shadow-properties: 0 15px 20px -10px;
+    --shadow-color: hsla(var(--bghs), calc(var(--bgl) * 0.2), 0.5);
+    position: relative;
+    padding: 10px;
+    background: var(--color-popover-background);
+    border: 1px solid var(--color-popover-border);
+    border-radius: 5px;
+    animation: popoverFrameEntrance 0.3s backwards cubic-bezier(0.16, 1, 0.3, 1);
+    box-shadow: var(--shadow-properties) var(--shadow-color);
+}
+
+.popover-frame::before {
+    content: '';
+    position: absolute;
+    width: var(--triangle-size);
+    height: var(--triangle-size);
+    transform: rotate(45deg);
+    background-color: var(--color-popover-background);
+    border-top-left-radius: 2px;
+    border-left: 1px solid var(--color-popover-border);
+    border-top: 1px solid var(--color-popover-border);
+    left: calc(var(--triangle-offset) - (var(--triangle-size) / 2));
+    top: calc(var(--triangle-size) / 2 * -1 - 1px);
+}
+
+.popover-container.position-above .popover-frame::before {
+    transform: rotate(-135deg);
+    top: auto;
+    bottom: calc(var(--triangle-size) / 2 * -1 - 1px);
+}
+
+.popover-container.position-above .popover-frame {
+    --shadow-properties: 0 10px 20px -10px;
+}
+
+@keyframes popoverFrameEntrance {
+    from {
+        opacity: 0;
+        transform: translateY(var(--entrance-direction));
+    }
+}
+
+.summary {
+    width: 100%;
+    cursor: pointer;
+    word-spacing: -0.18em;
+    user-select: none;
+    list-style: none;
+    position: relative;
+    display: flex;
+}
+
+.details[open] .summary {
+    margin-bottom: .8rem;
+}
+
+.summary::before {
+    content: "";
+    position: absolute;
+    inset: -.3rem -.8rem;
+    border-radius: var(--border-radius);
+    background-color: var(--color-widget-background-highlight);
+    opacity: 0;
+    transition: opacity 0.2s;
+    z-index: -1;
+}
+
+.details[open] .summary::before, .summary:hover::before {
+    opacity: 1;
+}
+
+.summary::after {
+    content: "◀";
+    font-size: 1.2em;
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    line-height: 1.3em;
+    right: 0;
+    transition: rotate .5s cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+details[open] .summary::after {
+    rotate: -90deg;
+}
+
 .content-bounds {
     max-width: 1600px;
+    width: 100%;
     margin-inline: auto;
     padding: 0 var(--content-bounds-padding);
 }
 
+.page-width-wide .content-bounds {
+    max-width: 1920px;
+}
+
+.page-width-slim .content-bounds {
+    max-width: 1100px;
+}
+
+.page-center-vertically .page {
+    display: flex;
+    justify-content: center;
+    flex-direction: column;
+}
+
+/* TODO: refactor, otherwise I hope I never have to change dynamic columns again */
 .dynamic-columns {
-    gap: calc(var(--widget-content-vertical-padding) / 2);
+    --list-half-gap: 0.5rem;
+    gap: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
     display: grid;
     grid-template-columns: repeat(var(--columns-per-row), 1fr);
-    margin: calc(0px - var(--widget-content-vertical-padding) / 2) calc(0px - var(--widget-content-horizontal-padding) / 2);
 }
 
 .dynamic-columns > * {
-    padding: calc(var(--widget-content-vertical-padding) / 2) calc(var(--widget-content-horizontal-padding) / 1.5);
-    background-color: var(--color-background);
-    border-radius: var(--border-radius);
+    padding-left: var(--widget-content-horizontal-padding);
+    border-left: 1px solid var(--color-separator);
+    min-width: 0;
+}
+
+.dynamic-columns > *:first-child {
+    padding-top: 0;
+    border-top: none;
+    border-left: none;
 }
 
 .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
@@ -395,23 +597,49 @@ kbd:active {
 .dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
 .dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
 
-@container widget (max-width: 1500px) {
+@container widget (max-width: 599px) {
+    .dynamic-columns { gap: 0; }
     .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
-    .dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
-    .dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
-    .dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
+    .dynamic-columns > * {
+        border-left: none;
+        padding-left: 0;
+    }
+    .dynamic-columns > *:not(:first-child) {
+        margin-top: calc(var(--list-half-gap) * 2);
+    }
+    .dynamic-columns.list-with-separator > *:not(:first-child) {
+        margin-top: var(--list-half-gap);
+        border-top: 1px solid var(--color-separator);
+        padding-top: var(--list-half-gap);
+    }
 }
-@container widget (max-width: 1250px) {
-    .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
+@container widget (min-width: 600px) and (max-width: 849px) {
     .dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
+    .dynamic-columns > :nth-child(2n-1) {
+        border-left: none;
+        padding-left: 0;
+    }
+}
+@container widget (min-width: 850px) and (max-width: 1249px) {
     .dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
+    .dynamic-columns > :nth-child(3n+1) {
+        border-left: none;
+        padding-left: 0;
+    }
 }
-@container widget (max-width: 850px) {
-    .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
-    .dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
+@container widget (min-width: 1250px) and (max-width: 1499px) {
+    .dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
+    .dynamic-columns > :nth-child(4n+1) {
+        border-left: none;
+        padding-left: 0;
+    }
 }
-@container widget (max-width: 550px) {
-    .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
+@container widget (min-width: 1500px) {
+    .dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
+    .dynamic-columns > :nth-child(5n+1) {
+        border-left: none;
+        padding-left: 0;
+    }
 }
 
 .cards-vertical {
@@ -444,6 +672,7 @@ kbd:active {
 
 .cards-horizontal {
     overflow-x: auto;
+    scrollbar-width: thin;
     padding-bottom: 1rem;
 }
 
@@ -467,7 +696,10 @@ kbd:active {
 @container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
 @container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
 
-
+.widget-small-content-bounds {
+    max-width: 350px;
+    margin: 0 auto;
+}
 
 .widget-error-header {
     display: flex;
@@ -538,7 +770,7 @@ kbd:active {
 .widget-header {
     padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
     font-size: var(--font-size-h4);
-    margin-bottom: 1rem;
+    margin-bottom: 0.9rem;
     display: flex;
     align-items: center;
     gap: 1rem;
@@ -584,6 +816,15 @@ kbd:active {
     padding-right: var(--widget-content-horizontal-padding);
 }
 
+.logo:has(img) {
+    display: flex;
+    align-items: center;
+}
+
+.logo img {
+    max-height: 2.7rem;
+}
+
 .nav {
     height: 100%;
     gap: var(--header-items-gap);
@@ -594,7 +835,8 @@ kbd:active {
 }
 
 .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-delay: 150ms;
 }
@@ -609,6 +851,7 @@ kbd:active {
     border-bottom: 2px solid transparent;
     transition: color .3s, border-color .3s;
     font-size: var(--font-size-h3);
+    flex-shrink: 0;
 }
 
 .nav-item:not(.nav-item-current):hover {
@@ -621,9 +864,17 @@ kbd:active {
     color: var(--color-text-highlight);
 }
 
+.release-source-icon {
+    width: 16px;
+    height: 16px;
+    flex-shrink: 0;
+    opacity: 0.4;
+}
+
 .market-chart {
     margin-left: auto;
     width: 6.5rem;
+    flex-shrink: 0;
 }
 
 .market-chart svg {
@@ -676,6 +927,7 @@ kbd:active {
     overflow: hidden;
     display: block;
     text-overflow: ellipsis;
+    line-clamp: 2;
     -webkit-line-clamp: 2;
     display: -webkit-box;
     -webkit-box-orient: vertical;
@@ -731,6 +983,7 @@ kbd:active {
     height: 6rem;
     font: inherit;
     outline: none;
+    color: var(--color-text-highlight);
 }
 
 .search-input::placeholder {
@@ -796,6 +1049,8 @@ kbd:active {
     background-color: var(--color-widget-background-highlight);
     border-radius: var(--border-radius);
     padding: 0.5rem;
+    opacity: 0.7;
+    flex-shrink: 0;
 }
 
 .bookmarks-icon {
@@ -804,10 +1059,6 @@ kbd:active {
     opacity: 0.8;
 }
 
-.simple-icon {
-    opacity: 0.7;
-}
-
 :root:not(.light-scheme) .simple-icon {
     filter: invert(1);
 }
@@ -821,6 +1072,7 @@ kbd:active {
   padding: 0.6rem 0;
 }
 
+
 .calendar-day-today {
   border-radius: var(--border-radius);
   background-color: hsl(
@@ -870,6 +1122,128 @@ kbd:active {
   opacity: 0.8;
 }
 
+.dns-stats-totals {
+    transition: opacity .3s;
+    transition-delay: 50ms;
+}
+
+.dns-stats:has(.dns-stats-graph .popover-active) .dns-stats-totals {
+    opacity: 0.1;
+    transition-delay: 0s;
+}
+
+.dns-stats-graph {
+    --graph-height: 70px;
+    height: var(--graph-height);
+    position: relative;
+    margin-bottom: 2.5rem;
+}
+
+.dns-stats-graph-gridlines-container {
+    position: absolute;
+    z-index: -1;
+    inset: 0;
+}
+
+.dns-stats-graph-gridlines {
+    height: 100%;
+    width: 100%;
+}
+
+.dns-stats-graph-columns {
+    display: flex;
+    height: 100%;
+}
+
+.dns-stats-graph-column {
+    display: flex;
+    justify-content: flex-end;
+    align-items: center;
+    flex-direction: column;
+    width: calc(100% / 8);
+    position: relative;
+}
+
+.dns-stats-graph-column::before {
+    content: '';
+    position: absolute;
+    inset: 1px 0;
+    z-index: -1;
+    opacity: 0;
+    background: var(--color-text-base);
+    transition: opacity .2s;
+}
+
+.dns-stats-graph-column:hover::before {
+    opacity: 0.05;
+}
+
+.dns-stats-graph-bar {
+    width: 14px;
+    height: calc((var(--bar-height) / 100) * var(--graph-height));
+    border: 1px solid var(--color-progress-border);
+    border-radius: var(--border-radius) var(--border-radius) 0 0;
+    display: flex;
+    background: var(--color-widget-background);
+    padding: 2px 2px 0 2px;
+    flex-direction: column;
+    gap: 2px;
+    transition: border-color .2s;
+    min-height: 10px;
+}
+
+.dns-stats-graph-column.popover-active .dns-stats-graph-bar {
+    border-color: var(--color-text-subdue);
+    border-bottom-color: var(--color-progress-border);
+}
+
+.dns-stats-graph-bar > * {
+    border-radius: 2px;
+    background: var(--color-progress-value);
+    min-height: 1px;
+}
+
+.dns-stats-graph-bar > .queries {
+    flex-grow: 1;
+}
+
+.dns-stats-graph-bar > *:last-child {
+    border-bottom-right-radius: 0;
+    border-bottom-left-radius: 0;
+}
+
+.dns-stats-graph-bar > .blocked {
+    background-color: var(--color-negative);
+}
+
+.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
+    opacity: 1;
+    transform: translateY(0);
+}
+
+.dns-stats-graph-time, .dns-stats-graph-columns:hover .dns-stats-graph-time {
+    position: absolute;
+    font-size: var(--font-size-h6);
+    inset-inline: 0;
+    text-align: center;
+    height: 2.5rem;
+    line-height: 2.5rem;
+    top: 100%;
+    user-select: none;
+    opacity: 0;
+    transform: translateY(-0.5rem);
+    transition: opacity .2s, transform .2s;
+}
+
+.dns-stats-graph-column:hover .dns-stats-graph-time {
+    opacity: 1;
+    transform: translateY(0);
+}
+
+.dns-stats-graph-columns:hover .dns-stats-graph-column:not(:hover) .dns-stats-graph-time {
+    opacity: 0;
+}
+
 .weather-column {
     position: relative;
     display: flex;
@@ -878,7 +1252,6 @@ kbd:active {
     flex-direction: column;
     width: calc(100% / 12);
     padding-top: 3px;
-    max-width: 30px;
 }
 
 .weather-column-value, .weather-columns:hover .weather-column-value {
@@ -1028,11 +1401,18 @@ kbd:active {
     transition: filter 0.3s, opacity 0.3s;
 }
 
+.monitor-site-icon.simple-icon {
+    opacity: 0.7;
+}
+
 .monitor-site:hover .monitor-site-icon {
-    filter: grayscale(0);
     opacity: 1;
 }
 
+.monitor-site:hover .monitor-site-icon:not(.simple-icon) {
+    filter: grayscale(0);
+}
+
 .monitor-site-status-icon {
     flex-shrink: 0;
     margin-left: auto;
@@ -1187,7 +1567,7 @@ kbd:active {
         display: none;
     }
 
-    .page-column-full .size-title-dynamic {
+    .page-column-small .size-title-dynamic {
         font-size: var(--font-size-h3);
     }
 
@@ -1212,8 +1592,9 @@ kbd:active {
         }
     }
 
-    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 {
@@ -1246,7 +1627,8 @@ kbd:active {
         padding: 15px var(--content-bounds-padding);
         display: flex;
         align-items: center;
-        overflow-x: scroll;
+        overflow-x: auto;
+        scrollbar-width: thin;
         gap: 2.5rem;
     }
 
@@ -1386,6 +1768,7 @@ kbd:active {
     }
 
     .rss-detailed-description {
+        line-clamp: 3;
         -webkit-line-clamp: 3;
     }
 }
@@ -1405,6 +1788,7 @@ kbd:active {
 .color-positive     { color: var(--color-positive); }
 .color-primary      { color: var(--color-primary); }
 
+.cursor-help        { cursor: help; }
 .break-all          { word-break: break-all; }
 .text-left          { text-align: left; }
 .text-right         { text-align: right; }
@@ -1416,6 +1800,7 @@ kbd:active {
 .shrink-0           { flex-shrink: 0; }
 .min-width-0        { min-width: 0; }
 .max-width-100      { max-width: 100%; }
+.height-100         { height: 100%; }
 .block              { display: block; }
 .inline-block       { display: inline-block; }
 .overflow-hidden    { overflow: hidden; }
@@ -1437,6 +1822,7 @@ kbd:active {
 .gap-7              { gap: 0.7rem; }
 .gap-10             { gap: 1rem; }
 .gap-15             { gap: 1.5rem; }
+.gap-20             { gap: 2rem; }
 .gap-25             { gap: 2.5rem; }
 .gap-35             { gap: 3.5rem; }
 .gap-45             { gap: 4.5rem; }
@@ -1446,6 +1832,11 @@ kbd:active {
 .margin-top-7       { margin-top: 0.7rem; }
 .margin-top-10      { margin-top: 1rem; }
 .margin-top-15      { margin-top: 1.5rem; }
+.margin-top-20      { margin-top: 2rem; }
+.margin-top-25      { margin-top: 2.5rem; }
+.margin-top-35      { margin-top: 3.5rem; }
+.margin-top-40      { margin-top: 4rem; }
+.margin-top-auto    { margin-top: auto; }
 .margin-block-3     { margin-block: 0.3rem; }
 .margin-block-5     { margin-block: 0.5rem; }
 .margin-block-7     { margin-block: 0.7rem; }
@@ -1457,4 +1848,13 @@ kbd:active {
 .margin-bottom-10   { margin-bottom: 1rem; }
 .margin-bottom-15   { margin-bottom: 1.5rem; }
 .margin-bottom-auto { margin-bottom: auto; }
+.padding-block-5    { padding-block: 0.5rem; }
 .scale-half         { transform: scale(0.5); }
+.list               { --list-half-gap: 0rem; }
+.list-gap-2         { --list-half-gap: 0.1rem; }
+.list-gap-4         { --list-half-gap: 0.2rem; }
+.list-gap-10        { --list-half-gap: 0.5rem; }
+.list-gap-14        { --list-half-gap: 0.7rem; }
+.list-gap-20        { --list-half-gap: 1rem; }
+.list-gap-24        { --list-half-gap: 1.2rem; }
+.list-gap-34        { --list-half-gap: 1.7rem; }

+ 1 - 1
internal/assets/static/manifest.json

@@ -6,7 +6,7 @@
     "start_url": "/",
     "icons": [
         {
-            "src": "/static/app-icon.png",
+            "src": "app-icon.png",
             "type": "image/png",
             "sizes": "512x512"
         }

+ 2 - 13
internal/assets/templates.go

@@ -37,6 +37,8 @@ var (
 	RepositoryTemplate            = compileTemplate("repository.html", "widget-base.html")
 	SearchTemplate                = compileTemplate("search.html", "widget-base.html")
 	ExtensionTemplate             = compileTemplate("extension.html", "widget-base.html")
+	GroupTemplate                 = compileTemplate("group.html", "widget-base.html")
+	DNSStatsTemplate              = compileTemplate("dns-stats.html", "widget-base.html")
 )
 
 var globalTemplateFunctions = template.FuncMap{
@@ -49,19 +51,6 @@ var globalTemplateFunctions = template.FuncMap{
 	"formatPrice": func(price float64) string {
 		return intl.Sprintf("%.2f", price)
 	},
-	"formatTime": func(t time.Time) string {
-		return t.Format("2006-01-02 15:04:05")
-	},
-	"shouldCollapse": func(i int, collapseAfter int) bool {
-		if collapseAfter < -1 {
-			return false
-		}
-
-		return i >= collapseAfter
-	},
-	"itemAnimationDelay": func(i int, collapseAfter int) string {
-		return fmt.Sprintf("%dms", (i-collapseAfter)*30)
-	},
 	"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
 		return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
 	},

+ 14 - 28
internal/assets/templates/bookmarks.html

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

+ 30 - 30
internal/assets/templates/calendar.html

@@ -1,34 +1,34 @@
-{{ template "widget-base.html" . }} {{ define "widget-content" }}
-<div class="flex justify-between items-center">
-  <div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
-  <ul class="list-horizontal-text color-highlight size-h4">
-    <li>Week {{ .Calendar.CurrentWeekNumber }}</li>
-    <li>{{ .Calendar.CurrentYear }}</li>
-  </ul>
-</div>
+{{ template "widget-base.html" . }}
 
-<div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
-  <div class="calendar-day">Mo</div>
-  <div class="calendar-day">Tu</div>
-  <div class="calendar-day">We</div>
-  <div class="calendar-day">Th</div>
-  <div class="calendar-day">Fr</div>
-  <div class="calendar-day">Sa</div>
-  <div class="calendar-day">Su</div>
-</div>
+{{ define "widget-content" }} {{ define "widget-content" }}
+<div class="widget-small-content-bounds">
+    <div class="flex justify-between items-center">
+        <div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
+        <ul class="list-horizontal-text color-highlight size-h4">
+            <li>Week {{ .Calendar.CurrentWeekNumber }}</li>
+            <li>{{ .Calendar.CurrentYear }}</li>
+        </ul>
+    </div>
 
-<div class="flex flex-wrap justify-center items-center">
-  {{ range .Calendar.Days }}
-  <div
-    class="calendar-day{{ if eq .Day $.Calendar.CurrentDay }} calendar-day-today{{ end }} {{ if .IsEvent}} calendar-isevent{{ end }}"
-  >
-    {{ if .IsEvent}}
-    <div class="tooltip">
-      <span class="tooltiptext">{{ .Event.EventHover }}</span>
+    <div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
+        <div class="calendar-day">Mo</div>
+        <div class="calendar-day">Tu</div>
+        <div class="calendar-day">We</div>
+        <div class="calendar-day">Th</div>
+        <div class="calendar-day">Fr</div>
+        <div class="calendar-day">Sa</div>
+        <div class="calendar-day">Su</div>
     </div>
-    {{ end }}
-    <span>{{ .Day }}</span>
-  </div>
-  {{ end }}
+
+<div class="flex flex-wrap justify-center items-center">
+	{{ range .Calendar.Days }}
+	<div class="calendar-day{{ if eq .Day $.Calendar.CurrentDay }} calendar-day-today{{ end }} {{ if .IsEvent}} calendar-isevent{{ end }}">
+	{{ if .IsEvent}}
+		<div class="tooltip">
+			<span class="tooltiptext">{{ .Event.EventHover }}</span>
+		</div>
+	{{ end }}
+	<span>{{ .Day }}</span>
+	</div>
 </div>
-{{ end }}
+{{ end }}

+ 85 - 0
internal/assets/templates/dns-stats.html

@@ -0,0 +1,85 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+<div class="widget-small-content-bounds dns-stats">
+    <div class="flex text-center justify-between dns-stats-totals">
+        <div>
+            <div class="color-highlight size-h3">{{ .Stats.TotalQueries | formatNumber }}</div>
+            <div class="size-h6">QUERIES</div>
+        </div>
+        <div>
+            <div class="color-highlight size-h3">{{ .Stats.BlockedPercent }}%</div>
+            <div class="size-h6">BLOCKED</div>
+        </div>
+        {{ if gt .Stats.ResponseTime 0 }}
+        <div>
+            <div class="color-highlight size-h3">{{ .Stats.ResponseTime | formatNumber }}ms</div>
+            <div class="size-h6">LATENCY</div>
+        </div>
+        {{ else }}
+        <div class="cursor-help" data-popover-type="text" data-popover-text="Total number of blocked domains from all adlists" data-popover-max-width="200px" data-popover-text-align="center">
+            <div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatViewerCount }}</div>
+            <div class="size-h6">DOMAINS</div>
+        </div>
+        {{ end }}
+    </div>
+
+    <div class="dns-stats-graph margin-top-15">
+        <div class="dns-stats-graph-gridlines-container">
+            <svg class="dns-stats-graph-gridlines" shape-rendering="crispEdges" viewBox="0 0 1 100" preserveAspectRatio="none">
+                <g stroke="var(--color-graph-gridlines)" stroke-width="1">
+                    <line x1="0" y1="1" x2="1" y2="1" vector-effect="non-scaling-stroke" />
+                    <line x1="0" y1="25" x2="1" y2="25" vector-effect="non-scaling-stroke" />
+                    <line x1="0" y1="50" x2="1" y2="50" vector-effect="non-scaling-stroke" />
+                    <line x1="0" y1="75" x2="1" y2="75" vector-effect="non-scaling-stroke" />
+                    <line x1="0" y1="99" x2="1" y2="99" vector-effect="non-scaling-stroke" stroke="var(--color-progress-bar-border)"/>
+                </g>
+            </svg>
+        </div>
+
+        <div class="dns-stats-graph-columns">
+            {{ range $i, $column := .Stats.Series }}
+            <div class="dns-stats-graph-column" data-popover-type="html" data-popover-position="above" data-popover-show-delay="500">
+                <div data-popover-html>
+                    <div class="flex text-center justify-between gap-25">
+                        <div>
+                            <div class="color-highlight size-h3">{{ $column.Queries | formatNumber }}</div>
+                            <div class="size-h6">QUERIES</div>
+                        </div>
+                        <div>
+                            <div class="color-highlight size-h3">{{ $column.PercentBlocked }}%</div>
+                            <div class="size-h6">BLOCKED</div>
+                        </div>
+                    </div>
+                </div>
+                {{ if gt $column.PercentTotal 0}}
+                <div class="dns-stats-graph-bar" style="--bar-height: {{ $column.PercentTotal }}">
+                    {{ if ne $column.Queries $column.Blocked }}
+                        <div class="queries"></div>
+                    {{ end }}
+                    {{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }}
+                        <div class="blocked" style="flex-basis: {{ $column.PercentBlocked }}%"></div>
+                    {{ end }}
+                </div>
+                {{ end }}
+                <div class="dns-stats-graph-time">{{ index $.TimeLabels $i }}</div>
+            </div>
+            {{ end }}
+        </div>
+    </div>
+
+    {{ if .Stats.TopBlockedDomains }}
+    <details class="details margin-top-40">
+        <summary class="summary">Top blocked domains</summary>
+        <ul class="list list-gap-4 list-with-transition size-h5">
+            {{ range .Stats.TopBlockedDomains }}
+            <li class="flex justify-between align-center">
+                <div class="text-truncate rtl">{{ .Domain }}</div>
+                <div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
+            </li>
+            {{ end }}
+        </ul>
+    </details>
+    {{ end }}
+</div>
+{{ end }}

+ 5 - 6
internal/assets/templates/document.html

@@ -11,12 +11,11 @@
     <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="/static/app-icon.png">
-    <link rel="icon" type="image/png" sizes="50x50" href="/static/favicon.png">
-    <link rel="manifest" href="/static/manifest.json">
-    <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>
+    <link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
+    <link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
+    <link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
+    <link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
+    <script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
     {{ block "document-head-after" . }}{{ end }}
 </head>
 <body>

+ 6 - 2
internal/assets/templates/forum-posts.html

@@ -6,7 +6,11 @@
     <li>
         <div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
             {{ if $.ShowThumbnails }}
-                {{ if ne .ThumbnailUrl "" }}
+                {{ if .IsCrosspost }}
+                <svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
+                    <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
+                </svg>
+                {{ else 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)">
@@ -19,7 +23,7 @@
                 {{ end }}
             {{ end }}
             <div class="grow min-width-0">
-                <a href="{{ .DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
+                <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">

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

+ 16 - 30
internal/assets/templates/markets.html

@@ -1,39 +1,25 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
-{{ if ne .Style "dynamic-columns-experimental" }}
-<ul class="list list-gap-20 list-with-separator">
-    {{ range .Markets }}
-    <li class="flex items-center gap-15">
-        {{ template "market" . }}
-    </li>
-    {{ end }}
-</ul>
-{{ else }}
-<div class="dynamic-columns">
+<div class="dynamic-columns list-gap-20 list-with-separator">
     {{ range .Markets }}
     <div class="flex items-center gap-15">
-        {{ template "market" . }}
-    </div>
-    {{ end }}
-</div>
-{{ end }}
-{{ end }}
-
-{{ 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>
-    <div class="text-truncate">{{ .Name }}</div>
-</div>
+        <div class="min-width-0">
+            <a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
+            <div class="text-truncate">{{ .Name }}</div>
+        </div>
 
-<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>
-    </svg>
-</a>
+        <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>
+            </svg>
+        </a>
 
-<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="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
+        <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="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
+        </div>
+    </div>
+    {{ end }}
 </div>
 {{ end }}

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

@@ -1,22 +1,22 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
-{{ if ne .Style "dynamic-columns-experimental" }}
-<ul class="list list-gap-20 list-with-separator">
+{{ if not (and .ShowFailingOnly (not .HasFailing)) }}
+<ul class="dynamic-columns list-gap-20 list-with-separator">
     {{ range .Sites }}
-    <li class="monitor-site flex items-center gap-15">
-        {{ template "site" . }}
-    </li>
-    {{ end }}
-</ul>
-{{ else }}
-<ul class="dynamic-columns">
-    {{ range .Sites }}
-    <div class="flex items-center gap-15">
+    {{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }} {{ continue }} {{ end }}
+    <div class="monitor-site flex items-center gap-15">
         {{ template "site" . }}
     </div>
     {{ end }}
 </ul>
+{{ else }}
+<div class="flex items-center justify-center gap-10 padding-block-5">
+    <p>All sites are online</p>
+    <svg class="shrink-0" style="width: 1.7rem;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
+        <path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
+    </svg>
+</div>
 {{ end }}
 {{ end }}
 
@@ -24,8 +24,8 @@
 {{ if .IconUrl }}
 <img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
 {{ end }}
-<div>
-    <a class="size-h3 color-highlight" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
+<div class="min-width-0">
+    <a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
     <ul class="list-horizontal-text">
         {{ if not .Status.Error }}
         <li title="{{ .Status.Code }}">{{ .StatusText }}</li>

+ 45 - 32
internal/assets/templates/page.html

@@ -1,16 +1,18 @@
 {{ template "document.html" . }}
 
-{{ define "document-title" }}{{ .Page.Title }} - Glance{{ end }}
+{{ define "document-title" }}{{ .Page.Title }}{{ end }}
 
 {{ define "document-head-before" }}
 <script>
     const pageData = {
         slug: "{{ .Page.Slug }}",
+        baseURL: "{{ .App.Config.Server.BaseURL }}",
     };
 </script>
 {{ 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 }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }}
+
 {{ define "document-head-after" }}
 {{ template "page-style-overrides.gotmpl" . }}
 {{ if ne "" .App.Config.Theme.CustomCSSFile }}
@@ -20,48 +22,59 @@
 
 {{ define "navigation-links" }}
 {{ 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 }}
 
 {{ 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">G</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">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
+            <div class="nav flex grow">
+                {{ template "navigation-links" . }}
+            </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 class="content-bounds">
-    <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 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 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 }}
+    {{ if not .App.Config.Branding.HideFooter }}
+    <div class="footer flex items-center flex-column">
+    {{ if eq "" .App.Config.Branding.CustomFooter }}
+        <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>
+    {{ else }}
+        {{ .App.Config.Branding.CustomFooter }}
+    {{ end }}
     </div>
-    <a class="color-primary block margin-top-5 size-h5" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
+    {{ end }}
+
+    <div class="mobile-navigation-offset"></div>
 </div>
 {{ end }}

+ 12 - 7
internal/assets/templates/releases.html

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

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

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

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

@@ -33,6 +33,8 @@
             {{ end }}
         </div>
     </li>
+    {{ else }}
+    <li>{{ .NoItemsMessage }}</li>
     {{ end }}
 </ul>
 {{ end }}

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

@@ -3,6 +3,7 @@
 {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
 
 {{ define "widget-content" }}
+{{ if gt (len .Items) 0 }}
 <div class="carousel-container">
     <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
         {{ range .Items }}
@@ -25,4 +26,7 @@
         {{ end }}
     </div>
 </div>
+{{ else }}
+<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
+{{ end }}
 {{ end }}

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

@@ -3,6 +3,7 @@
 {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
 
 {{ define "widget-content" }}
+{{ if gt (len .Items) 0 }}
 <div class="carousel-container">
     <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
         {{ range .Items }}
@@ -25,4 +26,7 @@
         {{ end }}
     </div>
 </div>
+{{ else }}
+<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
+{{ end }}
 {{ end }}

+ 4 - 2
internal/assets/templates/rss-list.html

@@ -1,10 +1,10 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
-<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
+<ul class="list list-gap-14 collapsible-container{{ if .SingleLineTitles }} single-line-titles{{ end }}" 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="title size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
         <ul class="list-horizontal-text flex-nowrap">
             <li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
             <li class="min-width-0">
@@ -12,6 +12,8 @@
             </li>
         </ul>
     </li>
+    {{ else }}
+    <li>{{ .NoItemsMessage }}</li>
     {{ end }}
 </ul>
 {{ end }}

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

@@ -3,7 +3,7 @@
 {{ 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 }}">
+<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 }}">
@@ -16,7 +16,7 @@
         </svg>
     </div>
 
-    <input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off">
+    <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>

+ 6 - 2
internal/assets/templates/twitch-channels.html

@@ -7,7 +7,9 @@
         <div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
             <div class="twitch-channel-avatar-container">
                 {{ if .Exists }}
-                <img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
+                <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 }}
                 <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" />
@@ -18,7 +20,9 @@
                 <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 }}
-                    <a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
+                        {{ 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">
                         <li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
                         <li>{{ .ViewersCount | formatViewerCount }} viewers</li>

+ 22 - 20
internal/assets/templates/weather.html

@@ -1,29 +1,31 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
-<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
-<div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}</div>
+<div class="widget-small-content-bounds">
+    <div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
+    <div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}</div>
 
-<div class="weather-columns flex margin-top-15 justify-center">
-    {{ range $i, $column := .Weather.Columns }}
-    <div class="weather-column{{ if eq $i $.Weather.CurrentColumn }} weather-column-current{{ end }}">
-        {{ if $column.HasPrecipitation }}
-        <div class="weather-column-rain"></div>
+    <div class="weather-columns flex margin-top-15 justify-center">
+        {{ range $i, $column := .Weather.Columns }}
+        <div class="weather-column{{ if eq $i $.Weather.CurrentColumn }} weather-column-current{{ end }}">
+            {{ if $column.HasPrecipitation }}
+            <div class="weather-column-rain"></div>
+            {{ end }}
+            {{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }}
+            <div class="weather-column-daylight{{ if eq $i $.Weather.SunriseColumn }} weather-column-daylight-sunrise{{ else if eq $i $.Weather.SunsetColumn }} weather-column-daylight-sunset{{ end }}"></div>
+            {{ end }}
+            <div class="weather-column-value{{ if lt $column.Temperature 0 }} weather-column-value-negative{{ end }}">{{ $column.Temperature | absInt }}</div>
+            <div class="weather-bar" style='--weather-bar-height: {{ printf "%.2f" $column.Scale }}'></div>
+            <div class="weather-column-time">{{ index $.TimeLabels $i }}</div>
+        </div>
         {{ end }}
-        {{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }}
-        <div class="weather-column-daylight{{ if eq $i $.Weather.SunriseColumn }} weather-column-daylight-sunrise{{ else if eq $i $.Weather.SunsetColumn }} weather-column-daylight-sunset{{ end }}"></div>
-        {{ end }}
-        <div class="weather-column-value{{ if lt $column.Temperature 0 }} weather-column-value-negative{{ end }}">{{ $column.Temperature | absInt }}</div>
-        <div class="weather-bar" style='--weather-bar-height: {{ printf "%.2f" $column.Scale }}'></div>
-        <div class="weather-column-time">{{ index $.TimeLabels $i }}</div>
     </div>
-    {{ end }}
-</div>
 
-{{ if not .HideLocation }}
-<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
-    <div class="location-icon"></div>
-    <div class="text-truncate">{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div>
+    {{ if not .HideLocation }}
+    <div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
+        <div class="location-icon"></div>
+        <div class="text-truncate">{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div>
+    </div>
+    {{ end }}
 </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="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 }}
         <div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
         {{ else if .Notice }}
         <div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
         {{ end }}
     </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 }}
             {{ block "widget-content" . }}{{ end }}
         {{ else }}

+ 120 - 0
internal/feed/adguard.go

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

+ 39 - 0
internal/feed/codeberg.go

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

+ 102 - 0
internal/feed/dockerhub.go

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

+ 71 - 93
internal/feed/github.go

@@ -2,119 +2,50 @@ package feed
 
 import (
 	"fmt"
-	"log/slog"
 	"net/http"
+	"strings"
 	"sync"
 	"time"
 )
 
-type githubReleaseResponseJson struct {
+type githubReleaseLatestResponseJson struct {
 	TagName     string `json:"tag_name"`
 	PublishedAt string `json:"published_at"`
 	HtmlUrl     string `json:"html_url"`
-	Draft       bool   `json:"draft"`
-	PreRelease  bool   `json:"prerelease"`
 	Reactions   struct {
 		Downvotes int `json:"-1"`
 	} `json:"reactions"`
 }
 
-func parseGithubTime(t string) time.Time {
-	parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t)
+func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) {
+	httpRequest, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository),
+		nil,
+	)
 
 	if err != nil {
-		return time.Now()
+		return nil, err
 	}
 
-	return parsedTime
-}
-
-func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
-	appReleases := make(AppReleases, 0, len(repositories))
-
-	if len(repositories) == 0 {
-		return appReleases, nil
+	if request.Token != nil {
+		httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
 	}
 
-	requests := make([]*http.Request, len(repositories))
-
-	for i, repository := range repositories {
-		request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=10", repository), nil)
-
-		if token != "" {
-			request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
-		}
-
-		requests[i] = request
-	}
-
-	task := decodeJsonFromRequestTask[[]githubReleaseResponseJson](defaultClient)
-	job := newJob(task, requests).withWorkers(15)
-	responses, errs, err := workerPoolDo(job)
+	response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
 
 	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 github release", "error", errs[i], "url", requests[i].URL)
-			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
-			}
-		}
-
-		if liveRelease == nil {
-			slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
-			continue
-		}
-
-		version := liveRelease.TagName
-
-		if version[0] != 'v' {
-			version = "v" + version
-		}
-
-		appReleases = append(appReleases, AppRelease{
-			Name:         repositories[i],
-			Version:      version,
-			NotesUrl:     liveRelease.HtmlUrl,
-			TimeReleased: parseGithubTime(liveRelease.PublishedAt),
-			Downvotes:    liveRelease.Reactions.Downvotes,
-		})
-	}
-
-	if len(appReleases) == 0 {
-		return nil, ErrNoContent
-	}
-
-	appReleases.SortByNewest()
-
-	if failed > 0 {
-		return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
-	}
-
-	return appReleases, nil
+	return &AppRelease{
+		Source:       ReleaseSourceGithub,
+		Name:         request.Repository,
+		Version:      normalizeVersionFormat(response.TagName),
+		NotesUrl:     response.HtmlUrl,
+		TimeReleased: parseRFC3339Time(response.PublishedAt),
+		Downvotes:    response.Reactions.Downvotes,
+	}, nil
 }
 
 type GithubTicket struct {
@@ -131,6 +62,8 @@ type RepositoryDetails struct {
 	PullRequests     []GithubTicket
 	OpenIssues       int
 	Issues           []GithubTicket
+	LastCommits      int
+	Commits          []CommitDetails
 }
 
 type githubRepositoryDetailsResponseJson struct {
@@ -148,21 +81,40 @@ type githubTicketResponseJson struct {
 	} `json:"items"`
 }
 
-func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) {
-	repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
+type CommitDetails struct {
+	Sha       string
+	Author    string
+	CreatedAt time.Time
+	Message   string
+}
 
+type gitHubCommitResponseJson struct {
+	Sha    string `json:"sha"`
+	Commit struct {
+		Author struct {
+			Name string `json:"name"`
+			Date string `json:"date"`
+		} `json:"author"`
+		Message string `json:"message"`
+	} `json:"commit"`
+}
+
+func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int, maxCommits int) (RepositoryDetails, error) {
+	repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
 	if err != nil {
 		return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
 	}
 
 	PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
 	issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
+	CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repository, maxCommits), nil)
 
 	if token != "" {
 		token = fmt.Sprintf("Bearer %s", token)
 		repositoryRequest.Header.Add("Authorization", token)
 		PRsRequest.Header.Add("Authorization", token)
 		issuesRequest.Header.Add("Authorization", token)
+		CommitsRequest.Header.Add("Authorization", token)
 	}
 
 	var detailsResponse githubRepositoryDetailsResponseJson
@@ -171,6 +123,8 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
 	var PRsErr error
 	var issuesResponse githubTicketResponseJson
 	var issuesErr error
+	var commitsResponse []gitHubCommitResponseJson
+	var CommitsErr error
 	var wg sync.WaitGroup
 
 	wg.Add(1)
@@ -195,6 +149,14 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
 		})()
 	}
 
+	if maxCommits > 0 {
+		wg.Add(1)
+		go (func() {
+			defer wg.Done()
+			commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest)
+		})()
+	}
+
 	wg.Wait()
 
 	if detailsErr != nil {
@@ -207,6 +169,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
 		Forks:        detailsResponse.Forks,
 		PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
 		Issues:       make([]GithubTicket, 0, len(issuesResponse.Tickets)),
+		Commits:      make([]CommitDetails, 0, len(commitsResponse)),
 	}
 
 	err = nil
@@ -220,7 +183,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
 			for i := range PRsResponse.Tickets {
 				details.PullRequests = append(details.PullRequests, GithubTicket{
 					Number:    PRsResponse.Tickets[i].Number,
-					CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
+					CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
 					Title:     PRsResponse.Tickets[i].Title,
 				})
 			}
@@ -237,12 +200,27 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
 			for i := range issuesResponse.Tickets {
 				details.Issues = append(details.Issues, GithubTicket{
 					Number:    issuesResponse.Tickets[i].Number,
-					CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
+					CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
 					Title:     issuesResponse.Tickets[i].Title,
 				})
 			}
 		}
 	}
 
+	if maxCommits > 0 {
+		if CommitsErr != nil {
+			err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, CommitsErr)
+		} else {
+			for i := range commitsResponse {
+				details.Commits = append(details.Commits, CommitDetails{
+					Sha:       commitsResponse[i].Sha,
+					Author:    commitsResponse[i].Commit.Author.Name,
+					CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date),
+					Message:   strings.SplitN(commitsResponse[i].Commit.Message, "\n\n", 2)[0],
+				})
+			}
+		}
+	}
+
 	return details, err
 }

+ 48 - 0
internal/feed/gitlab.go

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

+ 21 - 11
internal/feed/lobsters.go

@@ -55,20 +55,30 @@ func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
 	return posts, nil
 }
 
-func FetchLobstersPosts(sortBy string, tags []string) (ForumPosts, error) {
+func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
 	var feedUrl string
 
-	if sortBy == "hot" {
-		sortBy = "hottest"
-	} else if sortBy == "new" {
-		sortBy = "newest"
-	}
-
-	if len(tags) == 0 {
-		feedUrl = "https://lobste.rs/" + sortBy + ".json"
+	if customURL != "" {
+		feedUrl = customURL
 	} else {
-		tags := strings.Join(tags, ",")
-		feedUrl = "https://lobste.rs/t/" + tags + ".json"
+		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)

+ 8 - 1
internal/feed/monitor.go

@@ -9,6 +9,7 @@ import (
 
 type SiteStatusRequest struct {
 	URL           string `yaml:"url"`
+	CheckURL      string `yaml:"check-url"`
 	AllowInsecure bool   `yaml:"allow-insecure"`
 }
 
@@ -20,7 +21,13 @@ type SiteStatus struct {
 }
 
 func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
-	request, err := http.NewRequest(http.MethodGet, statusRequest.URL, nil)
+	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{

+ 136 - 0
internal/feed/pihole.go

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

+ 30 - 5
internal/feed/primitives.go

@@ -17,6 +17,7 @@ type ForumPost struct {
 	Engagement      float64
 	TimePosted      time.Time
 	Tags            []string
+	IsCrosspost     bool
 }
 
 type ForumPosts []ForumPost
@@ -49,11 +50,13 @@ type Weather struct {
 }
 
 type AppRelease struct {
-	Name         string
-	Version      string
-	NotesUrl     string
-	TimeReleased time.Time
-	Downvotes    int
+	Source        ReleaseSource
+	SourceIconURL string
+	Name          string
+	Version       string
+	NotesUrl      string
+	TimeReleased  time.Time
+	Downvotes     int
 }
 
 type AppReleases []AppRelease
@@ -94,6 +97,28 @@ var currencyToSymbol = map[string]string{
 	"PHP": "₱",
 }
 
+type DNSStats struct {
+	TotalQueries      int
+	BlockedQueries    int
+	BlockedPercent    int
+	ResponseTime      int
+	DomainsBlocked    int
+	Series            [8]DNSStatsSeries
+	TopBlockedDomains []DNSStatsBlockedDomain
+}
+
+type DNSStatsSeries struct {
+	Queries        int
+	Blocked        int
+	PercentTotal   int
+	PercentBlocked int
+}
+
+type DNSStatsBlockedDomain struct {
+	Domain         string
+	PercentBlocked int
+}
+
 type MarketRequest struct {
 	Name       string `yaml:"name"`
 	Symbol     string `yaml:"symbol"`

+ 36 - 4
internal/feed/reddit.go

@@ -25,12 +25,26 @@ type subredditResponseJson struct {
 				Pinned        bool    `json:"pinned"`
 				IsSelf        bool    `json:"is_self"`
 				Thumbnail     string  `json:"thumbnail"`
+				Flair         string  `json:"link_flair_text"`
+				ParentList    []struct {
+					Id        string `json:"id"`
+					Subreddit string `json:"subreddit"`
+					Permalink string `json:"permalink"`
+				} `json:"crosspost_parent_list"`
 			} `json:"data"`
 		} `json:"children"`
 	} `json:"data"`
 }
 
-func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string) (ForumPosts, error) {
+func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
+	template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
+	template = strings.ReplaceAll(template, "{POST-ID}", postId)
+	template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
+
+	return template
+}
+
+func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (ForumPosts, error) {
 	query := url.Values{}
 	var requestUrl string
 
@@ -85,9 +99,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
 		if commentsUrlTemplate == "" {
 			commentsUrl = "https://www.reddit.com" + post.Permalink
 		} else {
-			commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{SUBREDDIT}", subreddit)
-			commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-ID}", post.Id)
-			commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-PATH}", strings.TrimLeft(post.Permalink, "/"))
+			commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
 		}
 
 		forumPost := ForumPost{
@@ -107,6 +119,26 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
 			forumPost.TargetUrl = post.Url
 		}
 
+		if showFlairs && post.Flair != "" {
+			forumPost.Tags = append(forumPost.Tags, post.Flair)
+		}
+
+		if len(post.ParentList) > 0 {
+			forumPost.IsCrosspost = true
+			forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
+
+			if commentsUrlTemplate == "" {
+				forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
+			} else {
+				forumPost.TargetUrl = templateRedditCommentsURL(
+					commentsUrlTemplate,
+					post.ParentList[0].Subreddit,
+					post.ParentList[0].Id,
+					post.ParentList[0].Permalink,
+				)
+			}
+		}
+
 		posts = append(posts, forumPost)
 	}
 

+ 72 - 0
internal/feed/releases.go

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

+ 72 - 23
internal/feed/rss.go

@@ -12,6 +12,7 @@ import (
 	"time"
 
 	"github.com/mmcdole/gofeed"
+	gofeedext "github.com/mmcdole/gofeed/extensions"
 )
 
 type RSSFeedItem struct {
@@ -43,12 +44,25 @@ func sanitizeFeedDescription(description string) string {
 	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 {
 	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
@@ -80,7 +94,6 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
 
 		rssItem := RSSFeedItem{
 			ChannelURL: feed.Link,
-			Title:      item.Title,
 		}
 
 		if request.ItemLinkPrefix != "" {
@@ -97,7 +110,7 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
 			if err == nil {
 				var link string
 
-				if item.Link[0] == '/' {
+				if len(item.Link) > 0 && item.Link[0] == '/' {
 					link = item.Link
 				} else {
 					link = "/" + item.Link
@@ -107,34 +120,34 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
 			}
 		}
 
-		if !request.HideDescription && item.Description != "" {
-			description, _ := limitStringLength(item.Description, 1000)
-			description = sanitizeFeedDescription(description)
-			description, limited := limitStringLength(description, 200)
+		if item.Title != "" {
+			rssItem.Title = item.Title
+		} else {
+			rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
+		}
 
-			if limited {
-				description += "…"
+		if request.IsDetailed {
+			if !request.HideDescription && item.Description != "" && item.Title != "" {
+				rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
 			}
 
-			rssItem.Description = description
-		}
+			if !request.HideCategories {
+				var categories = make([]string, 0, 6)
 
-		if !request.HideCategories {
-			var categories = make([]string, 0, 6)
+				for _, category := range item.Categories {
+					if len(categories) == 6 {
+						break
+					}
 
-			for _, category := range item.Categories {
-				if len(categories) == 6 {
-					break
-				}
+					if len(category) == 0 || len(category) > 30 {
+						continue
+					}
 
-				if len(category) == 0 || len(category) > 30 {
-					continue
+					categories = append(categories, category)
 				}
 
-				categories = append(categories, category)
+				rssItem.Categories = categories
 			}
-
-			rssItem.Categories = categories
 		}
 
 		if request.Title != "" {
@@ -145,8 +158,14 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
 
 		if item.Image != nil {
 			rssItem.ImageURL = item.Image.URL
+		} else if url := findThumbnailInItemExtensions(item); url != "" {
+			rssItem.ImageURL = url
 		} else if feed.Image != nil {
-			rssItem.ImageURL = feed.Image.URL
+			if len(feed.Image.URL) > 0 && feed.Image.URL[0] == '/' {
+				rssItem.ImageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL
+			} else {
+				rssItem.ImageURL = feed.Image.URL
+			}
 		}
 
 		if item.PublishedParsed != nil {
@@ -161,6 +180,36 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
 	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) {
 	job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
 	feeds, errs, err := workerPoolDo(job)
@@ -183,7 +232,7 @@ func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
 		entries = append(entries, feeds[i]...)
 	}
 
-	if len(entries) == 0 {
+	if failed == len(requests) {
 		return nil, ErrNoContent
 	}
 

+ 5 - 3
internal/feed/twitch.go

@@ -204,9 +204,11 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
 		result.IsLive = true
 		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)
 
 			if err == nil {

+ 21 - 1
internal/feed/utils.go

@@ -7,6 +7,7 @@ import (
 	"regexp"
 	"slices"
 	"strings"
+	"time"
 )
 
 var (
@@ -79,7 +80,6 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
 	return values
 }
 
-
 var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
 
 func stripURLScheme(url string) string {
@@ -95,3 +95,23 @@ func limitStringLength(s string, max int) (string, bool) {
 
 	return s, false
 }
+
+func parseRFC3339Time(t string) time.Time {
+	parsed, err := time.Parse(time.RFC3339, t)
+
+	if err != nil {
+		return time.Now()
+	}
+
+	return parsed
+}
+
+func normalizeVersionFormat(version string) string {
+	version = strings.ToLower(strings.TrimSpace(version))
+
+	if len(version) > 0 && version[0] != 'v' {
+		return "v" + version
+	}
+
+	return version
+}

+ 14 - 14
internal/feed/youtube.go

@@ -10,11 +10,9 @@ import (
 )
 
 type youtubeFeedResponseXml struct {
-	Channel     string `xml:"title"`
-	ChannelLink struct {
-		Href string `xml:"href,attr"`
-	} `xml:"link"`
-	Videos []struct {
+	Channel     string `xml:"author>name"`
+	ChannelLink string `xml:"author>uri"`
+	Videos      []struct {
 		Title     string `xml:"title"`
 		Published string `xml:"published"`
 		Link      struct {
@@ -39,11 +37,19 @@ func parseYoutubeFeedTime(t string) time.Time {
 	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))
 
 	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)
 	}
 
@@ -70,12 +76,6 @@ func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (V
 
 		for j := range response.Videos {
 			video := &response.Videos[j]
-
-			// TODO: figure out a better way of skipping shorts
-			if strings.Contains(video.Title, "#shorts") {
-				continue
-			}
-
 			var videoUrl string
 
 			if videoUrlTemplate == "" {
@@ -95,7 +95,7 @@ func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (V
 				Title:        video.Title,
 				Url:          videoUrl,
 				Author:       response.Channel,
-				AuthorUrl:    response.ChannelLink.Href + "/videos",
+				AuthorUrl:    response.ChannelLink + "/videos",
 				TimePosted:   parseYoutubeFeedTime(video.Published),
 			})
 		}

+ 26 - 5
internal/glance/config.go

@@ -8,9 +8,10 @@ import (
 )
 
 type Config struct {
-	Server Server `yaml:"server"`
-	Theme  Theme  `yaml:"theme"`
-	Pages  []Page `yaml:"pages"`
+	Server   Server   `yaml:"server"`
+	Theme    Theme    `yaml:"theme"`
+	Branding Branding `yaml:"branding"`
+	Pages    []Page   `yaml:"pages"`
 }
 
 func NewConfigFromYml(contents io.Reader) (*Config, error) {
@@ -32,6 +33,16 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
 		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
 }
 
@@ -50,12 +61,22 @@ func configIsValid(config *Config) error {
 			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 {
 			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)

+ 97 - 12
internal/glance/glance.go

@@ -4,10 +4,12 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"html/template"
 	"log/slog"
 	"net/http"
 	"path/filepath"
 	"regexp"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -24,6 +26,7 @@ type Application struct {
 	Version    string
 	Config     Config
 	slugToPage map[string]*Page
+	widgetByID map[uint64]widget.Widget
 }
 
 type Theme struct {
@@ -41,7 +44,17 @@ type Server struct {
 	Host       string    `yaml:"host"`
 	Port       uint16    `yaml:"port"`
 	AssetsPath string    `yaml:"assets-path"`
-	StartedAt  time.Time `yaml:"-"`
+	BaseURL    string    `yaml:"base-url"`
+	AssetsHash string    `yaml:"-"`
+	StartedAt  time.Time `yaml:"-"` // used in custom css file
+}
+
+type Branding struct {
+	HideFooter   bool          `yaml:"hide-footer"`
+	CustomFooter template.HTML `yaml:"custom-footer"`
+	LogoText     string        `yaml:"logo-text"`
+	LogoURL      string        `yaml:"logo-url"`
+	FaviconURL   string        `yaml:"favicon-url"`
 }
 
 type Column struct {
@@ -55,11 +68,14 @@ type templateData 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"`
+	CenterVertically      bool     `yaml:"center-vertically"`
+	Columns               []Column `yaml:"columns"`
+	mu                    sync.Mutex
 }
 
 func (p *Page) UpdateOutdatedWidgets() {
@@ -96,6 +112,14 @@ func titleToSlug(s string) string {
 	return s
 }
 
+func (a *Application) TransformUserDefinedAssetPath(path string) string {
+	if strings.HasPrefix(path, "/assets/") {
+		return a.Config.Server.BaseURL + path
+	}
+
+	return path
+}
+
 func NewApplication(config *Config) (*Application, error) {
 	if len(config.Pages) == 0 {
 		return nil, fmt.Errorf("no pages configured")
@@ -105,18 +129,46 @@ func NewApplication(config *Config) (*Application, error) {
 		Version:    buildVersion,
 		Config:     *config,
 		slugToPage: make(map[string]*Page),
+		widgetByID: make(map[uint64]widget.Widget),
 	}
 
+	app.Config.Server.AssetsHash = assets.PublicFSHash
 	app.slugToPage[""] = &config.Pages[0]
 
-	for i := range config.Pages {
-		if config.Pages[i].Slug == "" {
-			config.Pages[i].Slug = titleToSlug(config.Pages[i].Title)
+	providers := &widget.Providers{
+		AssetResolver: app.AssetPath,
+	}
+
+	for p := range config.Pages {
+		if config.Pages[p].Slug == "" {
+			config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
 		}
 
-		app.slugToPage[config.Pages[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
+
+				widget.SetProviders(providers)
+			}
+		}
 	}
 
+	config = &app.Config
+
+	config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
+	config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile)
+
+	if config.Branding.FaviconURL == "" {
+		config.Branding.FaviconURL = app.AssetPath("favicon.png")
+	} else {
+		config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL)
+	}
+
+	config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
+
 	return app, nil
 }
 
@@ -189,6 +241,30 @@ 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 {
 	// TODO: add gzip support, static files must have their gzipped contents cached
 	// TODO: add HTTPS support
@@ -196,8 +272,17 @@ func (a *Application) Serve() error {
 
 	mux.HandleFunc("GET /{$}", a.HandlePageRequest)
 	mux.HandleFunc("GET /{page}", a.HandlePageRequest)
+
 	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.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	})
+
+	mux.Handle(
+		fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
+		http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
+	)
 
 	if a.Config.Server.AssetsPath != "" {
 		absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
@@ -217,7 +302,7 @@ func (a *Application) Serve() error {
 	}
 
 	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()
 }

+ 0 - 1
internal/widget/bookmarks.go

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

+ 77 - 0
internal/widget/dns-stats.go

@@ -0,0 +1,77 @@
+package widget
+
+import (
+	"context"
+	"errors"
+	"html/template"
+	"strings"
+	"time"
+
+	"github.com/glanceapp/glance/internal/assets"
+	"github.com/glanceapp/glance/internal/feed"
+)
+
+type DNSStats struct {
+	widgetBase `yaml:",inline"`
+
+	TimeLabels [8]string      `yaml:"-"`
+	Stats      *feed.DNSStats `yaml:"-"`
+
+	HourFormat string            `yaml:"hour-format"`
+	Service    string            `yaml:"service"`
+	URL        OptionalEnvString `yaml:"url"`
+	Token      OptionalEnvString `yaml:"token"`
+	Username   OptionalEnvString `yaml:"username"`
+	Password   OptionalEnvString `yaml:"password"`
+}
+
+func makeDNSTimeLabels(format string) [8]string {
+	now := time.Now()
+	var labels [8]string
+
+	for i := 24; i > 0; i -= 3 {
+		labels[7-(i/3-1)] = strings.ToLower(now.Add(-time.Duration(i) * time.Hour).Format(format))
+	}
+
+	return labels
+}
+
+func (widget *DNSStats) Initialize() error {
+	widget.
+		withTitle("DNS Stats").
+		withTitleURL(string(widget.URL)).
+		withCacheDuration(10 * time.Minute)
+
+	if widget.Service != "adguard" && widget.Service != "pihole" {
+		return errors.New("DNS stats service must be either 'adguard' or 'pihole'")
+	}
+
+	return nil
+}
+
+func (widget *DNSStats) Update(ctx context.Context) {
+	var stats *feed.DNSStats
+	var err error
+
+	if widget.Service == "adguard" {
+		stats, err = feed.FetchAdguardStats(string(widget.URL), string(widget.Username), string(widget.Password))
+	} else {
+		stats, err = feed.FetchPiholeStats(string(widget.URL), string(widget.Token))
+	}
+
+	if !widget.canContinueUpdateAfterHandlingErr(err) {
+		return
+	}
+
+	if widget.HourFormat == "24h" {
+		widget.TimeLabels = makeDNSTimeLabels("15:00")
+	} else {
+		widget.TimeLabels = makeDNSTimeLabels("3PM")
+	}
+
+	widget.Stats = stats
+}
+
+func (widget *DNSStats) Render() template.HTML {
+	return widget.render(widget, assets.DNSStatsTemplate)
+}

+ 4 - 0
internal/widget/fields.go

@@ -152,6 +152,10 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
 	return nil
 }
 
+func (f *OptionalEnvString) String() string {
+	return string(*f)
+}
+
 func toSimpleIconIfPrefixed(icon string) (string, bool) {
 	if !strings.HasPrefix(icon, "si:") {
 		return icon, false

+ 76 - 0
internal/widget/group.go

@@ -0,0 +1,76 @@
+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) SetProviders(providers *Providers) {
+	for i := range widget.Widgets {
+		widget.Widgets[i].SetProviders(providers)
+	}
+}
+
+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 {
-	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 {
 		widget.Limit = 15

+ 10 - 2
internal/widget/lobsters.go

@@ -12,6 +12,8 @@ import (
 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"`
@@ -20,7 +22,13 @@ type Lobsters struct {
 }
 
 func (widget *Lobsters) Initialize() error {
-	widget.withTitle("Lobsters").withCacheDuration(30 * time.Minute)
+	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"
@@ -38,7 +46,7 @@ func (widget *Lobsters) Initialize() error {
 }
 
 func (widget *Lobsters) Update(ctx context.Context) {
-	posts, err := feed.FetchLobstersPosts(widget.SortBy, widget.Tags)
+	posts, err := feed.FetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 		return

+ 0 - 1
internal/widget/stocks.go → internal/widget/markets.go

@@ -14,7 +14,6 @@ type Markets struct {
 	StocksRequests []feed.MarketRequest `yaml:"stocks"`
 	MarketRequests []feed.MarketRequest `yaml:"markets"`
 	Sort           string               `yaml:"sort-by"`
-	Style          string               `yaml:"style"`
 	Markets        feed.Markets         `yaml:"-"`
 }
 

+ 8 - 2
internal/widget/monitor.go

@@ -53,7 +53,8 @@ type Monitor struct {
 		StatusText              string           `yaml:"-"`
 		StatusStyle             string           `yaml:"-"`
 	} `yaml:"sites"`
-	Style string `yaml:"style"`
+	ShowFailingOnly bool `yaml:"show-failing-only"`
+	HasFailing      bool `yaml:"-"`
 }
 
 func (widget *Monitor) Initialize() error {
@@ -79,12 +80,17 @@ func (widget *Monitor) Update(ctx context.Context) {
 		return
 	}
 
+	widget.HasFailing = false
+
 	for i := range widget.Sites {
 		site := &widget.Sites[i]
 		status := &statuses[i]
-
 		site.Status = status
 
+		if status.Code >= 400 || status.TimedOut || status.Error != nil {
+			widget.HasFailing = true
+		}
+
 		if !status.TimedOut {
 			site.StatusText = statusCodeToText(status.Code)
 			site.StatusStyle = statusCodeToStyle(status.Code)

+ 6 - 1
internal/widget/reddit.go

@@ -17,6 +17,7 @@ type Reddit struct {
 	Subreddit           string          `yaml:"subreddit"`
 	Style               string          `yaml:"style"`
 	ShowThumbnails      bool            `yaml:"show-thumbnails"`
+	ShowFlairs          bool            `yaml:"show-flairs"`
 	SortBy              string          `yaml:"sort-by"`
 	TopPeriod           string          `yaml:"top-period"`
 	Search              string          `yaml:"search"`
@@ -54,7 +55,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
 }
@@ -84,6 +88,7 @@ func (widget *Reddit) Update(ctx context.Context) {
 		widget.Search,
 		widget.CommentsUrlTemplate,
 		widget.RequestUrlTemplate,
+		widget.ShowFlairs,
 	)
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {

+ 59 - 7
internal/widget/releases.go

@@ -2,7 +2,9 @@ package widget
 
 import (
 	"context"
+	"errors"
 	"html/template"
+	"strings"
 	"time"
 
 	"github.com/glanceapp/glance/internal/assets"
@@ -10,12 +12,15 @@ import (
 )
 
 type Releases struct {
-	widgetBase    `yaml:",inline"`
-	Releases      feed.AppReleases  `yaml:"-"`
-	Repositories  []string          `yaml:"repositories"`
-	Token         OptionalEnvString `yaml:"token"`
-	Limit         int               `yaml:"limit"`
-	CollapseAfter int               `yaml:"collapse-after"`
+	widgetBase      `yaml:",inline"`
+	Releases        feed.AppReleases       `yaml:"-"`
+	releaseRequests []*feed.ReleaseRequest `yaml:"-"`
+	Repositories    []string               `yaml:"repositories"`
+	Token           OptionalEnvString      `yaml:"token"`
+	GitLabToken     OptionalEnvString      `yaml:"gitlab-token"`
+	Limit           int                    `yaml:"limit"`
+	CollapseAfter   int                    `yaml:"collapse-after"`
+	ShowSourceIcon  bool                   `yaml:"show-source-icon"`
 }
 
 func (widget *Releases) Initialize() error {
@@ -29,11 +34,54 @@ func (widget *Releases) Initialize() error {
 		widget.CollapseAfter = 5
 	}
 
+	var tokenAsString = widget.Token.String()
+	var gitLabTokenAsString = widget.GitLabToken.String()
+
+	for _, repository := range widget.Repositories {
+		parts := strings.SplitN(repository, ":", 2)
+		var request *feed.ReleaseRequest
+		if len(parts) == 1 {
+			request = &feed.ReleaseRequest{
+				Source:     feed.ReleaseSourceGithub,
+				Repository: repository,
+			}
+
+			if widget.Token != "" {
+				request.Token = &tokenAsString
+			}
+		} else if len(parts) == 2 {
+			if parts[0] == string(feed.ReleaseSourceGitlab) {
+				request = &feed.ReleaseRequest{
+					Source:     feed.ReleaseSourceGitlab,
+					Repository: parts[1],
+				}
+
+				if widget.GitLabToken != "" {
+					request.Token = &gitLabTokenAsString
+				}
+			} else if parts[0] == string(feed.ReleaseSourceDockerHub) {
+				request = &feed.ReleaseRequest{
+					Source:     feed.ReleaseSourceDockerHub,
+					Repository: parts[1],
+				}
+			} else if parts[0] == string(feed.ReleaseSourceCodeberg) {
+				request = &feed.ReleaseRequest{
+					Source:     feed.ReleaseSourceCodeberg,
+					Repository: parts[1],
+				}
+			} else {
+				return errors.New("invalid repository source " + parts[0])
+			}
+		}
+
+		widget.releaseRequests = append(widget.releaseRequests, request)
+	}
+
 	return nil
 }
 
 func (widget *Releases) Update(ctx context.Context) {
-	releases, err := feed.FetchLatestReleasesFromGithub(widget.Repositories, string(widget.Token))
+	releases, err := feed.FetchLatestReleases(widget.releaseRequests)
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 		return
@@ -43,6 +91,10 @@ func (widget *Releases) Update(ctx context.Context) {
 		releases = releases[:widget.Limit]
 	}
 
+	for i := range releases {
+		releases[i].SourceIconURL = widget.Providers.AssetResolver("icons/" + string(releases[i].Source) + ".svg")
+	}
+
 	widget.Releases = releases
 }
 

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

@@ -15,6 +15,7 @@ type Repository struct {
 	Token               OptionalEnvString `yaml:"token"`
 	PullRequestsLimit   int               `yaml:"pull-requests-limit"`
 	IssuesLimit         int               `yaml:"issues-limit"`
+	CommitsLimit        int               `yaml:"commits-limit"`
 	RepositoryDetails   feed.RepositoryDetails
 }
 
@@ -29,6 +30,10 @@ func (widget *Repository) Initialize() error {
 		widget.IssuesLimit = 3
 	}
 
+	if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 {
+		widget.CommitsLimit = -1
+	}
+
 	return nil
 }
 
@@ -38,6 +43,7 @@ func (widget *Repository) Update(ctx context.Context) {
 		string(widget.Token),
 		widget.PullRequestsLimit,
 		widget.IssuesLimit,
+		widget.CommitsLimit,
 	)
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {

+ 14 - 11
internal/widget/rss.go

@@ -10,14 +10,16 @@ import (
 )
 
 type RSS struct {
-	widgetBase      `yaml:",inline"`
-	FeedRequests    []feed.RSSFeedRequest `yaml:"feeds"`
-	Style           string                `yaml:"style"`
-	ThumbnailHeight float64               `yaml:"thumbnail-height"`
-	CardHeight      float64               `yaml:"card-height"`
-	Items           feed.RSSFeedItems     `yaml:"-"`
-	Limit           int                   `yaml:"limit"`
-	CollapseAfter   int                   `yaml:"collapse-after"`
+	widgetBase       `yaml:",inline"`
+	FeedRequests     []feed.RSSFeedRequest `yaml:"feeds"`
+	Style            string                `yaml:"style"`
+	ThumbnailHeight  float64               `yaml:"thumbnail-height"`
+	CardHeight       float64               `yaml:"card-height"`
+	Items            feed.RSSFeedItems     `yaml:"-"`
+	Limit            int                   `yaml:"limit"`
+	CollapseAfter    int                   `yaml:"collapse-after"`
+	SingleLineTitles bool                  `yaml:"single-line-titles"`
+	NoItemsMessage   string                `yaml:"-"`
 }
 
 func (widget *RSS) Initialize() error {
@@ -39,13 +41,14 @@ func (widget *RSS) Initialize() error {
 		widget.CardHeight = 0
 	}
 
-	if widget.Style != "detailed-list" {
+	if widget.Style == "detailed-list" {
 		for i := range widget.FeedRequests {
-			widget.FeedRequests[i].HideCategories = true
-			widget.FeedRequests[i].HideDescription = true
+			widget.FeedRequests[i].IsDetailed = true
 		}
 	}
 
+	widget.NoItemsMessage = "No items were returned from the feeds."
+
 	return nil
 }
 

+ 2 - 0
internal/widget/search.go

@@ -19,6 +19,8 @@ type Search struct {
 	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 {

+ 4 - 1
internal/widget/twitch-channels.go

@@ -18,7 +18,10 @@ type TwitchChannels struct {
 }
 
 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 {
 		widget.CollapseAfter = 5

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

@@ -18,7 +18,10 @@ type TwitchGames struct {
 }
 
 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 {
 		widget.Limit = 10

+ 2 - 1
internal/widget/videos.go

@@ -17,6 +17,7 @@ type Videos struct {
 	CollapseAfterRows int         `yaml:"collapse-after-rows"`
 	Channels          []string    `yaml:"channels"`
 	Limit             int         `yaml:"limit"`
+	IncludeShorts     bool        `yaml:"include-shorts"`
 }
 
 func (widget *Videos) Initialize() error {
@@ -34,7 +35,7 @@ func (widget *Videos) Initialize() error {
 }
 
 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) {
 		return

+ 76 - 24
internal/widget/widget.go

@@ -8,6 +8,8 @@ import (
 	"html/template"
 	"log/slog"
 	"math"
+	"net/http"
+	"sync/atomic"
 	"time"
 
 	"github.com/glanceapp/glance/internal/feed"
@@ -15,51 +17,63 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
+var uniqueID atomic.Uint64
+
 func New(widgetType string) (Widget, error) {
+	var widget Widget
+
 	switch widgetType {
 	case "calendar":
-		return &Calendar{}, nil
+		widget = &Calendar{}
 	case "clock":
-		return &Clock{}, nil
+		widget = &Clock{}
 	case "weather":
-		return &Weather{}, nil
+		widget = &Weather{}
 	case "bookmarks":
-		return &Bookmarks{}, nil
+		widget = &Bookmarks{}
 	case "iframe":
-		return &IFrame{}, nil
+		widget = &IFrame{}
 	case "html":
-		return &HTML{}, nil
+		widget = &HTML{}
 	case "hacker-news":
-		return &HackerNews{}, nil
+		widget = &HackerNews{}
 	case "releases":
-		return &Releases{}, nil
+		widget = &Releases{}
 	case "videos":
-		return &Videos{}, nil
+		widget = &Videos{}
 	case "markets", "stocks":
-		return &Markets{}, nil
+		widget = &Markets{}
 	case "reddit":
-		return &Reddit{}, nil
+		widget = &Reddit{}
 	case "rss":
-		return &RSS{}, nil
+		widget = &RSS{}
 	case "monitor":
-		return &Monitor{}, nil
+		widget = &Monitor{}
 	case "twitch-top-games":
-		return &TwitchGames{}, nil
+		widget = &TwitchGames{}
 	case "twitch-channels":
-		return &TwitchChannels{}, nil
+		widget = &TwitchChannels{}
 	case "lobsters":
-		return &Lobsters{}, nil
+		widget = &Lobsters{}
 	case "change-detection":
-		return &ChangeDetection{}, nil
+		widget = &ChangeDetection{}
 	case "repository":
-		return &Repository{}, nil
+		widget = &Repository{}
 	case "search":
-		return &Search{}, nil
+		widget = &Search{}
 	case "extension":
-		return &Extension{}, nil
+		widget = &Extension{}
+	case "group":
+		widget = &Group{}
+	case "dns-stats":
+		widget = &DNSStats{}
 	default:
 		return nil, fmt.Errorf("unknown widget type: %s", widgetType)
 	}
+
+	widget.SetID(uniqueID.Add(1))
+
+	return widget, nil
 }
 
 type Widgets []Widget
@@ -90,10 +104,6 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
 			return err
 		}
 
-		if err = widget.Initialize(); err != nil {
-			return err
-		}
-
 		*w = append(*w, widget)
 	}
 
@@ -103,9 +113,14 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
 type Widget interface {
 	Initialize() error
 	RequiresUpdate(*time.Time) bool
+	SetProviders(*Providers)
 	Update(context.Context)
 	Render() template.HTML
 	GetType() string
+	GetID() uint64
+	SetID(uint64)
+	HandleRequest(w http.ResponseWriter, r *http.Request)
+	SetHideHeader(bool)
 }
 
 type cacheType int
@@ -117,8 +132,12 @@ const (
 )
 
 type widgetBase struct {
+	ID                  uint64        `yaml:"-"`
+	Providers           *Providers    `yaml:"-"`
 	Type                string        `yaml:"type"`
 	Title               string        `yaml:"title"`
+	TitleURL            string        `yaml:"title-url"`
+	CSSClass            string        `yaml:"css-class"`
 	CustomCacheDuration DurationField `yaml:"cache"`
 	ContentAvailable    bool          `yaml:"-"`
 	Error               error         `yaml:"-"`
@@ -128,6 +147,11 @@ type widgetBase struct {
 	cacheType           cacheType     `yaml:"-"`
 	nextUpdate          time.Time     `yaml:"-"`
 	updateRetriedTimes  int           `yaml:"-"`
+	HideHeader          bool          `yaml:"-"`
+}
+
+type Providers struct {
+	AssetResolver func(string) string
 }
 
 func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
@@ -146,10 +170,30 @@ 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 {
 	return w.Type
 }
 
+func (w *widgetBase) SetProviders(providers *Providers) {
+	w.Providers = providers
+}
+
 func (w *widgetBase) render(data any, t *template.Template) template.HTML {
 	w.templateBuffer.Reset()
 	err := t.Execute(&w.templateBuffer, data)
@@ -185,6 +229,14 @@ func (w *widgetBase) withTitle(title string) *widgetBase {
 	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 {
 	w.cacheType = cacheTypeDuration
 

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

@@ -1,303 +0,0 @@
-package main
-
-import (
-	"flag"
-	"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 hasUncommitedChanges() (bool, error) {
-	output, err := exec.Command("git", "status", "--porcelain").CombinedOutput()
-
-	if err != nil {
-		return false, err
-	}
-
-	return len(output) > 0, nil
-}
-
-func main() {
-	flags := flag.NewFlagSet("", flag.ExitOnError)
-
-	specificTag := flags.String("tag", "", "Which tagged version to build")
-
-	err := flags.Parse(os.Args[1:])
-
-	if err != nil {
-		fmt.Println(err)
-		os.Exit(1)
-	}
-
-	uncommitedChanges, err := hasUncommitedChanges()
-
-	if err != nil {
-		fmt.Println(err)
-		os.Exit(1)
-	}
-
-	if uncommitedChanges {
-		fmt.Println("There are uncommited changes - commit, stash or discard them first")
-		os.Exit(1)
-	}
-
-	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)
-
-	var version string
-
-	if *specificTag == "" {
-		version, err := getVersionFromGit()
-
-		if err != nil {
-			fmt.Println(version, err)
-			os.Exit(1)
-		}
-	} else {
-		version = *specificTag
-	}
-
-	output, err := exec.Command("git", "checkout", "tags/"+version).CombinedOutput()
-
-	if err != nil {
-		fmt.Println(string(output))
-		fmt.Println(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")
-
-	var dockerBuildOptions = []string{
-		"docker", "build",
-		"--platform=linux/amd64,linux/arm64,linux/arm/v7",
-		"-t", versionTag,
-	}
-
-	if !strings.Contains(version, "beta") {
-		dockerBuildOptions = append(dockerBuildOptions, "-t", latestTag)
-	}
-
-	dockerBuildOptions = append(dockerBuildOptions, ".")
-
-	output, err = exec.Command("sudo", dockerBuildOptions...).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)
-	}
-
-	if strings.Contains(version, "beta") {
-		return
-	}
-
-	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
-}