mirror of
https://github.com/glanceapp/glance.git
synced 2024-11-28 19:35:00 +01:00
Add group widget
This commit is contained in:
parent
795caa5d9d
commit
738bcf8bcb
@ -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).
|
||||||
|
|
||||||
|
BIN
docs/images/group-widget-preview.png
Normal file
BIN
docs/images/group-widget-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 88 KiB |
@ -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; }
|
||||||
|
@ -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 {
|
||||||
|
@ -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{
|
||||||
|
20
internal/assets/templates/group.html
Normal file
20
internal/assets/templates/group.html
Normal 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 }}
|
@ -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 }}
|
||||||
|
@ -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
70
internal/widget/group.go
Normal 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)
|
||||||
|
}
|
@ -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)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user