Merge remote-tracking branch 'upstream/release/v0.7.0' into alt-status-codes
Update to current release
This commit is contained in:
commit
04811a0658
27 changed files with 377 additions and 78 deletions
|
@ -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
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
- [Reddit](#reddit)
|
||||
- [Search](#search-widget)
|
||||
- [Group](#group)
|
||||
- [Split Column](#split-column)
|
||||
- [Extension](#extension)
|
||||
- [Weather](#weather)
|
||||
- [Monitor](#monitor)
|
||||
|
@ -890,7 +891,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 +934,63 @@ 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:
|
||||
|
||||
<details>
|
||||
<summary>View config</summary>
|
||||
|
||||
```yaml
|
||||
shared:
|
||||
- &reddit-props
|
||||
type: reddit
|
||||
collapse-after: 4
|
||||
show-thumbnails: true
|
||||
|
||||
pages:
|
||||
- name: Split Column Demo
|
||||
width: wide
|
||||
columns:
|
||||
- size: full
|
||||
widgets:
|
||||
- type: split-column
|
||||
widgets:
|
||||
- subreddit: gaming
|
||||
<<: *reddit-props
|
||||
- subreddit: worldnews
|
||||
<<: *reddit-props
|
||||
- subreddit: lifeprotips
|
||||
<<: *reddit-props
|
||||
show-thumbnails: false
|
||||
- subreddit: askreddit
|
||||
<<: *reddit-props
|
||||
show-thumbnails: false
|
||||
|
||||
- size: full
|
||||
widgets:
|
||||
- type: split-column
|
||||
widgets:
|
||||
- subreddit: todayilearned
|
||||
<<: *reddit-props
|
||||
collapse-after: 2
|
||||
- subreddit: aww
|
||||
<<: *reddit-props
|
||||
- subreddit: science
|
||||
<<: *reddit-props
|
||||
- subreddit: showerthoughts
|
||||
<<: *reddit-props
|
||||
show-thumbnails: false
|
||||
```
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
Preview:
|
||||
|
||||

|
||||
|
||||
### 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 +1006,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.
|
||||
|
||||
|
@ -1108,7 +1170,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`
|
||||
|
||||
|
@ -1395,7 +1457,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`
|
||||
|
||||
|
@ -1549,7 +1611,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 |
|
||||
|
|
BIN
docs/images/split-column-widget-preview.png
Normal file
BIN
docs/images/split-column-widget-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 328 KiB |
8
go.mod
8
go.mod
|
@ -1,19 +1,19 @@
|
|||
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
|
||||
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
|
||||
golang.org/x/net v0.29.0 // indirect
|
||||
)
|
||||
|
|
6
go.sum
6
go.sum
|
@ -1,5 +1,7 @@
|
|||
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=
|
||||
|
@ -35,6 +37,8 @@ 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=
|
||||
|
@ -56,6 +60,8 @@ 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=
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { setupPopovers } from './popover.js';
|
||||
import { setupMasonries } from './masonry.js';
|
||||
import { throttledDebounce, isElementVisible } from './utils.js';
|
||||
|
||||
async function fetchPageContent(pageData) {
|
||||
|
@ -581,6 +582,7 @@ async function setupPage() {
|
|||
setupCollapsibleLists();
|
||||
setupCollapsibleGrids();
|
||||
setupGroups();
|
||||
setupMasonries();
|
||||
setupDynamicRelativeTime();
|
||||
setupLazyImages();
|
||||
} finally {
|
||||
|
|
53
internal/assets/static/js/masonry.js
Normal file
53
internal/assets/static/js/masonry.js
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() {
|
|||
}
|
||||
|
||||
function showPopover() {
|
||||
if (pendingTarget === null) return;
|
||||
|
||||
activeTarget = pendingTarget;
|
||||
pendingTarget = null;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
@ -1493,6 +1504,13 @@ details[open] .summary::after {
|
|||
border: 2px solid var(--color-widget-background);
|
||||
}
|
||||
|
||||
.twitch-stream-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--border-radius);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.reddit-card-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
|
|
@ -39,6 +39,7 @@ 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")
|
||||
)
|
||||
|
||||
var globalTemplateFunctions = template.FuncMap{
|
||||
|
|
|
@ -44,7 +44,7 @@
|
|||
<div class="mobile-navigation-icons">
|
||||
<a class="mobile-navigation-label" href="#top">↑</a>
|
||||
{{ range $i, $column := .Page.Columns }}
|
||||
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
||||
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
||||
{{ end }}
|
||||
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-10 collapsible-container single-line-titles" data-collapse-after="{{ .CollapseAfter }}">
|
||||
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{ range .Releases }}
|
||||
<li>
|
||||
<div class="flex items-center gap-10">
|
||||
|
|
11
internal/assets/templates/split-column.html
Normal file
11
internal/assets/templates/split-column.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="masonry" data-max-columns="2">
|
||||
{{ range .Widgets }}
|
||||
{{ .Render }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
|
@ -5,7 +5,13 @@
|
|||
{{ range .Channels }}
|
||||
<li>
|
||||
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
|
||||
<div class="twitch-channel-avatar-container">
|
||||
<div class="twitch-channel-avatar-container"{{ if .IsLive }} data-popover-type="html" data-popover-position="above" data-popover-margin="0.15rem"{{ end }}>
|
||||
{{ if .IsLive }}
|
||||
<div data-popover-html>
|
||||
<img class="twitch-stream-preview" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_{{ .Login }}-440x248.jpg" loading="lazy" alt="">
|
||||
<p class="margin-top-10 color-highlight text-truncate-3-lines">{{ .StreamTitle }}</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .Exists }}
|
||||
<a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
|
||||
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
|
||||
|
|
|
@ -27,9 +27,10 @@ const (
|
|||
)
|
||||
|
||||
type ExtensionRequestOptions struct {
|
||||
URL string `yaml:"url"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
URL string `yaml:"url"`
|
||||
FallbackContentType string `yaml:"fallback-content-type"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
}
|
||||
|
||||
type Extension struct {
|
||||
|
@ -88,7 +89,11 @@ func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
|
|||
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
|
||||
|
||||
if !ok {
|
||||
contentType = ExtensionContentUnknown
|
||||
contentType, ok = ExtensionStringToType[options.FallbackContentType]
|
||||
|
||||
if !ok {
|
||||
contentType = ExtensionContentUnknown
|
||||
}
|
||||
}
|
||||
|
||||
extension.Content = convertExtensionContent(options, body, contentType)
|
||||
|
|
|
@ -189,12 +189,19 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
|
|||
minT := slices.Min(temperatures)
|
||||
maxT := slices.Max(temperatures)
|
||||
|
||||
temperaturesRange := float64(maxT - minT)
|
||||
|
||||
for i := 0; i < 12; i++ {
|
||||
bars = append(bars, weatherColumn{
|
||||
Temperature: temperatures[i],
|
||||
Scale: float64(temperatures[i]-minT) / float64(maxT-minT),
|
||||
HasPrecipitation: precipitations[i],
|
||||
})
|
||||
|
||||
if temperaturesRange > 0 {
|
||||
bars[i].Scale = float64(temperatures[i]-minT) / temperaturesRange
|
||||
} else {
|
||||
bars[i].Scale = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -133,6 +133,12 @@ func (t Markets) SortByAbsChange() {
|
|||
})
|
||||
}
|
||||
|
||||
func (t Markets) SortByChange() {
|
||||
sort.Slice(t, func(i, j int) bool {
|
||||
return t[i].PercentChange > t[j].PercentChange
|
||||
})
|
||||
}
|
||||
|
||||
var weatherCodeTable = map[int]string{
|
||||
0: "Clear Sky",
|
||||
1: "Mainly Clear",
|
||||
|
|
|
@ -28,6 +28,7 @@ type TwitchChannel struct {
|
|||
Login string
|
||||
Exists bool
|
||||
Name string
|
||||
StreamTitle string
|
||||
AvatarUrl string
|
||||
IsLive bool
|
||||
LiveSince time.Time
|
||||
|
@ -77,6 +78,9 @@ type twitchStreamMetadataOperationResponse struct {
|
|||
Name string `json:"name"`
|
||||
} `json:"game"`
|
||||
} `json:"stream"`
|
||||
LastBroadcast *struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
|
@ -142,7 +146,10 @@ func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, err
|
|||
return categories, nil
|
||||
}
|
||||
|
||||
const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}]`
|
||||
const twitchChannelStatusOperationRequestBody = `[
|
||||
{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},
|
||||
{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}
|
||||
]`
|
||||
|
||||
// TODO: rework
|
||||
// The operations for multiple channels can all be sent in a single request
|
||||
|
@ -205,6 +212,10 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
|
|||
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
|
||||
|
||||
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
|
||||
if streamMetadata.UserOrNull.LastBroadcast != nil {
|
||||
result.StreamTitle = streamMetadata.UserOrNull.LastBroadcast.Title
|
||||
}
|
||||
|
||||
if streamMetadata.UserOrNull.Stream.Game != nil {
|
||||
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
|
||||
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
|
||||
|
|
|
@ -75,6 +75,7 @@ type Page struct {
|
|||
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
||||
CenterVertically bool `yaml:"center-vertically"`
|
||||
Columns []Column `yaml:"columns"`
|
||||
PrimaryColumnIndex int8 `yaml:"-"`
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
|
@ -140,15 +141,24 @@ func NewApplication(config *Config) (*Application, error) {
|
|||
}
|
||||
|
||||
for p := range config.Pages {
|
||||
if config.Pages[p].Slug == "" {
|
||||
config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
|
||||
page := &config.Pages[p]
|
||||
page.PrimaryColumnIndex = -1
|
||||
|
||||
if page.Slug == "" {
|
||||
page.Slug = titleToSlug(page.Title)
|
||||
}
|
||||
|
||||
app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
|
||||
app.slugToPage[page.Slug] = page
|
||||
|
||||
for c := range config.Pages[p].Columns {
|
||||
for w := range config.Pages[p].Columns[c].Widgets {
|
||||
widget := config.Pages[p].Columns[c].Widgets[w]
|
||||
for c := range page.Columns {
|
||||
column := &page.Columns[c]
|
||||
|
||||
if page.PrimaryColumnIndex == -1 && column.Size == "full" {
|
||||
page.PrimaryColumnIndex = int8(c)
|
||||
}
|
||||
|
||||
for w := range column.Widgets {
|
||||
widget := column.Widgets[w]
|
||||
app.widgetByID[widget.GetID()] = widget
|
||||
|
||||
widget.SetProviders(providers)
|
||||
|
|
48
internal/widget/container.go
Normal file
48
internal/widget/container.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type containerWidgetBase struct {
|
||||
Widgets Widgets `yaml:"widgets"`
|
||||
}
|
||||
|
||||
func (widget *containerWidgetBase) Update(ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
now := time.Now()
|
||||
|
||||
for w := range widget.Widgets {
|
||||
widget := widget.Widgets[w]
|
||||
|
||||
if !widget.RequiresUpdate(&now) {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
widget.Update(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (widget *containerWidgetBase) SetProviders(providers *Providers) {
|
||||
for i := range widget.Widgets {
|
||||
widget.Widgets[i].SetProviders(providers)
|
||||
}
|
||||
}
|
||||
|
||||
func (widget *containerWidgetBase) RequiresUpdate(now *time.Time) bool {
|
||||
for i := range widget.Widgets {
|
||||
if widget.Widgets[i].RequiresUpdate(now) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
|
@ -12,12 +12,13 @@ import (
|
|||
)
|
||||
|
||||
type Extension struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
URL string `yaml:"url"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
Extension feed.Extension `yaml:"-"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
widgetBase `yaml:",inline"`
|
||||
URL string `yaml:"url"`
|
||||
FallbackContentType string `yaml:"fallback-content-type"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
Extension feed.Extension `yaml:"-"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *Extension) Initialize() error {
|
||||
|
@ -38,9 +39,10 @@ func (widget *Extension) Initialize() error {
|
|||
|
||||
func (widget *Extension) Update(ctx context.Context) {
|
||||
extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
|
||||
URL: widget.URL,
|
||||
Parameters: widget.Parameters,
|
||||
AllowHtml: widget.AllowHtml,
|
||||
URL: widget.URL,
|
||||
FallbackContentType: widget.FallbackContentType,
|
||||
Parameters: widget.Parameters,
|
||||
AllowHtml: widget.AllowHtml,
|
||||
})
|
||||
|
||||
widget.canContinueUpdateAfterHandlingErr(err)
|
||||
|
|
|
@ -13,7 +13,7 @@ import (
|
|||
)
|
||||
|
||||
var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
|
||||
var EnvFieldPattern = regexp.MustCompile(`^\${([A-Z_]+)}$`)
|
||||
var EnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`)
|
||||
|
||||
const (
|
||||
HSLHueMax = 360
|
||||
|
@ -133,21 +133,42 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
|
|||
return err
|
||||
}
|
||||
|
||||
matches := EnvFieldPattern.FindStringSubmatch(value)
|
||||
replaced := EnvFieldPattern.ReplaceAllStringFunc(value, func(whole string) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(matches) != 2 {
|
||||
*f = OptionalEnvString(value)
|
||||
groups := EnvFieldPattern.FindStringSubmatch(whole)
|
||||
|
||||
return nil
|
||||
if len(groups) != 3 {
|
||||
return whole
|
||||
}
|
||||
|
||||
prefix, key := groups[1], groups[2]
|
||||
|
||||
if prefix == `\` {
|
||||
if len(whole) >= 2 {
|
||||
return whole[1:]
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
value, found := os.LookupEnv(key)
|
||||
|
||||
if !found {
|
||||
err = fmt.Errorf("environment variable %s not found", key)
|
||||
return ""
|
||||
}
|
||||
|
||||
return prefix + value
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
value, found := os.LookupEnv(matches[1])
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("environment variable %s not found", matches[1])
|
||||
}
|
||||
|
||||
*f = OptionalEnvString(value)
|
||||
*f = OptionalEnvString(replaced)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -162,7 +183,7 @@ func toSimpleIconIfPrefixed(icon string) (string, bool) {
|
|||
}
|
||||
|
||||
icon = strings.TrimPrefix(icon, "si:")
|
||||
icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
|
||||
icon = "https://cdn.jsdelivr.net/npm/simple-icons@latest/" + icon + ".svg"
|
||||
|
||||
return icon, true
|
||||
}
|
||||
|
|
|
@ -4,15 +4,14 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type Group struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Widgets Widgets `yaml:"widgets"`
|
||||
widgetBase `yaml:",inline"`
|
||||
containerWidgetBase `yaml:",inline"`
|
||||
}
|
||||
|
||||
func (widget *Group) Initialize() error {
|
||||
|
@ -23,7 +22,9 @@ func (widget *Group) Initialize() error {
|
|||
widget.Widgets[i].SetHideHeader(true)
|
||||
|
||||
if widget.Widgets[i].GetType() == "group" {
|
||||
return errors.New("nested groups are not allowed")
|
||||
return errors.New("nested groups are not supported")
|
||||
} else if widget.Widgets[i].GetType() == "split-column" {
|
||||
return errors.New("split columns inside of groups are not supported")
|
||||
}
|
||||
|
||||
if err := widget.Widgets[i].Initialize(); err != nil {
|
||||
|
@ -35,40 +36,15 @@ func (widget *Group) Initialize() error {
|
|||
}
|
||||
|
||||
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()
|
||||
widget.containerWidgetBase.Update(ctx)
|
||||
}
|
||||
|
||||
func (widget *Group) SetProviders(providers *Providers) {
|
||||
for i := range widget.Widgets {
|
||||
widget.Widgets[i].SetProviders(providers)
|
||||
}
|
||||
widget.containerWidgetBase.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
|
||||
return widget.containerWidgetBase.RequiresUpdate(now)
|
||||
}
|
||||
|
||||
func (widget *Group) Render() template.HTML {
|
||||
|
|
|
@ -38,6 +38,10 @@ func (widget *Markets) Update(ctx context.Context) {
|
|||
markets.SortByAbsChange()
|
||||
}
|
||||
|
||||
if widget.Sort == "change" {
|
||||
markets.SortByChange()
|
||||
}
|
||||
|
||||
widget.Markets = markets
|
||||
}
|
||||
|
||||
|
|
42
internal/widget/split-column.go
Normal file
42
internal/widget/split-column.go
Normal file
|
@ -0,0 +1,42 @@
|
|||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type SplitColumn struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
containerWidgetBase `yaml:",inline"`
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) Initialize() error {
|
||||
widget.withError(nil).withTitle("Split Column").SetHideHeader(true)
|
||||
|
||||
for i := range widget.Widgets {
|
||||
if err := widget.Widgets[i].Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) Update(ctx context.Context) {
|
||||
widget.containerWidgetBase.Update(ctx)
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) SetProviders(providers *Providers) {
|
||||
widget.containerWidgetBase.SetProviders(providers)
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) RequiresUpdate(now *time.Time) bool {
|
||||
return widget.containerWidgetBase.RequiresUpdate(now)
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) Render() template.HTML {
|
||||
return widget.render(widget, assets.SplitColumnTemplate)
|
||||
}
|
|
@ -67,6 +67,8 @@ func New(widgetType string) (Widget, error) {
|
|||
widget = &Group{}
|
||||
case "dns-stats":
|
||||
widget = &DNSStats{}
|
||||
case "split-column":
|
||||
widget = &SplitColumn{}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue