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: + +![](images/split-column-widget-preview.png) + +### 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 @@
{{ .Symbol }} -
{{ .Name }}
+
{{ .Name }}
diff --git a/internal/assets/templates/page.html b/internal/assets/templates/page.html index d2cee76..18d8b85 100644 --- a/internal/assets/templates/page.html +++ b/internal/assets/templates/page.html @@ -44,7 +44,7 @@
{{ range $i, $column := .Page.Columns }} - + {{ end }}
diff --git a/internal/assets/templates/releases.html b/internal/assets/templates/releases.html index 9bef5a0..7cd89f7 100644 --- a/internal/assets/templates/releases.html +++ b/internal/assets/templates/releases.html @@ -1,7 +1,7 @@ {{ template "widget-base.html" . }} {{ define "widget-content" }} -