diff --git a/docs/configuration.md b/docs/configuration.md index d3cea7d..3767f71 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,6 +13,7 @@ - [Lobsters](#lobsters) - [Reddit](#reddit) - [Search](#search-widget) + - [Group](#group) - [Extension](#extension) - [Weather](#weather) - [Monitor](#monitor) @@ -806,6 +807,50 @@ url: https://store.steampowered.com/search/?term={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 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). diff --git a/docs/images/group-widget-preview.png b/docs/images/group-widget-preview.png new file mode 100644 index 0000000..1380937 Binary files /dev/null and b/docs/images/group-widget-preview.png differ diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css index cfc96fc..8b34b78 100644 --- a/internal/assets/static/main.css +++ b/internal/assets/static/main.css @@ -184,6 +184,57 @@ 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) { padding-bottom: 0; } @@ -1393,6 +1444,7 @@ kbd:active { .gap-7 { gap: 0.7rem; } .gap-10 { gap: 1rem; } .gap-15 { gap: 1.5rem; } +.gap-20 { gap: 2rem; } .gap-25 { gap: 2.5rem; } .gap-35 { gap: 3.5rem; } .gap-45 { gap: 4.5rem; } diff --git a/internal/assets/static/main.js b/internal/assets/static/main.js index c8f7324..35ab6a0 100644 --- a/internal/assets/static/main.js +++ b/internal/assets/static/main.js @@ -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() { const images = document.querySelectorAll("img[loading=lazy]"); @@ -558,6 +598,7 @@ async function setupPage() { setupSearchBoxes(); setupCollapsibleLists(); setupCollapsibleGrids(); + setupGroups(); setupDynamicRelativeTime(); setupLazyImages(); } finally { diff --git a/internal/assets/templates.go b/internal/assets/templates.go index 53ae871..8274c8c 100644 --- a/internal/assets/templates.go +++ b/internal/assets/templates.go @@ -37,6 +37,7 @@ var ( RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") SearchTemplate = compileTemplate("search.html", "widget-base.html") ExtensionTemplate = compileTemplate("extension.html", "widget-base.html") + GroupTemplate = compileTemplate("group.html", "widget-base.html") ) var globalTemplateFunctions = template.FuncMap{ diff --git a/internal/assets/templates/group.html b/internal/assets/templates/group.html new file mode 100644 index 0000000..fe296fe --- /dev/null +++ b/internal/assets/templates/group.html @@ -0,0 +1,20 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content-classes" }}widget-content-frameless{{ end }} + +{{ define "widget-content" }} +
+
+ {{ range $i, $widget := .Widgets }} + + {{ end }} +
+
+ +
+{{ range $i, $widget := .Widgets }} +
{{ .Render }}
+{{ end }} +
+ +{{ end }} diff --git a/internal/assets/templates/widget-base.html b/internal/assets/templates/widget-base.html index eed89e1..bdd30b9 100644 --- a/internal/assets/templates/widget-base.html +++ b/internal/assets/templates/widget-base.html @@ -1,4 +1,5 @@
+ {{ if not .HideHeader}}
{{ if ne "" .TitleURL}}{{ .Title }}{{ else }}
{{ .Title }}
{{ end }} {{ if and .Error .ContentAvailable }} @@ -7,6 +8,7 @@
{{ end }}
+ {{ end }}
{{ if .ContentAvailable }} {{ block "widget-content" . }}{{ end }} diff --git a/internal/glance/config.go b/internal/glance/config.go index 9acc3be..2f2b1eb 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -32,6 +32,16 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) { 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 } diff --git a/internal/widget/group.go b/internal/widget/group.go new file mode 100644 index 0000000..6bf58e5 --- /dev/null +++ b/internal/widget/group.go @@ -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) +} diff --git a/internal/widget/widget.go b/internal/widget/widget.go index 4ede5bc..a08eeed 100644 --- a/internal/widget/widget.go +++ b/internal/widget/widget.go @@ -63,6 +63,8 @@ func New(widgetType string) (Widget, error) { widget = &Search{} case "extension": widget = &Extension{} + case "group": + widget = &Group{} default: return nil, fmt.Errorf("unknown widget type: %s", widgetType) } @@ -100,10 +102,6 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error { return err } - if err := widget.Initialize(); err != nil { - return err - } - *w = append(*w, widget) } @@ -119,6 +117,7 @@ type Widget interface { GetID() uint64 SetID(uint64) HandleRequest(w http.ResponseWriter, r *http.Request) + SetHideHeader(bool) } type cacheType int @@ -144,6 +143,7 @@ type widgetBase struct { cacheType cacheType `yaml:"-"` nextUpdate time.Time `yaml:"-"` updateRetriedTimes int `yaml:"-"` + HideHeader bool `yaml:"-"` } func (w *widgetBase) RequiresUpdate(now *time.Time) bool { @@ -170,6 +170,10 @@ func (w *widgetBase) SetID(id uint64) { w.ID = id } +func (w *widgetBase) SetHideHeader(value bool) { + w.HideHeader = value +} + func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) { http.Error(w, "not implemented", http.StatusNotImplemented) }