Merge remote-tracking branch 'upstream/release/v0.7.0' into alt-status-codes

Update to current release
This commit is contained in:
Cody Meadows
2024-10-17 22:01:56 +00:00
27 changed files with 377 additions and 78 deletions

View File

@@ -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 {

View 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);
}
}

View File

@@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() {
}
function showPopover() {
if (pendingTarget === null) return;
activeTarget = pendingTarget;
pendingTarget = null;

View File

@@ -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);
}

View File

@@ -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%;

View File

@@ -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{

View File

@@ -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>

View File

@@ -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">

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

View File

@@ -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">

View File

@@ -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)

View File

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

View File

@@ -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",

View File

@@ -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

View File

@@ -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)

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

View File

@@ -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)

View File

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

View File

@@ -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 {

View File

@@ -38,6 +38,10 @@ func (widget *Markets) Update(ctx context.Context) {
markets.SortByAbsChange()
}
if widget.Sort == "change" {
markets.SortByChange()
}
widget.Markets = markets
}

View 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)
}

View File

@@ -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)
}