Add group widget

This commit is contained in:
Svilen Markov 2024-08-01 21:34:07 +01:00
parent 795caa5d9d
commit 738bcf8bcb
10 changed files with 249 additions and 4 deletions

View File

@ -13,6 +13,7 @@
- [Lobsters](#lobsters) - [Lobsters](#lobsters)
- [Reddit](#reddit) - [Reddit](#reddit)
- [Search](#search-widget) - [Search](#search-widget)
- [Group](#group)
- [Extension](#extension) - [Extension](#extension)
- [Weather](#weather) - [Weather](#weather)
- [Monitor](#monitor) - [Monitor](#monitor)
@ -806,6 +807,50 @@ url: https://store.steampowered.com/search/?term={QUERY}
url: https://www.amazon.com/s?k={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 ### 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). 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).

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@ -184,6 +184,57 @@
transform: rotate(-90deg); transform: rotate(-90deg);
} }
.widget-group-header {
overflow-x: auto;
scrollbar-width: thin;
}
.widget-group-title {
background: none;
font: inherit;
border: none;
color: inherit;
text-transform: uppercase;
border-bottom: 1px solid transparent;
cursor: pointer;
flex-shrink: 0;
padding-bottom: 0.1rem;
transition: color .3s, border-color .3s;
}
.widget-group-title:hover:not(.widget-group-title-current) {
border-bottom-color: var(--color-text-subdue);
color: var(--color-text-highlight);
}
.widget-group-title-current {
border-bottom-color: var(--color-primary);
color: var(--color-text-highlight);
}
.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) { .widget-content:has(.expand-toggle-button:last-child) {
padding-bottom: 0; padding-bottom: 0;
} }
@ -1393,6 +1444,7 @@ kbd:active {
.gap-7 { gap: 0.7rem; } .gap-7 { gap: 0.7rem; }
.gap-10 { gap: 1rem; } .gap-10 { gap: 1rem; }
.gap-15 { gap: 1.5rem; } .gap-15 { gap: 1.5rem; }
.gap-20 { gap: 2rem; }
.gap-25 { gap: 2.5rem; } .gap-25 { gap: 2.5rem; }
.gap-35 { gap: 3.5rem; } .gap-35 { gap: 3.5rem; }
.gap-45 { gap: 4.5rem; } .gap-45 { gap: 4.5rem; }

View File

@ -250,6 +250,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() { function setupLazyImages() {
const images = document.querySelectorAll("img[loading=lazy]"); const images = document.querySelectorAll("img[loading=lazy]");
@ -558,6 +598,7 @@ async function setupPage() {
setupSearchBoxes(); setupSearchBoxes();
setupCollapsibleLists(); setupCollapsibleLists();
setupCollapsibleGrids(); setupCollapsibleGrids();
setupGroups();
setupDynamicRelativeTime(); setupDynamicRelativeTime();
setupLazyImages(); setupLazyImages();
} finally { } finally {

View File

@ -37,6 +37,7 @@ var (
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
SearchTemplate = compileTemplate("search.html", "widget-base.html") SearchTemplate = compileTemplate("search.html", "widget-base.html")
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html") ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
GroupTemplate = compileTemplate("group.html", "widget-base.html")
) )
var globalTemplateFunctions = template.FuncMap{ var globalTemplateFunctions = template.FuncMap{

View File

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

View File

@ -1,4 +1,5 @@
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}"> <div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
{{ if not .HideHeader}}
<div class="widget-header"> <div class="widget-header">
{{ if ne "" .TitleURL}}<a href="{{ .TitleURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }} {{ 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 }} {{ if and .Error .ContentAvailable }}
@ -7,6 +8,7 @@
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div> <div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
{{ end }} {{ end }}
</div> </div>
{{ end }}
<div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}"> <div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
{{ if .ContentAvailable }} {{ if .ContentAvailable }}
{{ block "widget-content" . }}{{ end }} {{ block "widget-content" . }}{{ end }}

View File

@ -32,6 +32,16 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
return nil, err 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 return config, nil
} }

70
internal/widget/group.go Normal file
View File

@ -0,0 +1,70 @@
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) 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)
}

View File

@ -63,6 +63,8 @@ func New(widgetType string) (Widget, error) {
widget = &Search{} widget = &Search{}
case "extension": case "extension":
widget = &Extension{} widget = &Extension{}
case "group":
widget = &Group{}
default: default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType) return nil, fmt.Errorf("unknown widget type: %s", widgetType)
} }
@ -100,10 +102,6 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
return err return err
} }
if err := widget.Initialize(); err != nil {
return err
}
*w = append(*w, widget) *w = append(*w, widget)
} }
@ -119,6 +117,7 @@ type Widget interface {
GetID() uint64 GetID() uint64
SetID(uint64) SetID(uint64)
HandleRequest(w http.ResponseWriter, r *http.Request) HandleRequest(w http.ResponseWriter, r *http.Request)
SetHideHeader(bool)
} }
type cacheType int type cacheType int
@ -144,6 +143,7 @@ type widgetBase struct {
cacheType cacheType `yaml:"-"` cacheType cacheType `yaml:"-"`
nextUpdate time.Time `yaml:"-"` nextUpdate time.Time `yaml:"-"`
updateRetriedTimes int `yaml:"-"` updateRetriedTimes int `yaml:"-"`
HideHeader bool `yaml:"-"`
} }
func (w *widgetBase) RequiresUpdate(now *time.Time) bool { func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
@ -170,6 +170,10 @@ func (w *widgetBase) SetID(id uint64) {
w.ID = id w.ID = id
} }
func (w *widgetBase) SetHideHeader(value bool) {
w.HideHeader = value
}
func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) { func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not implemented", http.StatusNotImplemented) http.Error(w, "not implemented", http.StatusNotImplemented)
} }