diff --git a/Dockerfile b/Dockerfile
index e4019ba..48f214b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.22.5-alpine3.20 AS builder
+FROM golang:1.23.1-alpine3.20 AS builder
WORKDIR /app
COPY . /app
diff --git a/docs/configuration.md b/docs/configuration.md
index 5d7049e..9a2bfce 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -15,6 +15,8 @@
- [Reddit](#reddit)
- [Search](#search-widget)
- [Group](#group)
+ - [Split Column](#split-column)
+ - [Custom API](#custom-api)
- [Extension](#extension)
- [Weather](#weather)
- [Monitor](#monitor)
@@ -525,10 +527,22 @@ An array of RSS/atom feeds. The title can optionally be changed.
| hide-categories | boolean | no | false | Only applicable for `detailed-list` style |
| hide-description | boolean | no | false | Only applicable for `detailed-list` style |
| item-link-prefix | string | no | | |
+| headers | key (string) & value (string) | no | | |
###### `item-link-prefix`
If an RSS feed isn't returning item links with a base domain and Glance has failed to automatically detect the correct domain you can manually add a prefix to each link with this property.
+###### `headers`
+Optionally specify the headers that will be sent with the request. Example:
+
+```yaml
+- type: rss
+ feeds:
+ - url: https://domain.com/rss
+ headers:
+ User-Agent: Custom User Agent
+```
+
##### `limit`
The maximum number of articles to show.
@@ -890,7 +904,7 @@ url: https://www.amazon.com/s?k={QUERY}
```
### Group
-Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget within a group widget.
+Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget or a split column widget within a group widget.
Example:
@@ -933,6 +947,67 @@ Example:
<<: *shared-properties
```
+### Split Column
+
+Splits a full sized column in half, allowing you to place widgets side by side. This is converted to a single column on mobile devices or if not enough width is available. Widgets are defined using a `widgets` property exactly as you would on a page column.
+
+Example of a full page with an effective 4 column layout using two split column widgets inside of two full sized columns:
+
+View config
+
+```yaml
+shared:
+ - &reddit-props
+ type: reddit
+ collapse-after: 4
+ show-thumbnails: true
+
+pages:
+ - name: Split Column Demo
+ width: wide
+ columns:
+ - size: full
+ widgets:
+ - type: split-column
+ widgets:
+ - subreddit: gaming
+ <<: *reddit-props
+ - subreddit: worldnews
+ <<: *reddit-props
+ - subreddit: lifeprotips
+ <<: *reddit-props
+ show-thumbnails: false
+ - subreddit: askreddit
+ <<: *reddit-props
+ show-thumbnails: false
+
+ - size: full
+ widgets:
+ - type: split-column
+ widgets:
+ - subreddit: todayilearned
+ <<: *reddit-props
+ collapse-after: 2
+ - subreddit: aww
+ <<: *reddit-props
+ - subreddit: science
+ <<: *reddit-props
+ - subreddit: showerthoughts
+ <<: *reddit-props
+ show-thumbnails: false
+```
+
+
+Preview:
+
+
+
+### Custom API
+
+
### Extension
Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP).
@@ -948,12 +1023,16 @@ Display a widget provided by an external source (3rd party). If you want to lear
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| url | string | yes | |
+| fallback-content-type | string | no | |
| allow-potentially-dangerous-html | boolean | no | false |
| parameters | key & value | no | |
##### `url`
The URL of the extension.
+##### `fallback-content-type`
+Optionally specify the fallback content type of the extension if the URL does not return a valid `Widget-Content-Type` header. Currently the only supported value for this property is `html`.
+
##### `allow-potentially-dangerous-html`
Whether to allow the extension to display HTML.
@@ -1082,6 +1161,7 @@ Properties for each site:
| icon | string | no | |
| allow-insecure | boolean | no | false |
| same-tab | boolean | no | false |
+| alt-status-codes | array | no | |
`title`
@@ -1107,7 +1187,7 @@ icon: si:adguard
> [!WARNING]
>
-> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
+> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
`allow-insecure`
@@ -1117,6 +1197,15 @@ Whether to ignore invalid/self-signed certificates.
Whether to open the link in the same or a new tab.
+`alt-status-codes`
+
+Status codes other than 200 that you want to return "OK".
+
+```yaml
+alt-status-codes:
+ - 403
+```
+
### Releases
Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
@@ -1385,7 +1474,7 @@ icon: si:reddit
> [!WARNING]
>
-> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
+> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
`same-tab`
@@ -1539,7 +1628,7 @@ Preview:
An array of markets for which to display information about.
##### `sort-by`
-By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change.
+By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `change` for descending order based on the stock's percentage change (e.g. 1% would be sorted higher than -1%) or `absolute-change` for descending order based on the stock's absolute price change (e.g. -1% would be sorted higher than +0.5%).
###### Properties for each stock
| Name | Type | Required |
diff --git a/docs/images/split-column-widget-preview.png b/docs/images/split-column-widget-preview.png
new file mode 100644
index 0000000..f1931f8
Binary files /dev/null and b/docs/images/split-column-widget-preview.png differ
diff --git a/go.mod b/go.mod
index 17aa4d4..56b35a5 100644
--- a/go.mod
+++ b/go.mod
@@ -1,19 +1,22 @@
module github.com/glanceapp/glance
-go 1.22.5
+go 1.23.1
require (
github.com/mmcdole/gofeed v1.3.0
- golang.org/x/text v0.16.0
+ github.com/tidwall/gjson v1.18.0
+ golang.org/x/text v0.18.0
gopkg.in/yaml.v3 v3.0.1
)
require (
- github.com/PuerkitoBio/goquery v1.9.2 // indirect
+ github.com/PuerkitoBio/goquery v1.10.0 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mmcdole/goxpp v1.1.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
- golang.org/x/net v0.27.0 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ golang.org/x/net v0.29.0 // indirect
)
diff --git a/go.sum b/go.sum
index 28cb1ae..be33712 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
-github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
+github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
+github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -23,6 +23,13 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -33,8 +40,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
-golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
-golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
+golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
+golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
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=
@@ -54,8 +61,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
+golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
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=
diff --git a/internal/assets/static/js/main.js b/internal/assets/static/js/main.js
index ffa7eb7..25d78ca 100644
--- a/internal/assets/static/js/main.js
+++ b/internal/assets/static/js/main.js
@@ -1,4 +1,5 @@
import { setupPopovers } from './popover.js';
+import { setupMasonries } from './masonry.js';
import { throttledDebounce, isElementVisible } from './utils.js';
async function fetchPageContent(pageData) {
@@ -502,9 +503,34 @@ function timeInZone(now, zone) {
timeInZone = now
}
- const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
+ const diffInMinutes = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60);
- return { time: timeInZone, diffInHours: diffInHours };
+ return { time: timeInZone, diffInMinutes: diffInMinutes };
+}
+
+function zoneDiffText(diffInMinutes) {
+ if (diffInMinutes == 0) {
+ return "";
+ }
+
+ const sign = diffInMinutes < 0 ? "-" : "+";
+ const signText = diffInMinutes < 0 ? "behind" : "ahead";
+
+ diffInMinutes = Math.abs(diffInMinutes);
+
+ const hours = Math.floor(diffInMinutes / 60);
+ const minutes = diffInMinutes % 60;
+ const hourSuffix = hours == 1 ? "" : "s";
+
+ if (minutes == 0) {
+ return { text: `${sign}${hours}h`, title: `${hours} hour${hourSuffix} ${signText}` };
+ }
+
+ if (hours == 0) {
+ return { text: `${sign}${minutes}m`, title: `${minutes} minutes ${signText}` };
+ }
+
+ return { text: `${sign}${hours}h~`, title: `${hours} hour${hourSuffix} and ${minutes} minutes ${signText}` };
}
function setupClocks() {
@@ -547,9 +573,11 @@ function setupClocks() {
);
updateCallbacks.push((now) => {
- const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
+ const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
setZoneTime(time);
- diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
+ const { text, title } = zoneDiffText(diffInMinutes);
+ diffElement.textContent = text;
+ diffElement.title = title;
});
}
}
@@ -581,6 +609,7 @@ async function setupPage() {
setupCollapsibleLists();
setupCollapsibleGrids();
setupGroups();
+ setupMasonries();
setupDynamicRelativeTime();
setupLazyImages();
} finally {
diff --git a/internal/assets/static/js/masonry.js b/internal/assets/static/js/masonry.js
new file mode 100644
index 0000000..45680f4
--- /dev/null
+++ b/internal/assets/static/js/masonry.js
@@ -0,0 +1,53 @@
+
+import { clamp } from "./utils.js";
+
+export function setupMasonries() {
+ const masonryContainers = document.getElementsByClassName("masonry");
+
+ for (let i = 0; i < masonryContainers.length; i++) {
+ const container = masonryContainers[i];
+
+ const options = {
+ minColumnWidth: container.dataset.minColumnWidth || 330,
+ maxColumns: container.dataset.maxColumns || 6,
+ };
+
+ const items = Array.from(container.children);
+ let previousColumnsCount = 0;
+
+ const render = function() {
+ const columnsCount = clamp(
+ Math.floor(container.offsetWidth / options.minColumnWidth),
+ 1,
+ Math.min(options.maxColumns, items.length)
+ );
+
+ if (columnsCount === previousColumnsCount) {
+ return;
+ } else {
+ container.textContent = "";
+ previousColumnsCount = columnsCount;
+ }
+
+ const columnsFragment = document.createDocumentFragment();
+
+ for (let i = 0; i < columnsCount; i++) {
+ const column = document.createElement("div");
+ column.className = "masonry-column";
+ columnsFragment.append(column);
+ }
+
+ // poor man's masonry
+ // TODO: add an option that allows placing items in the
+ // shortest column instead of iterating the columns in order
+ for (let i = 0; i < items.length; i++) {
+ columnsFragment.children[i % columnsCount].appendChild(items[i]);
+ }
+
+ container.append(columnsFragment);
+ };
+
+ const observer = new ResizeObserver(() => requestAnimationFrame(render));
+ observer.observe(container);
+ }
+}
diff --git a/internal/assets/static/js/popover.js b/internal/assets/static/js/popover.js
index d6578ee..533feed 100644
--- a/internal/assets/static/js/popover.js
+++ b/internal/assets/static/js/popover.js
@@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() {
}
function showPopover() {
+ if (pendingTarget === null) return;
+
activeTarget = pendingTarget;
pendingTarget = null;
@@ -109,9 +111,10 @@ function repositionContainer() {
const containerBounds = containerElement.getBoundingClientRect();
const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline"));
- const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverOffset || 0.5);
+ const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverTargetOffset || 0.5);
const position = activeTarget.dataset.popoverPosition || "below";
- const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2));
+ const popoverOffest = activeTarget.dataset.popoverOffset || 0.5;
+ const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width * popoverOffest));
if (left < 0) {
containerElement.style.left = 0;
@@ -124,7 +127,7 @@ function repositionContainer() {
} else {
containerElement.style.removeProperty("right");
containerElement.style.left = left + "px";
- containerElement.style.removeProperty("--triangle-offset");
+ containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + "px");
}
const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;
diff --git a/internal/assets/static/js/utils.js b/internal/assets/static/js/utils.js
index af02086..ddf7e4f 100644
--- a/internal/assets/static/js/utils.js
+++ b/internal/assets/static/js/utils.js
@@ -23,3 +23,7 @@ export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
export function isElementVisible(element) {
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
}
+
+export function clamp(value, min, max) {
+ return Math.min(Math.max(value, min), max);
+}
diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css
index 4eb9ee6..3c342a0 100644
--- a/internal/assets/static/main.css
+++ b/internal/assets/static/main.css
@@ -440,6 +440,17 @@ kbd:active {
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
}
+.masonry {
+ display: flex;
+ gap: var(--widget-gap);
+}
+
+.masonry-column {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
.popover-container, [data-popover-html] {
display: none;
}
@@ -1339,6 +1350,10 @@ details[open] .summary::after {
transform: translate(-50%, -50%);
}
+.clock-time {
+ min-width: 8ch;
+}
+
.clock-time span {
color: var(--color-text-highlight);
}
@@ -1493,6 +1508,14 @@ details[open] .summary::after {
border: 2px solid var(--color-widget-background);
}
+.twitch-stream-preview {
+ max-width: 100%;
+ width: 400px;
+ aspect-ratio: 16 / 9;
+ border-radius: var(--border-radius);
+ object-fit: cover;
+}
+
.reddit-card-thumbnail {
width: 100%;
height: 100%;
diff --git a/internal/assets/templates.go b/internal/assets/templates.go
index 85abb69..324f8ca 100644
--- a/internal/assets/templates.go
+++ b/internal/assets/templates.go
@@ -39,9 +39,11 @@ var (
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
GroupTemplate = compileTemplate("group.html", "widget-base.html")
DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html")
+ SplitColumnTemplate = compileTemplate("split-column.html", "widget-base.html")
+ CustomAPITemplate = compileTemplate("custom-api.html", "widget-base.html")
)
-var globalTemplateFunctions = template.FuncMap{
+var GlobalTemplateFunctions = template.FuncMap{
"relativeTime": relativeTimeSince,
"formatViewerCount": formatViewerCount,
"formatNumber": intl.Sprint,
@@ -58,7 +60,7 @@ var globalTemplateFunctions = template.FuncMap{
func compileTemplate(primary string, dependencies ...string) *template.Template {
t, err := template.New(primary).
- Funcs(globalTemplateFunctions).
+ Funcs(GlobalTemplateFunctions).
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
if err != nil {
diff --git a/internal/assets/templates/custom-api.html b/internal/assets/templates/custom-api.html
new file mode 100644
index 0000000..e1f1f6f
--- /dev/null
+++ b/internal/assets/templates/custom-api.html
@@ -0,0 +1,7 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }}
+
+{{ define "widget-content" }}
+{{ .CompiledHTML }}
+{{ end }}
diff --git a/internal/assets/templates/markets.html b/internal/assets/templates/markets.html
index a979321..5cb5213 100644
--- a/internal/assets/templates/markets.html
+++ b/internal/assets/templates/markets.html
@@ -6,7 +6,7 @@
{{ .StreamTitle }}
+