mirror of
https://github.com/glanceapp/glance.git
synced 2024-11-25 01:44:47 +01:00
Add group widget
This commit is contained in:
parent
795caa5d9d
commit
738bcf8bcb
@ -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).
|
||||
|
||||
|
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);
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
@ -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 {
|
||||
|
@ -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{
|
||||
|
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 }}">
|
||||
{{ if not .HideHeader}}
|
||||
<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 and .Error .ContentAvailable }}
|
||||
@ -7,6 +8,7 @@
|
||||
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
|
||||
{{ if .ContentAvailable }}
|
||||
{{ block "widget-content" . }}{{ end }}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
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{}
|
||||
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)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user