Merge remote-tracking branch 'upstream/dev' into theme_switcher
This commit is contained in:
@@ -5,23 +5,39 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
"github.com/shirou/gopsutil/v4/sensors"
|
||||
)
|
||||
|
||||
type cliIntent uint8
|
||||
|
||||
const (
|
||||
cliIntentServe cliIntent = iota
|
||||
cliIntentConfigValidate = iota
|
||||
cliIntentConfigPrint = iota
|
||||
cliIntentDiagnose = iota
|
||||
cliIntentVersionPrint cliIntent = iota
|
||||
cliIntentServe
|
||||
cliIntentConfigValidate
|
||||
cliIntentConfigPrint
|
||||
cliIntentDiagnose
|
||||
cliIntentSensorsPrint
|
||||
cliIntentMountpointInfo
|
||||
)
|
||||
|
||||
type cliOptions struct {
|
||||
intent cliIntent
|
||||
configPath string
|
||||
args []string
|
||||
}
|
||||
|
||||
func parseCliOptions() (*cliOptions, error) {
|
||||
var args []string
|
||||
|
||||
args = os.Args[1:]
|
||||
if len(args) == 1 && (args[0] == "--version" || args[0] == "-v" || args[0] == "version") {
|
||||
return &cliOptions{
|
||||
intent: cliIntentVersionPrint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
flags := flag.NewFlagSet("", flag.ExitOnError)
|
||||
flags.Usage = func() {
|
||||
fmt.Println("Usage: glance [options] command")
|
||||
@@ -32,6 +48,8 @@ func parseCliOptions() (*cliOptions, error) {
|
||||
fmt.Println("\nCommands:")
|
||||
fmt.Println(" config:validate Validate the config file")
|
||||
fmt.Println(" config:print Print the parsed config file with embedded includes")
|
||||
fmt.Println(" sensors:print List all sensors")
|
||||
fmt.Println(" mountpoint:info Print information about a given mountpoint path")
|
||||
fmt.Println(" diagnose Run diagnostic checks")
|
||||
}
|
||||
configPath := flags.String("config", "glance.yml", "Set config path")
|
||||
@@ -41,7 +59,7 @@ func parseCliOptions() (*cliOptions, error) {
|
||||
}
|
||||
|
||||
var intent cliIntent
|
||||
var args = flags.Args()
|
||||
args = flags.Args()
|
||||
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
|
||||
|
||||
if len(args) == 0 {
|
||||
@@ -51,11 +69,19 @@ func parseCliOptions() (*cliOptions, error) {
|
||||
intent = cliIntentConfigValidate
|
||||
} else if args[0] == "config:print" {
|
||||
intent = cliIntentConfigPrint
|
||||
} else if args[0] == "sensors:print" {
|
||||
intent = cliIntentSensorsPrint
|
||||
} else if args[0] == "diagnose" {
|
||||
intent = cliIntentDiagnose
|
||||
} else {
|
||||
return nil, unknownCommandErr
|
||||
}
|
||||
} else if len(args) == 2 {
|
||||
if args[0] == "mountpoint:info" {
|
||||
intent = cliIntentMountpointInfo
|
||||
} else {
|
||||
return nil, unknownCommandErr
|
||||
}
|
||||
} else {
|
||||
return nil, unknownCommandErr
|
||||
}
|
||||
@@ -63,5 +89,54 @@ func parseCliOptions() (*cliOptions, error) {
|
||||
return &cliOptions{
|
||||
intent: intent,
|
||||
configPath: *configPath,
|
||||
args: args,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cliSensorsPrint() int {
|
||||
tempSensors, err := sensors.SensorsTemperatures()
|
||||
if err != nil {
|
||||
if warns, ok := err.(*sensors.Warnings); ok {
|
||||
fmt.Printf("Could not retrieve information for some sensors (%v):\n", err)
|
||||
for _, w := range warns.List {
|
||||
fmt.Printf(" - %v\n", w)
|
||||
}
|
||||
fmt.Println()
|
||||
} else {
|
||||
fmt.Printf("Failed to retrieve sensor information: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
if len(tempSensors) == 0 {
|
||||
fmt.Println("No sensors found")
|
||||
return 0
|
||||
}
|
||||
|
||||
fmt.Println("Sensors found:")
|
||||
for _, sensor := range tempSensors {
|
||||
fmt.Printf(" %s: %.1f°C\n", sensor.SensorKey, sensor.Temperature)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func cliMountpointInfo(requestedPath string) int {
|
||||
usage, err := disk.Usage(requestedPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to retrieve info for path %s: %v\n", requestedPath, err)
|
||||
if warns, ok := err.(*disk.Warnings); ok {
|
||||
for _, w := range warns.List {
|
||||
fmt.Printf(" - %v\n", w)
|
||||
}
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Println("Path:", usage.Path)
|
||||
fmt.Println("FS type:", ternary(usage.Fstype == "", "unknown", usage.Fstype))
|
||||
fmt.Printf("Used percent: %.1f%%\n", usage.UsedPercent)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -10,7 +14,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
|
||||
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?([\d\.]+)(?: |,)+([\d\.]+)%?(?: |,)+([\d\.]+)%?\)?$`)
|
||||
|
||||
const (
|
||||
hslHueMax = 360
|
||||
@@ -19,13 +23,17 @@ const (
|
||||
)
|
||||
|
||||
type hslColorField struct {
|
||||
Hue uint16
|
||||
Saturation uint8
|
||||
Lightness uint8
|
||||
Hue float64
|
||||
Saturation float64
|
||||
Lightness float64
|
||||
}
|
||||
|
||||
func (c *hslColorField) String() string {
|
||||
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
|
||||
return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", c.Hue, c.Saturation, c.Lightness)
|
||||
}
|
||||
|
||||
func (c *hslColorField) ToHex() string {
|
||||
return hslToHex(c.Hue, c.Saturation, c.Lightness)
|
||||
}
|
||||
|
||||
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||
@@ -41,7 +49,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||
return fmt.Errorf("invalid HSL color format: %s", value)
|
||||
}
|
||||
|
||||
hue, err := strconv.ParseUint(matches[1], 10, 16)
|
||||
hue, err := strconv.ParseFloat(matches[1], 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -50,7 +58,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
|
||||
}
|
||||
|
||||
saturation, err := strconv.ParseUint(matches[2], 10, 8)
|
||||
saturation, err := strconv.ParseFloat(matches[2], 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -59,7 +67,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
|
||||
}
|
||||
|
||||
lightness, err := strconv.ParseUint(matches[3], 10, 8)
|
||||
lightness, err := strconv.ParseFloat(matches[3], 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -68,9 +76,9 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
|
||||
}
|
||||
|
||||
c.Hue = uint16(hue)
|
||||
c.Saturation = uint8(saturation)
|
||||
c.Lightness = uint8(lightness)
|
||||
c.Hue = hue
|
||||
c.Saturation = saturation
|
||||
c.Lightness = lightness
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -112,7 +120,7 @@ func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
|
||||
}
|
||||
|
||||
type customIconField struct {
|
||||
URL string
|
||||
URL template.URL
|
||||
IsFlatIcon bool
|
||||
// TODO: along with whether the icon is flat, we also need to know
|
||||
// whether the icon is black or white by default in order to properly
|
||||
@@ -120,17 +128,23 @@ type customIconField struct {
|
||||
}
|
||||
|
||||
func newCustomIconField(value string) customIconField {
|
||||
const autoInvertPrefix = "auto-invert "
|
||||
field := customIconField{}
|
||||
|
||||
prefix, icon, found := strings.Cut(value, ":")
|
||||
if !found {
|
||||
field.URL = value
|
||||
if strings.HasPrefix(value, autoInvertPrefix) {
|
||||
field.IsFlatIcon = true
|
||||
value = strings.TrimPrefix(value, autoInvertPrefix)
|
||||
}
|
||||
|
||||
field.URL = template.URL(value)
|
||||
return field
|
||||
}
|
||||
|
||||
switch prefix {
|
||||
case "si":
|
||||
field.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
|
||||
field.URL = template.URL("https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg")
|
||||
field.IsFlatIcon = true
|
||||
case "di", "sh":
|
||||
// syntax: di:<icon_name>[.svg|.png]
|
||||
@@ -149,12 +163,12 @@ func newCustomIconField(value string) customIconField {
|
||||
}
|
||||
|
||||
if prefix == "di" {
|
||||
field.URL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/" + ext + "/" + basename + "." + ext
|
||||
field.URL = template.URL("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext)
|
||||
} else {
|
||||
field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/" + ext + "/" + basename + "." + ext
|
||||
field.URL = template.URL("https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext)
|
||||
}
|
||||
default:
|
||||
field.URL = value
|
||||
field.URL = template.URL(value)
|
||||
}
|
||||
|
||||
return field
|
||||
@@ -169,3 +183,105 @@ func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
|
||||
*i = newCustomIconField(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type proxyOptionsField struct {
|
||||
URL string `yaml:"url"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
Timeout durationField `yaml:"timeout"`
|
||||
client *http.Client `yaml:"-"`
|
||||
}
|
||||
|
||||
func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error {
|
||||
type proxyOptionsFieldAlias proxyOptionsField
|
||||
alias := (*proxyOptionsFieldAlias)(p)
|
||||
var proxyURL string
|
||||
|
||||
if err := node.Decode(&proxyURL); err != nil {
|
||||
if err := node.Decode(alias); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if proxyURL == "" && p.URL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.URL != "" {
|
||||
proxyURL = p.URL
|
||||
}
|
||||
|
||||
parsedUrl, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing proxy URL: %v", err)
|
||||
}
|
||||
|
||||
var timeout = defaultClientTimeout
|
||||
if p.Timeout > 0 {
|
||||
timeout = time.Duration(p.Timeout)
|
||||
}
|
||||
|
||||
p.client = &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(parsedUrl),
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: p.AllowInsecure},
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type queryParametersField map[string][]string
|
||||
|
||||
func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error {
|
||||
var decoded map[string]any
|
||||
|
||||
if err := node.Decode(&decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*q = make(queryParametersField)
|
||||
|
||||
// TODO: refactor the duplication in the switch cases if any more types get added
|
||||
for key, value := range decoded {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
(*q)[key] = []string{v}
|
||||
case int, int8, int16, int32, int64, float32, float64:
|
||||
(*q)[key] = []string{fmt.Sprintf("%v", v)}
|
||||
case bool:
|
||||
(*q)[key] = []string{fmt.Sprintf("%t", v)}
|
||||
case []string:
|
||||
(*q)[key] = append((*q)[key], v...)
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
switch item := item.(type) {
|
||||
case string:
|
||||
(*q)[key] = append((*q)[key], item)
|
||||
case int, int8, int16, int32, int64, float32, float64:
|
||||
(*q)[key] = append((*q)[key], fmt.Sprintf("%v", item))
|
||||
case bool:
|
||||
(*q)[key] = append((*q)[key], fmt.Sprintf("%t", item))
|
||||
default:
|
||||
return fmt.Errorf("invalid query parameter value type: %T", item)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid query parameter value type: %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *queryParametersField) toQueryString() string {
|
||||
query := url.Values{}
|
||||
|
||||
for key, values := range *q {
|
||||
for _, value := range values {
|
||||
query.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
return query.Encode()
|
||||
}
|
||||
|
||||
@@ -17,23 +17,20 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type CssProperties struct {
|
||||
BackgroundColor *hslColorField `yaml:"background-color"`
|
||||
PrimaryColor *hslColorField `yaml:"primary-color"`
|
||||
PositiveColor *hslColorField `yaml:"positive-color"`
|
||||
NegativeColor *hslColorField `yaml:"negative-color"`
|
||||
Light bool `yaml:"light"`
|
||||
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
||||
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
||||
}
|
||||
const CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT = 20
|
||||
|
||||
const (
|
||||
configVarTypeEnv = "env"
|
||||
configVarTypeSecret = "secret"
|
||||
configVarTypeFileFromEnv = "readFileFromEnv"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
Server struct {
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port"`
|
||||
AssetsPath string `yaml:"assets-path"`
|
||||
BaseURL string `yaml:"base-url"`
|
||||
StartedAt time.Time `yaml:"-"` // used in custom css file
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port"`
|
||||
AssetsPath string `yaml:"assets-path"`
|
||||
BaseURL string `yaml:"base-url"`
|
||||
} `yaml:"server"`
|
||||
|
||||
Document struct {
|
||||
@@ -43,6 +40,7 @@ type config struct {
|
||||
Theme struct {
|
||||
// Todo : Find a way to use CssProperties struct to avoid duplicates
|
||||
BackgroundColor *hslColorField `yaml:"background-color"`
|
||||
BackgroundColorAsHex string `yaml:"-"`
|
||||
PrimaryColor *hslColorField `yaml:"primary-color"`
|
||||
PositiveColor *hslColorField `yaml:"positive-color"`
|
||||
NegativeColor *hslColorField `yaml:"negative-color"`
|
||||
@@ -55,20 +53,34 @@ type config struct {
|
||||
} `yaml:"theme"`
|
||||
|
||||
Branding struct {
|
||||
HideFooter bool `yaml:"hide-footer"`
|
||||
CustomFooter template.HTML `yaml:"custom-footer"`
|
||||
LogoText string `yaml:"logo-text"`
|
||||
LogoURL string `yaml:"logo-url"`
|
||||
FaviconURL string `yaml:"favicon-url"`
|
||||
HideFooter bool `yaml:"hide-footer"`
|
||||
CustomFooter template.HTML `yaml:"custom-footer"`
|
||||
LogoText string `yaml:"logo-text"`
|
||||
LogoURL string `yaml:"logo-url"`
|
||||
FaviconURL string `yaml:"favicon-url"`
|
||||
AppName string `yaml:"app-name"`
|
||||
AppIconURL string `yaml:"app-icon-url"`
|
||||
AppBackgroundColor string `yaml:"app-background-color"`
|
||||
} `yaml:"branding"`
|
||||
|
||||
Pages []page `yaml:"pages"`
|
||||
}
|
||||
|
||||
type CssProperties struct {
|
||||
BackgroundColor *hslColorField `yaml:"background-color"`
|
||||
PrimaryColor *hslColorField `yaml:"primary-color"`
|
||||
PositiveColor *hslColorField `yaml:"positive-color"`
|
||||
NegativeColor *hslColorField `yaml:"negative-color"`
|
||||
Light bool `yaml:"light"`
|
||||
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
||||
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
||||
}
|
||||
|
||||
type page struct {
|
||||
Title string `yaml:"name"`
|
||||
Slug string `yaml:"slug"`
|
||||
Width string `yaml:"width"`
|
||||
DesktopNavigationWidth string `yaml:"desktop-navigation-width"`
|
||||
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
||||
ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"`
|
||||
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
||||
@@ -82,7 +94,7 @@ type page struct {
|
||||
}
|
||||
|
||||
func newConfigFromYAML(contents []byte) (*config, error) {
|
||||
contents, err := parseConfigEnvVariables(contents)
|
||||
contents, err := parseConfigVariables(contents)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -112,22 +124,33 @@ func newConfigFromYAML(contents []byte) (*config, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
var configEnvVariablePattern = regexp.MustCompile(`(^|.)\$\{([A-Z0-9_]+)\}`)
|
||||
var envVariableNamePattern = regexp.MustCompile(`^[A-Z0-9_]+$`)
|
||||
var configVariablePattern = regexp.MustCompile(`(^|.)\$\{(?:([a-zA-Z]+):)?([a-zA-Z0-9_-]+)\}`)
|
||||
|
||||
func parseConfigEnvVariables(contents []byte) ([]byte, error) {
|
||||
// Parses variables defined in the config such as:
|
||||
// ${API_KEY} - gets replaced with the value of the API_KEY environment variable
|
||||
// \${API_KEY} - escaped, gets used as is without the \ in the config
|
||||
// ${secret:api_key} - value gets loaded from /run/secrets/api_key
|
||||
// ${readFileFromEnv:PATH_TO_SECRET} - value gets loaded from the file path specified in the environment variable PATH_TO_SECRET
|
||||
//
|
||||
// TODO: don't match against commented out sections, not sure exactly how since
|
||||
// variables can be placed anywhere and used to modify the YAML structure itself
|
||||
func parseConfigVariables(contents []byte) ([]byte, error) {
|
||||
var err error
|
||||
|
||||
replaced := configEnvVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
|
||||
replaced := configVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
groups := configEnvVariablePattern.FindSubmatch(match)
|
||||
if len(groups) != 3 {
|
||||
groups := configVariablePattern.FindSubmatch(match)
|
||||
if len(groups) != 4 {
|
||||
// we can't handle this match, this shouldn't happen unless the number of groups
|
||||
// in the regex has been changed without updating the below code
|
||||
return match
|
||||
}
|
||||
|
||||
prefix, key := string(groups[1]), string(groups[2])
|
||||
prefix := string(groups[1])
|
||||
if prefix == `\` {
|
||||
if len(match) >= 2 {
|
||||
return match[1:]
|
||||
@@ -136,13 +159,20 @@ func parseConfigEnvVariables(contents []byte) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
value, found := os.LookupEnv(key)
|
||||
if !found {
|
||||
err = fmt.Errorf("environment variable %s not found", key)
|
||||
typeAsString, variableName := string(groups[2]), string(groups[3])
|
||||
variableType := ternary(typeAsString == "", configVarTypeEnv, typeAsString)
|
||||
|
||||
parsedValue, returnOriginal, localErr := parseConfigVariableOfType(variableType, variableName)
|
||||
if localErr != nil {
|
||||
err = fmt.Errorf("parsing variable: %v", localErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
return []byte(prefix + value)
|
||||
if returnOriginal {
|
||||
return match
|
||||
}
|
||||
|
||||
return []byte(prefix + parsedValue)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -152,33 +182,90 @@ func parseConfigEnvVariables(contents []byte) ([]byte, error) {
|
||||
return replaced, nil
|
||||
}
|
||||
|
||||
// When the bool return value is true, it indicates that the caller should use the original value
|
||||
func parseConfigVariableOfType(variableType, variableName string) (string, bool, error) {
|
||||
switch variableType {
|
||||
case configVarTypeEnv:
|
||||
if !envVariableNamePattern.MatchString(variableName) {
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
v, found := os.LookupEnv(variableName)
|
||||
if !found {
|
||||
return "", false, fmt.Errorf("environment variable %s not found", variableName)
|
||||
}
|
||||
|
||||
return v, false, nil
|
||||
case configVarTypeSecret:
|
||||
secretPath := filepath.Join("/run/secrets", variableName)
|
||||
secret, err := os.ReadFile(secretPath)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("reading secret file: %v", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(secret)), false, nil
|
||||
case configVarTypeFileFromEnv:
|
||||
if !envVariableNamePattern.MatchString(variableName) {
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
filePath, found := os.LookupEnv(variableName)
|
||||
if !found {
|
||||
return "", false, fmt.Errorf("readFileFromEnv: environment variable %s not found", variableName)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(filePath) {
|
||||
return "", false, fmt.Errorf("readFileFromEnv: file path %s is not absolute", filePath)
|
||||
}
|
||||
|
||||
fileContents, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("readFileFromEnv: reading file from %s: %v", variableName, err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(fileContents)), false, nil
|
||||
default:
|
||||
return "", true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func formatWidgetInitError(err error, w widget) error {
|
||||
return fmt.Errorf("%s widget: %v", w.GetType(), err)
|
||||
}
|
||||
|
||||
var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
|
||||
var configIncludePattern = regexp.MustCompile(`(?m)^([ \t]*)(?:-[ \t]*)?(?:!|\$)include:[ \t]*(.+)$`)
|
||||
|
||||
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
|
||||
return recursiveParseYAMLIncludes(mainFilePath, nil, 0)
|
||||
}
|
||||
|
||||
func recursiveParseYAMLIncludes(mainFilePath string, includes map[string]struct{}, depth int) ([]byte, map[string]struct{}, error) {
|
||||
if depth > CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT {
|
||||
return nil, nil, fmt.Errorf("recursion depth limit of %d reached", CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT)
|
||||
}
|
||||
|
||||
mainFileContents, err := os.ReadFile(mainFilePath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("reading main YAML file: %w", err)
|
||||
return nil, nil, fmt.Errorf("reading %s: %w", mainFilePath, err)
|
||||
}
|
||||
|
||||
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err)
|
||||
return nil, nil, fmt.Errorf("getting absolute path of %s: %w", mainFilePath, err)
|
||||
}
|
||||
mainFileDir := filepath.Dir(mainFileAbsPath)
|
||||
|
||||
includes := make(map[string]struct{})
|
||||
if includes == nil {
|
||||
includes = make(map[string]struct{})
|
||||
}
|
||||
var includesLastErr error
|
||||
|
||||
mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
||||
mainFileContents = configIncludePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
||||
if includesLastErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
matches := includePattern.FindSubmatch(match)
|
||||
matches := configIncludePattern.FindSubmatch(match)
|
||||
if len(matches) != 3 {
|
||||
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
|
||||
return nil
|
||||
@@ -193,13 +280,14 @@ func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error)
|
||||
var fileContents []byte
|
||||
var err error
|
||||
|
||||
fileContents, err = os.ReadFile(includeFilePath)
|
||||
includes[includeFilePath] = struct{}{}
|
||||
|
||||
fileContents, includes, err = recursiveParseYAMLIncludes(includeFilePath, includes, depth+1)
|
||||
if err != nil {
|
||||
includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err)
|
||||
includesLastErr = err
|
||||
return nil
|
||||
}
|
||||
|
||||
includes[includeFilePath] = struct{}{}
|
||||
return []byte(prefixStringLines(indent, string(fileContents)))
|
||||
})
|
||||
|
||||
@@ -254,7 +342,7 @@ func configFilesWatcher(
|
||||
// needed for lastContents and lastIncludes because they get updated in multiple goroutines
|
||||
mu := sync.Mutex{}
|
||||
|
||||
checkForContentChangesBeforeCallback := func() {
|
||||
parseAndCompareBeforeCallback := func() {
|
||||
currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
|
||||
if err != nil {
|
||||
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
|
||||
@@ -280,15 +368,22 @@ func configFilesWatcher(
|
||||
|
||||
const debounceDuration = 500 * time.Millisecond
|
||||
var debounceTimer *time.Timer
|
||||
debouncedCallback := func() {
|
||||
debouncedParseAndCompareBeforeCallback := func() {
|
||||
if debounceTimer != nil {
|
||||
debounceTimer.Stop()
|
||||
debounceTimer.Reset(debounceDuration)
|
||||
} else {
|
||||
debounceTimer = time.AfterFunc(debounceDuration, checkForContentChangesBeforeCallback)
|
||||
debounceTimer = time.AfterFunc(debounceDuration, parseAndCompareBeforeCallback)
|
||||
}
|
||||
}
|
||||
|
||||
deleteLastInclude := func(filePath string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
fileAbsPath, _ := filepath.Abs(filePath)
|
||||
delete(lastIncludes, fileAbsPath)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
@@ -297,16 +392,33 @@ func configFilesWatcher(
|
||||
return
|
||||
}
|
||||
if event.Has(fsnotify.Write) {
|
||||
debouncedCallback()
|
||||
} else if event.Has(fsnotify.Remove) {
|
||||
func() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
fileAbsPath, _ := filepath.Abs(event.Name)
|
||||
delete(lastIncludes, fileAbsPath)
|
||||
}()
|
||||
debouncedParseAndCompareBeforeCallback()
|
||||
} else if event.Has(fsnotify.Rename) {
|
||||
// on linux the file will no longer be watched after a rename, on windows
|
||||
// it will continue to be watched with the new name but we have no access to
|
||||
// the new name in this event in order to stop watching it manually and match the
|
||||
// behavior in linux, may lead to weird unintended behaviors on windows as we're
|
||||
// only handling renames from linux's perspective
|
||||
// see https://github.com/fsnotify/fsnotify/issues/255
|
||||
|
||||
debouncedCallback()
|
||||
// remove the old file from our manually tracked includes, calling
|
||||
// debouncedParseAndCompareBeforeCallback will re-add it if it's still
|
||||
// required after it triggers
|
||||
deleteLastInclude(event.Name)
|
||||
|
||||
// wait for file to maybe get created again
|
||||
// see https://github.com/glanceapp/glance/pull/358
|
||||
for range 10 {
|
||||
if _, err := os.Stat(event.Name); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
debouncedParseAndCompareBeforeCallback()
|
||||
} else if event.Has(fsnotify.Remove) {
|
||||
deleteLastInclude(event.Name)
|
||||
debouncedParseAndCompareBeforeCallback()
|
||||
}
|
||||
case err, isOpen := <-watcher.Errors:
|
||||
if !isOpen {
|
||||
@@ -340,36 +452,46 @@ func isConfigStateValid(config *config) error {
|
||||
}
|
||||
|
||||
for i := range config.Pages {
|
||||
if config.Pages[i].Title == "" {
|
||||
page := &config.Pages[i]
|
||||
|
||||
if page.Title == "" {
|
||||
return fmt.Errorf("page %d has no name", i+1)
|
||||
}
|
||||
|
||||
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
|
||||
if page.Width != "" && (page.Width != "wide" && page.Width != "slim" && page.Width != "default") {
|
||||
return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
|
||||
}
|
||||
|
||||
if len(config.Pages[i].Columns) == 0 {
|
||||
if page.DesktopNavigationWidth != "" {
|
||||
if page.DesktopNavigationWidth != "wide" && page.DesktopNavigationWidth != "slim" && page.DesktopNavigationWidth != "default" {
|
||||
return fmt.Errorf("page %d: desktop-navigation-width can only be either wide or slim", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if len(page.Columns) == 0 {
|
||||
return fmt.Errorf("page %d has no columns", i+1)
|
||||
}
|
||||
|
||||
if config.Pages[i].Width == "slim" {
|
||||
if len(config.Pages[i].Columns) > 2 {
|
||||
if page.Width == "slim" {
|
||||
if len(page.Columns) > 2 {
|
||||
return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
|
||||
}
|
||||
} else {
|
||||
if len(config.Pages[i].Columns) > 3 {
|
||||
if len(page.Columns) > 3 {
|
||||
return fmt.Errorf("page %d has more than 3 columns", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
columnSizesCount := make(map[string]int)
|
||||
|
||||
for j := range config.Pages[i].Columns {
|
||||
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" {
|
||||
for j := range page.Columns {
|
||||
column := &page.Columns[j]
|
||||
|
||||
if column.Size != "small" && column.Size != "full" {
|
||||
return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
|
||||
}
|
||||
|
||||
columnSizesCount[config.Pages[i].Columns[j].Size]++
|
||||
columnSizesCount[page.Columns[j].Size]++
|
||||
}
|
||||
|
||||
full := columnSizesCount["full"]
|
||||
|
||||
@@ -81,7 +81,9 @@ var diagnosticSteps = []diagnosticStep{
|
||||
{
|
||||
name: "fetch data from Yahoo finance API",
|
||||
fn: func() (string, error) {
|
||||
return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200)
|
||||
return testHttpRequestWithHeaders("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", map[string]string{
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
|
||||
}, 200)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -103,7 +105,7 @@ func runDiagnostic() {
|
||||
fmt.Println("Glance version: " + buildVersion)
|
||||
fmt.Println("Go version: " + runtime.Version())
|
||||
fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
|
||||
fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no"))
|
||||
fmt.Println("In Docker container: " + ternary(isRunningInsideDockerContainer(), "yes", "no"))
|
||||
|
||||
fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
|
||||
|
||||
@@ -129,7 +131,7 @@ func runDiagnostic() {
|
||||
|
||||
fmt.Printf(
|
||||
"%s %s %s| %dms\n",
|
||||
boolToString(step.err == nil, "✓ Can", "✗ Can't"),
|
||||
ternary(step.err == nil, "✓ Can", "✗ Can't"),
|
||||
step.name,
|
||||
extraInfo,
|
||||
step.elapsed.Milliseconds(),
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -20,6 +26,19 @@ var _templateFS embed.FS
|
||||
var staticFS, _ = fs.Sub(_staticFS, "static")
|
||||
var templateFS, _ = fs.Sub(_templateFS, "templates")
|
||||
|
||||
func readAllFromStaticFS(path string) ([]byte, error) {
|
||||
// For some reason fs.FS only works with forward slashes, so in case we're
|
||||
// running on Windows or pass paths with backslashes we need to replace them.
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
|
||||
file, err := staticFS.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return io.ReadAll(file)
|
||||
}
|
||||
|
||||
var staticFSHash = func() string {
|
||||
hash, err := computeFSHash(staticFS)
|
||||
if err != nil {
|
||||
@@ -60,3 +79,74 @@ func computeFSHash(files fs.FS) (string, error) {
|
||||
|
||||
return hex.EncodeToString(hash.Sum(nil))[:10], nil
|
||||
}
|
||||
|
||||
var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`)
|
||||
var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`)
|
||||
var whitespaceAtBeginningOfLinePattern = regexp.MustCompile(`(?m)^\s+`)
|
||||
|
||||
// Yes, we bundle at runtime, give comptime pls
|
||||
var bundledCSSContents = func() []byte {
|
||||
const mainFilePath = "css/main.css"
|
||||
|
||||
var recursiveParseImports func(path string, depth int) ([]byte, error)
|
||||
recursiveParseImports = func(path string, depth int) ([]byte, error) {
|
||||
if depth > 20 {
|
||||
return nil, errors.New("maximum import depth reached, is one of your imports circular?")
|
||||
}
|
||||
|
||||
mainFileContents, err := readAllFromStaticFS(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Normalize line endings, otherwise the \r's make the regex not match
|
||||
mainFileContents = bytes.ReplaceAll(mainFileContents, []byte("\r\n"), []byte("\n"))
|
||||
|
||||
mainFileDir := filepath.Dir(path)
|
||||
var importLastErr error
|
||||
|
||||
parsed := cssImportPattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
||||
if importLastErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
matches := cssImportPattern.FindSubmatch(match)
|
||||
if len(matches) != 2 {
|
||||
importLastErr = fmt.Errorf(
|
||||
"import didn't return expected number of capture groups: %s, expected 2, got %d",
|
||||
match, len(matches),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
importFilePath := filepath.Join(mainFileDir, string(matches[1]))
|
||||
importContents, err := recursiveParseImports(importFilePath, depth+1)
|
||||
if err != nil {
|
||||
importLastErr = err
|
||||
return nil
|
||||
}
|
||||
|
||||
return importContents
|
||||
})
|
||||
|
||||
if importLastErr != nil {
|
||||
return nil, importLastErr
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
contents, err := recursiveParseImports(mainFilePath, 0)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("building CSS bundle: %v", err))
|
||||
}
|
||||
|
||||
// We could strip a bunch more unnecessary characters, but the biggest
|
||||
// win comes from removing the whitespace at the beginning of lines
|
||||
// since that's at least 4 bytes per property, which yielded a ~20% reduction.
|
||||
contents = cssSingleLineCommentPattern.ReplaceAll(contents, nil)
|
||||
contents = whitespaceAtBeginningOfLinePattern.ReplaceAll(contents, nil)
|
||||
contents = bytes.ReplaceAll(contents, []byte("\n"), []byte(""))
|
||||
|
||||
return contents
|
||||
}()
|
||||
|
||||
@@ -19,13 +19,19 @@ var (
|
||||
pageTemplate = mustParseTemplate("page.html", "document.html")
|
||||
pageContentTemplate = mustParseTemplate("page-content.html")
|
||||
pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
|
||||
manifestTemplate = mustParseTemplate("manifest.json")
|
||||
)
|
||||
|
||||
const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour
|
||||
|
||||
type application struct {
|
||||
Version string
|
||||
CreatedAt time.Time
|
||||
Config config
|
||||
ParsedThemeStyle template.HTML
|
||||
|
||||
parsedManifest []byte
|
||||
|
||||
slugToPage map[string]*page
|
||||
widgetByID map[uint64]widget
|
||||
}
|
||||
@@ -33,6 +39,7 @@ type application struct {
|
||||
func newApplication(config *config) (*application, error) {
|
||||
app := &application{
|
||||
Version: buildVersion,
|
||||
CreatedAt: time.Now(),
|
||||
Config: *config,
|
||||
slugToPage: make(map[string]*page),
|
||||
widgetByID: make(map[uint64]widget),
|
||||
@@ -41,7 +48,7 @@ func newApplication(config *config) (*application, error) {
|
||||
app.slugToPage[""] = &config.Pages[0]
|
||||
|
||||
providers := &widgetProviders{
|
||||
assetResolver: app.AssetPath,
|
||||
assetResolver: app.StaticAssetPath,
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -60,6 +67,14 @@ func newApplication(config *config) (*application, error) {
|
||||
|
||||
app.slugToPage[page.Slug] = page
|
||||
|
||||
if page.Width == "default" {
|
||||
page.Width = ""
|
||||
}
|
||||
|
||||
if page.DesktopNavigationWidth == "" && page.DesktopNavigationWidth != "default" {
|
||||
page.DesktopNavigationWidth = page.Width
|
||||
}
|
||||
|
||||
for c := range page.Columns {
|
||||
column := &page.Columns[c]
|
||||
|
||||
@@ -69,7 +84,7 @@ func newApplication(config *config) (*application, error) {
|
||||
|
||||
for w := range column.Widgets {
|
||||
widget := column.Widgets[w]
|
||||
app.widgetByID[widget.id()] = widget
|
||||
app.widgetByID[widget.GetID()] = widget
|
||||
|
||||
widget.setProviders(providers)
|
||||
}
|
||||
@@ -79,15 +94,38 @@ func newApplication(config *config) (*application, error) {
|
||||
config = &app.Config
|
||||
|
||||
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
||||
config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
||||
config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
||||
config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL)
|
||||
|
||||
if config.Branding.FaviconURL == "" {
|
||||
config.Branding.FaviconURL = app.AssetPath("favicon.png")
|
||||
if config.Theme.BackgroundColor != nil {
|
||||
config.Theme.BackgroundColorAsHex = config.Theme.BackgroundColor.ToHex()
|
||||
} else {
|
||||
config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL)
|
||||
config.Theme.BackgroundColorAsHex = "#151519"
|
||||
}
|
||||
|
||||
config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL)
|
||||
if config.Branding.FaviconURL == "" {
|
||||
config.Branding.FaviconURL = app.StaticAssetPath("favicon.png")
|
||||
} else {
|
||||
config.Branding.FaviconURL = app.resolveUserDefinedAssetPath(config.Branding.FaviconURL)
|
||||
}
|
||||
|
||||
if config.Branding.AppName == "" {
|
||||
config.Branding.AppName = "Glance"
|
||||
}
|
||||
|
||||
if config.Branding.AppIconURL == "" {
|
||||
config.Branding.AppIconURL = app.StaticAssetPath("app-icon.png")
|
||||
}
|
||||
|
||||
if config.Branding.AppBackgroundColor == "" {
|
||||
config.Branding.AppBackgroundColor = config.Theme.BackgroundColorAsHex
|
||||
}
|
||||
|
||||
var manifestWriter bytes.Buffer
|
||||
if err := manifestTemplate.Execute(&manifestWriter, pageTemplateData{App: app}); err != nil {
|
||||
return nil, fmt.Errorf("parsing manifest.json: %v", err)
|
||||
}
|
||||
app.parsedManifest = manifestWriter.Bytes()
|
||||
|
||||
return app, nil
|
||||
}
|
||||
@@ -117,7 +155,7 @@ func (p *page) updateOutdatedWidgets() {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (a *application) transformUserDefinedAssetPath(path string) string {
|
||||
func (a *application) resolveUserDefinedAssetPath(path string) string {
|
||||
if strings.HasPrefix(path, "/assets/") {
|
||||
return a.Config.Server.BaseURL + path
|
||||
}
|
||||
@@ -223,10 +261,15 @@ func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request
|
||||
widget.handleRequest(w, r)
|
||||
}
|
||||
|
||||
func (a *application) AssetPath(asset string) string {
|
||||
func (a *application) StaticAssetPath(asset string) string {
|
||||
return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
|
||||
}
|
||||
|
||||
func (a *application) VersionedAssetPath(asset string) string {
|
||||
return a.Config.Server.BaseURL + asset +
|
||||
"?v=" + strconv.FormatInt(a.CreatedAt.Unix(), 10)
|
||||
}
|
||||
|
||||
func (a *application) server() (func() error, func() error) {
|
||||
// TODO: add gzip support, static files must have their gzipped contents cached
|
||||
// TODO: add HTTPS support
|
||||
@@ -243,9 +286,29 @@ func (a *application) server() (func() error, func() error) {
|
||||
|
||||
mux.Handle(
|
||||
fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
|
||||
http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)),
|
||||
http.StripPrefix(
|
||||
"/static/"+staticFSHash,
|
||||
fileServerWithCache(http.FS(staticFS), STATIC_ASSETS_CACHE_DURATION),
|
||||
),
|
||||
)
|
||||
|
||||
assetCacheControlValue := fmt.Sprintf(
|
||||
"public, max-age=%d",
|
||||
int(STATIC_ASSETS_CACHE_DURATION.Seconds()),
|
||||
)
|
||||
|
||||
mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", staticFSHash), func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Cache-Control", assetCacheControlValue)
|
||||
w.Header().Add("Content-Type", "text/css; charset=utf-8")
|
||||
w.Write(bundledCSSContents)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Cache-Control", assetCacheControlValue)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(a.parsedManifest)
|
||||
})
|
||||
|
||||
var absAssetsPath string
|
||||
if a.Config.Server.AssetsPath != "" {
|
||||
absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
|
||||
@@ -259,7 +322,6 @@ func (a *application) server() (func() error, func() error) {
|
||||
}
|
||||
|
||||
start := func() error {
|
||||
a.Config.Server.StartedAt = time.Now()
|
||||
log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
|
||||
a.Config.Server.Host,
|
||||
a.Config.Server.Port,
|
||||
|
||||
@@ -18,6 +18,8 @@ func Main() int {
|
||||
}
|
||||
|
||||
switch options.intent {
|
||||
case cliIntentVersionPrint:
|
||||
fmt.Println(buildVersion)
|
||||
case cliIntentServe:
|
||||
// remove in v0.10.0
|
||||
if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
|
||||
@@ -47,6 +49,10 @@ func Main() int {
|
||||
}
|
||||
|
||||
fmt.Println(string(contents))
|
||||
case cliIntentSensorsPrint:
|
||||
return cliSensorsPrint()
|
||||
case cliIntentMountpointInfo:
|
||||
return cliMountpointInfo(options.args[1])
|
||||
case cliIntentDiagnose:
|
||||
runDiagnostic()
|
||||
}
|
||||
@@ -56,8 +62,6 @@ func Main() int {
|
||||
|
||||
func serveApp(configPath string) error {
|
||||
exitChannel := make(chan struct{})
|
||||
// the onChange method gets called at most once per 500ms due to debouncing so we shouldn't
|
||||
// need to use atomic.Bool here unless newConfigFromYAML is very slow for some reason
|
||||
hadValidConfigOnStartup := false
|
||||
var stopServer func() error
|
||||
|
||||
@@ -153,9 +157,9 @@ func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool {
|
||||
templateFile, _ := templateFS.Open("v0.7-update-notice-page.html")
|
||||
bodyContents, _ := io.ReadAll(templateFile)
|
||||
|
||||
// TODO: update - add link
|
||||
fmt.Println("!!! WARNING !!!")
|
||||
fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0, please see <link> for more information.")
|
||||
fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0.")
|
||||
fmt.Println("Please see https://github.com/glanceapp/glance/blob/main/docs/v0.7.0-upgrade.md for more information.")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
19
internal/glance/static/css/forum-posts.css
Normal file
19
internal/glance/static/css/forum-posts.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.forum-post-list-thumbnail {
|
||||
flex-shrink: 0;
|
||||
width: 6rem;
|
||||
height: 4.1rem;
|
||||
border-radius: var(--border-radius);
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--color-separator);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.forum-post-tags-container {
|
||||
transform: translateY(-0.15rem);
|
||||
}
|
||||
|
||||
@container widget (max-width: 550px) {
|
||||
.forum-post-autohide {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
66
internal/glance/static/css/main.css
Normal file
66
internal/glance/static/css/main.css
Normal file
@@ -0,0 +1,66 @@
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('../fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
font-size: 10px;
|
||||
|
||||
--scheme: ;
|
||||
--bgh: 240;
|
||||
--bgs: 8%;
|
||||
--bgl: 9%;
|
||||
--bghs: var(--bgh), var(--bgs);
|
||||
--cm: 1;
|
||||
--tsm: 1;
|
||||
|
||||
--widget-gap: 23px;
|
||||
--widget-content-vertical-padding: 15px;
|
||||
--widget-content-horizontal-padding: 17px;
|
||||
--widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
|
||||
--content-bounds-padding: 15px;
|
||||
--border-radius: 5px;
|
||||
--mobile-navigation-height: 50px;
|
||||
|
||||
--color-primary: hsl(43, 50%, 70%);
|
||||
--color-positive: var(--color-primary);
|
||||
--color-negative: hsl(0, 70%, 70%);
|
||||
--color-background: hsl(var(--bghs), var(--bgl));
|
||||
--color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%);
|
||||
--color-widget-background: hsl(var(--color-widget-background-hsl-values));
|
||||
--color-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm))));
|
||||
--color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
|
||||
--color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
|
||||
--color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
|
||||
--color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
|
||||
--color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
|
||||
--color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 26% * var(--cm))));
|
||||
--color-vertical-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 28% * var(--cm))));
|
||||
--color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
|
||||
|
||||
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
|
||||
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
|
||||
--color-text-paragraph: hsl(var(--ths), calc(var(--scheme) var(--cm) * 73%));
|
||||
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
|
||||
--color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
|
||||
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
|
||||
|
||||
--font-size-h1: 1.7rem;
|
||||
--font-size-h2: 1.6rem;
|
||||
--font-size-h3: 1.5rem;
|
||||
--font-size-h4: 1.4rem;
|
||||
--font-size-base: 1.3rem;
|
||||
--font-size-h5: 1.2rem;
|
||||
--font-size-h6: 1.1rem;
|
||||
}
|
||||
|
||||
/* Do not change the order of the below imports unless you know what you're doing */
|
||||
|
||||
@import "site.css";
|
||||
@import "widgets.css";
|
||||
@import "popover.css";
|
||||
@import "utils.css";
|
||||
@import "mobile.css";
|
||||
223
internal/glance/static/css/mobile.css
Normal file
223
internal/glance/static/css/mobile.css
Normal file
@@ -0,0 +1,223 @@
|
||||
@media (max-width: 1190px) {
|
||||
.header-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-column-small .size-title-dynamic {
|
||||
font-size: var(--font-size-h3);
|
||||
}
|
||||
|
||||
.page-column-small {
|
||||
width: 100%;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.page-column {
|
||||
display: none;
|
||||
animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||
}
|
||||
|
||||
.page-columns-transitioned .page-column {
|
||||
animation-duration: .3s;
|
||||
}
|
||||
|
||||
@keyframes columnEntrance {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scaleX(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-navigation-offset {
|
||||
height: var(--mobile-navigation-height);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-navigation {
|
||||
display: block;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
transform: translateY(calc(100% - var(--mobile-navigation-height)));
|
||||
left: var(--content-bounds-padding);
|
||||
right: var(--content-bounds-padding);
|
||||
z-index: 11;
|
||||
background-color: var(--color-widget-background);
|
||||
border: 1px solid var(--color-widget-content-border);
|
||||
border-bottom: 0;
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
transition: transform .3s;
|
||||
}
|
||||
|
||||
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) .hamburger-icon {
|
||||
--spacing: 7px;
|
||||
color: var(--color-primary);
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.mobile-navigation-page-links {
|
||||
border-top: 1px solid var(--color-widget-content-border);
|
||||
padding: 20px var(--content-bounds-padding);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.mobile-navigation-icons {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
body:has(.mobile-navigation-input[value="0"]:checked) .page-columns > :nth-child(1),
|
||||
body:has(.mobile-navigation-input[value="1"]:checked) .page-columns > :nth-child(2),
|
||||
body:has(.mobile-navigation-input[value="2"]:checked) .page-columns > :nth-child(3) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-navigation-label {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
max-width: 50px;
|
||||
height: var(--mobile-navigation-height);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
line-height: var(--mobile-navigation-height);
|
||||
}
|
||||
|
||||
.mobile-navigation-pill {
|
||||
display: block;
|
||||
background: var(--color-text-base);
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
border-radius: 10px;
|
||||
transition: width .3s, background-color .3s;
|
||||
}
|
||||
|
||||
.mobile-navigation-label:hover > .mobile-navigation-pill {
|
||||
background-color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.mobile-navigation-label:hover {
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.mobile-navigation-input:checked + .mobile-navigation-pill {
|
||||
background: var(--color-primary);
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.mobile-navigation-input, .mobile-navigation-page-links-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hamburger-icon {
|
||||
--spacing: 4px;
|
||||
width: 1em;
|
||||
height: 1px;
|
||||
background-color: currentColor;
|
||||
transition: color .3s, box-shadow .3s;
|
||||
box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
|
||||
}
|
||||
|
||||
.expand-toggle-button.container-expanded {
|
||||
bottom: var(--mobile-navigation-height);
|
||||
}
|
||||
|
||||
.cards-grid + .expand-toggle-button.container-expanded {
|
||||
/* hides content that peeks through the rounded borders of the mobile navigation */
|
||||
box-shadow: 0 var(--border-radius) 0 0 var(--color-background);
|
||||
}
|
||||
|
||||
.weather-column-rain::before {
|
||||
background-size: 7px 7px;
|
||||
}
|
||||
|
||||
.ios .search-input {
|
||||
/* so that iOS Safari does not zoom the page when the input is focused */
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1190px) and (display-mode: standalone) {
|
||||
:root {
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
.ios .body-content {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.expand-toggle-button.container-expanded {
|
||||
bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.mobile-navigation {
|
||||
transform: translateY(calc(100% - var(--mobile-navigation-height) - var(--safe-area-inset-bottom)));
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.mobile-navigation-icons {
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
transition: padding-bottom .3s;
|
||||
}
|
||||
|
||||
.mobile-navigation-offset {
|
||||
height: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
body {
|
||||
padding-top: env(safe-area-inset-top, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
:root {
|
||||
font-size: 9.4px;
|
||||
--widget-gap: 15px;
|
||||
--widget-content-vertical-padding: 10px;
|
||||
--widget-content-horizontal-padding: 10px;
|
||||
--content-bounds-padding: 10px;
|
||||
}
|
||||
|
||||
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||
|
||||
.row-reverse-on-mobile {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) {
|
||||
display: none
|
||||
}
|
||||
|
||||
.mobile-reachability-header {
|
||||
display: block;
|
||||
font-size: 3rem;
|
||||
padding: 10vh 1rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-highlight);
|
||||
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||
}
|
||||
|
||||
.rss-detailed-thumbnail > * {
|
||||
height: 6rem;
|
||||
}
|
||||
|
||||
.rss-detailed-description {
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
65
internal/glance/static/css/popover.css
Normal file
65
internal/glance/static/css/popover.css
Normal file
@@ -0,0 +1,65 @@
|
||||
.popover-container, [data-popover-html] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.popover-container {
|
||||
--triangle-size: 10px;
|
||||
--triangle-offset: 50%;
|
||||
--triangle-margin: calc(var(--triangle-size) + 3px);
|
||||
--entrance-y-offset: 8px;
|
||||
--entrance-direction: calc(var(--entrance-y-offset) * -1);
|
||||
|
||||
z-index: 20;
|
||||
position: absolute;
|
||||
padding-top: var(--triangle-margin);
|
||||
padding-inline: var(--content-bounds-padding);
|
||||
}
|
||||
|
||||
.popover-container.position-above {
|
||||
--entrance-direction: var(--entrance-y-offset);
|
||||
padding-top: 0;
|
||||
padding-bottom: var(--triangle-margin);
|
||||
}
|
||||
|
||||
.popover-frame {
|
||||
--shadow-properties: 0 15px 20px -10px;
|
||||
--shadow-color: hsla(var(--bghs), calc(var(--bgl) * 0.2), 0.5);
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
background: var(--color-popover-background);
|
||||
border: 1px solid var(--color-popover-border);
|
||||
border-radius: 5px;
|
||||
animation: popoverFrameEntrance 0.3s backwards cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow: var(--shadow-properties) var(--shadow-color);
|
||||
}
|
||||
|
||||
.popover-frame::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: var(--triangle-size);
|
||||
height: var(--triangle-size);
|
||||
transform: rotate(45deg);
|
||||
background-color: var(--color-popover-background);
|
||||
border-top-left-radius: 2px;
|
||||
border-left: 1px solid var(--color-popover-border);
|
||||
border-top: 1px solid var(--color-popover-border);
|
||||
left: calc(var(--triangle-offset) - (var(--triangle-size) / 2));
|
||||
top: calc(var(--triangle-size) / 2 * -1 - 1px);
|
||||
}
|
||||
|
||||
.popover-container.position-above .popover-frame::before {
|
||||
transform: rotate(-135deg);
|
||||
top: auto;
|
||||
bottom: calc(var(--triangle-size) / 2 * -1 - 1px);
|
||||
}
|
||||
|
||||
.popover-container.position-above .popover-frame {
|
||||
--shadow-properties: 0 10px 20px -10px;
|
||||
}
|
||||
|
||||
@keyframes popoverFrameEntrance {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--entrance-direction));
|
||||
}
|
||||
}
|
||||
396
internal/glance/static/css/site.css
Normal file
396
internal/glance/static/css/site.css
Normal file
@@ -0,0 +1,396 @@
|
||||
.dark {
|
||||
--scheme: ;
|
||||
--bgh: 240;
|
||||
--bgs: 8%;
|
||||
--bgl: 9%;
|
||||
--bghs: var(--bgh), var(--bgs);
|
||||
--cm: 1;
|
||||
--tsm: 1;
|
||||
}
|
||||
|
||||
.light {
|
||||
--scheme: 100% -;
|
||||
--bgh: 240;
|
||||
--bgs: 50%;
|
||||
--bgl: 98%;
|
||||
--bghs: var(--bgh), var(--bgs);
|
||||
--cm: 1;
|
||||
--tsm: 1;
|
||||
--color-primary: hsl(43, 50%, 70%);
|
||||
}
|
||||
|
||||
.light-scheme {
|
||||
--scheme: 100% -;
|
||||
}
|
||||
|
||||
.page {
|
||||
height: 100%;
|
||||
padding-block: var(--widget-gap);
|
||||
}
|
||||
|
||||
.page-content, .page.content-ready .page-loading-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page.content-ready > .page-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-column-small .size-title-dynamic {
|
||||
font-size: var(--font-size-h4);
|
||||
}
|
||||
|
||||
.page-column-full .size-title-dynamic {
|
||||
font-size: var(--font-size-h3);
|
||||
}
|
||||
|
||||
pre {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-text-subdue);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
background: var(--color-background);
|
||||
height: 5px;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 0.1rem;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background-color: var(--color-separator);
|
||||
}
|
||||
|
||||
img, svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
img[loading=lazy].loaded:not(.finished-transition) {
|
||||
transition: opacity .4s;
|
||||
}
|
||||
|
||||
img[loading=lazy].cached:not(.finished-transition) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
img[loading=lazy]:not(.loaded, .cached) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-color: var(--color-text-subdue) transparent;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
html, body, .body-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 1.3rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-variant-ligatures: none;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-base);
|
||||
background-color: var(--color-background);
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.page-column-small {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-column-full {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-columns {
|
||||
display: flex;
|
||||
gap: var(--widget-gap);
|
||||
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes pageColumnsEntrance {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.page-loading-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: loadingContainerEntrance 200ms backwards;
|
||||
animation-delay: 150ms;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.page-loading-container > .loading-icon {
|
||||
translate: 0 -250%;
|
||||
}
|
||||
|
||||
@keyframes loadingContainerEntrance {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
min-width: 1.5em;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
border: 0.25em solid hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 12%)));
|
||||
border-top-color: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%)));
|
||||
border-radius: 50%;
|
||||
animation: loadingIconSpin 800ms infinite linear;
|
||||
}
|
||||
|
||||
@keyframes loadingIconSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.notice-icon {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.notice-icon-major {
|
||||
background: var(--color-negative);
|
||||
}
|
||||
|
||||
.notice-icon-minor {
|
||||
border: 1px solid var(--color-negative);
|
||||
}
|
||||
|
||||
kbd {
|
||||
font: inherit;
|
||||
padding: 0.1rem 0.8rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: 2px solid var(--color-widget-background-highlight);
|
||||
box-shadow: 0 2px 0 var(--color-widget-background-highlight);
|
||||
user-select: none;
|
||||
transition: transform .1s, box-shadow .1s;
|
||||
font-size: var(--font-size-h5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
kbd:active {
|
||||
transform: translateY(2px);
|
||||
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
|
||||
}
|
||||
|
||||
.content-bounds {
|
||||
max-width: 1600px;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding: 0 var(--content-bounds-padding);
|
||||
}
|
||||
|
||||
.content-bounds-wide {
|
||||
max-width: 1920px;
|
||||
}
|
||||
|
||||
.content-bounds-slim {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.page-center-vertically .page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
margin-top: calc(var(--widget-gap) / 2);
|
||||
--header-height: 45px;
|
||||
--header-items-gap: 2.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
height: var(--header-height);
|
||||
gap: var(--header-items-gap);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 100%;
|
||||
line-height: var(--header-height);
|
||||
font-size: 2rem;
|
||||
color: var(--color-text-highlight);
|
||||
border-right: 1px solid var(--color-widget-content-border);
|
||||
padding-right: var(--widget-content-horizontal-padding);
|
||||
}
|
||||
|
||||
.logo:has(img) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
max-height: 2.7rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
height: 100%;
|
||||
gap: var(--header-items-gap);
|
||||
}
|
||||
|
||||
.nav .nav-item {
|
||||
line-height: var(--header-height);
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding-bottom: calc(var(--widget-gap) * 1.5);
|
||||
padding-top: calc(var(--widget-gap) / 2);
|
||||
animation: loadingContainerEntrance 200ms backwards;
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
.mobile-navigation, .mobile-reachability-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color .3s, border-color .3s;
|
||||
font-size: var(--font-size-h3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item:not(.nav-item-current):hover {
|
||||
border-bottom-color: var(--color-text-subdue);
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.nav-item.nav-item-current {
|
||||
border-bottom-color: var(--color-primary);
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
/*
|
||||
### Theme Dropdown ###
|
||||
*/
|
||||
.theme-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.dropdown-button {
|
||||
padding: 10px 15px;
|
||||
background: var(--color-widget-background);
|
||||
border: 1px solid var(--color-widget-content-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 150px;
|
||||
transition: border-color .2s;
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.dropdown-button:hover {
|
||||
border-color: var(--color-text-subdue);
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background: var(--color-widget-content-border);
|
||||
min-width: 150px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.mobile-navigation-page-links .dropdown-content {
|
||||
top: unset;
|
||||
bottom: 38px;
|
||||
}
|
||||
|
||||
.dropdown-content.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.theme-option:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
background-color: #dee2e6;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
border: solid #666;
|
||||
border-width: 0 2px 2px 0;
|
||||
display: inline-block;
|
||||
padding: 3px;
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.2s ease;
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.dropdown-button.active .arrow {
|
||||
transform: rotate(-135deg);
|
||||
}
|
||||
562
internal/glance/static/css/utils.css
Normal file
562
internal/glance/static/css/utils.css
Normal file
@@ -0,0 +1,562 @@
|
||||
.masonry {
|
||||
display: flex;
|
||||
gap: var(--widget-gap);
|
||||
}
|
||||
|
||||
.masonry-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widget-small-content-bounds {
|
||||
max-width: 350px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.list-horizontal-text {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-horizontal-text > *:not(:last-child)::after {
|
||||
content: '•' / "";
|
||||
color: var(--color-text-subdue);
|
||||
margin: 0 0.4rem;
|
||||
position: relative;
|
||||
top: 0.1rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
word-spacing: -0.18em;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
position: relative;
|
||||
display: flex;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.details[open] .summary {
|
||||
margin-bottom: .8rem;
|
||||
}
|
||||
|
||||
.summary::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -.3rem -.8rem;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--color-widget-background-highlight);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.details[open] .summary::before, .summary:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.details:not([open]) .list-with-transition {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary::after {
|
||||
content: "◀" / "";
|
||||
font-size: 1.2em;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
line-height: 1.3em;
|
||||
right: 0;
|
||||
transition: rotate .5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
details[open] .summary::after {
|
||||
rotate: -90deg;
|
||||
}
|
||||
|
||||
/* TODO: refactor, otherwise I hope I never have to change dynamic columns again */
|
||||
.dynamic-columns {
|
||||
--list-half-gap: 0.5rem;
|
||||
gap: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--columns-per-row), 1fr);
|
||||
}
|
||||
|
||||
.dynamic-columns > * {
|
||||
padding-left: var(--widget-content-horizontal-padding);
|
||||
border-left: 1px solid var(--color-separator);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dynamic-columns > *:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
|
||||
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
|
||||
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
|
||||
.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
|
||||
|
||||
@container widget (max-width: 599px) {
|
||||
.dynamic-columns { gap: 0; }
|
||||
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||
.dynamic-columns > * {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
.dynamic-columns > *:not(:first-child) {
|
||||
margin-top: calc(var(--list-half-gap) * 2);
|
||||
}
|
||||
.dynamic-columns.list-with-separator > *:not(:first-child) {
|
||||
margin-top: var(--list-half-gap);
|
||||
border-top: 1px solid var(--color-separator);
|
||||
padding-top: var(--list-half-gap);
|
||||
}
|
||||
}
|
||||
@container widget (min-width: 600px) and (max-width: 849px) {
|
||||
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
|
||||
.dynamic-columns > :nth-child(2n-1) {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
@container widget (min-width: 850px) and (max-width: 1249px) {
|
||||
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
|
||||
.dynamic-columns > :nth-child(3n+1) {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
@container widget (min-width: 1250px) and (max-width: 1499px) {
|
||||
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
|
||||
.dynamic-columns > :nth-child(4n+1) {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
@container widget (min-width: 1500px) {
|
||||
.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
|
||||
.dynamic-columns > :nth-child(5n+1) {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cards-vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cards-horizontal {
|
||||
--cards-per-row: 6.5;
|
||||
}
|
||||
|
||||
.cards-horizontal, .cards-vertical {
|
||||
--cards-gap: calc(var(--widget-content-vertical-padding) * 0.7);
|
||||
display: flex;
|
||||
gap: var(--cards-gap);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cards-horizontal .card {
|
||||
flex-shrink: 0;
|
||||
width: calc(100% / var(--cards-per-row) - var(--cards-gap) * (var(--cards-per-row) - 1) / var(--cards-per-row));
|
||||
}
|
||||
|
||||
.cards-grid .card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cards-horizontal {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
--cards-per-row: 6;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cards-per-row), 1fr);
|
||||
gap: calc(var(--widget-content-vertical-padding) * 0.7);
|
||||
}
|
||||
|
||||
@container widget (max-width: 1300px) { .cards-horizontal { --cards-per-row: 5.5; } }
|
||||
@container widget (max-width: 1100px) { .cards-horizontal { --cards-per-row: 4.5; } }
|
||||
@container widget (max-width: 850px) { .cards-horizontal { --cards-per-row: 3.5; } }
|
||||
@container widget (max-width: 750px) { .cards-horizontal { --cards-per-row: 3.5; } }
|
||||
@container widget (max-width: 650px) { .cards-horizontal { --cards-per-row: 2.5; } }
|
||||
@container widget (max-width: 450px) { .cards-horizontal { --cards-per-row: 2.3; } }
|
||||
|
||||
@container widget (max-width: 1300px) { .cards-grid { --cards-per-row: 5; } }
|
||||
@container widget (max-width: 1100px) { .cards-grid { --cards-per-row: 4; } }
|
||||
@container widget (max-width: 850px) { .cards-grid { --cards-per-row: 3; } }
|
||||
@container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
|
||||
@container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
|
||||
|
||||
.text-truncate,
|
||||
.single-line-titles .title
|
||||
{
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.single-line-titles .title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.text-truncate-2-lines, .text-truncate-3-lines {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.text-truncate-3-lines { line-clamp: 3; -webkit-line-clamp: 3; }
|
||||
.text-truncate-2-lines { line-clamp: 2; -webkit-line-clamp: 2; }
|
||||
|
||||
.visited-indicator:not(.text-truncate)::after,
|
||||
.visited-indicator.text-truncate::before {
|
||||
content: '↗' / "";
|
||||
margin-left: 0.5em;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.15em;
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
|
||||
.visited-indicator.text-truncate {
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.visited-indicator:not(:visited)::before, .visited-indicator:not(:visited)::after {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.page-columns-transitioned .list-with-transition > * { animation: collapsibleItemReveal .25s backwards; }
|
||||
.list-with-transition > *:nth-child(2) { animation-delay: 30ms; }
|
||||
.list-with-transition > *:nth-child(3) { animation-delay: 60ms; }
|
||||
.list-with-transition > *:nth-child(4) { animation-delay: 90ms; }
|
||||
.list-with-transition > *:nth-child(5) { animation-delay: 120ms; }
|
||||
.list-with-transition > *:nth-child(6) { animation-delay: 150ms; }
|
||||
.list-with-transition > *:nth-child(7) { animation-delay: 180ms; }
|
||||
.list-with-transition > *:nth-child(8) { animation-delay: 210ms; }
|
||||
|
||||
.list > *:not(:first-child) {
|
||||
margin-top: calc(var(--list-half-gap) * 2);
|
||||
}
|
||||
|
||||
.list.list-with-separator > *:not(:first-child) {
|
||||
margin-top: var(--list-half-gap);
|
||||
border-top: 1px solid var(--color-separator);
|
||||
padding-top: var(--list-half-gap);
|
||||
}
|
||||
|
||||
.collapsible-container:not(.container-expanded) > .collapsible-item {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapsible-item {
|
||||
animation: collapsibleItemReveal .25s backwards;
|
||||
}
|
||||
|
||||
@keyframes collapsibleItemReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.expand-toggle-button {
|
||||
font: inherit;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
color: var(--color-text-base);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-size-h4);
|
||||
padding: var(--widget-content-vertical-padding) 0;
|
||||
background: var(--color-widget-background);
|
||||
}
|
||||
|
||||
.expand-toggle-button.container-expanded {
|
||||
position: sticky;
|
||||
/* -1px to hide 1px gap on chrome */
|
||||
bottom: -1px;
|
||||
}
|
||||
|
||||
.expand-toggle-button-icon {
|
||||
display: inline-block;
|
||||
margin-left: 1rem;
|
||||
position: relative;
|
||||
top: -.2rem;
|
||||
}
|
||||
|
||||
.expand-toggle-button-icon::before {
|
||||
content: '' / "";
|
||||
font-size: 0.8rem;
|
||||
transform: rotate(90deg);
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.expand-toggle-button.container-expanded .expand-toggle-button-icon::before {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.cards-grid.collapsible-container + .expand-toggle-button {
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
.widget-content:has(.expand-toggle-button:last-child) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.carousel-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carousel-container::before, .carousel-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 2rem;
|
||||
top: 0;
|
||||
bottom: 1rem;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
.carousel-container::before {
|
||||
background: linear-gradient(to right, var(--color-background), transparent);
|
||||
}
|
||||
|
||||
.carousel-container::after {
|
||||
right: 0;
|
||||
background: linear-gradient(to left, var(--color-background), transparent);
|
||||
}
|
||||
|
||||
.carousel-container.show-left-cutoff::before, .carousel-container.show-right-cutoff::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
:root:not(.light-scheme) .flat-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.attachments > * {
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: var(--font-size-h6);
|
||||
background-color: var(--color-separator);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
border: 1px solid var(--color-progress-border);
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
height: 1.5rem;
|
||||
/* naughty, but oh so beautiful */
|
||||
margin-inline: -3px;
|
||||
}
|
||||
|
||||
.progress-bar-combined {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.popover-active > .progress-bar {
|
||||
transition: border-color .3s;
|
||||
border-color: var(--color-text-subdue);
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
--half-border-radius: calc(var(--border-radius) / 2);
|
||||
border-radius: 0 var(--half-border-radius) var(--half-border-radius) 0;
|
||||
background: var(--color-progress-value);
|
||||
width: calc(var(--percent) * 1%);
|
||||
min-width: 1px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.progress-value:first-child {
|
||||
border-top-left-radius: var(--half-border-radius);
|
||||
}
|
||||
|
||||
.progress-value:last-child {
|
||||
border-bottom-left-radius: var(--half-border-radius);
|
||||
}
|
||||
|
||||
.progress-value-notice {
|
||||
background: linear-gradient(to right, var(--color-progress-value) 65%, var(--color-negative));
|
||||
}
|
||||
|
||||
.value-separator {
|
||||
min-width: 2rem;
|
||||
margin-inline: 0.8rem;
|
||||
flex: 1;
|
||||
height: calc(1em * 1.1);
|
||||
border-bottom: 1px dotted var(--color-text-subdue);
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
filter: grayscale(0.2) contrast(0.9);
|
||||
opacity: 0.8;
|
||||
transition: filter 0.2s, opacity .2s;
|
||||
}
|
||||
|
||||
.thumbnail-container {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-separator);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.thumbnail-container > * {
|
||||
border-radius: var(--border-radius);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumbnail-parent:hover .thumbnail {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.size-h1 { font-size: var(--font-size-h1); }
|
||||
.size-h2 { font-size: var(--font-size-h2); }
|
||||
.size-h3 { font-size: var(--font-size-h3); }
|
||||
.size-h4 { font-size: var(--font-size-h4); }
|
||||
.size-base { font-size: var(--font-size-base); }
|
||||
.size-h5 { font-size: var(--font-size-h5); }
|
||||
.size-h6 { font-size: var(--font-size-h6); }
|
||||
|
||||
.color-highlight { color: var(--color-text-highlight); }
|
||||
.color-paragraph { color: var(--color-text-paragraph); }
|
||||
.color-base { color: var(--color-text-base); }
|
||||
.color-subdue { color: var(--color-text-subdue); }
|
||||
.color-negative { color: var(--color-negative); }
|
||||
.color-positive { color: var(--color-positive); }
|
||||
.color-primary { color: var(--color-primary); }
|
||||
|
||||
.color-primary-if-not-visited:not(:visited) {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.cursor-help { cursor: help; }
|
||||
.rounded { border-radius: var(--border-radius); }
|
||||
.break-all { word-break: break-all; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
.text-center { text-align: center; }
|
||||
.text-elevate { margin-top: -0.2em; }
|
||||
.text-compact { word-spacing: -0.18em; }
|
||||
.text-very-compact { word-spacing: -0.35em; }
|
||||
.rtl { direction: rtl; }
|
||||
.shrink { flex-shrink: 1; }
|
||||
.shrink-0 { flex-shrink: 0; }
|
||||
.min-width-0 { min-width: 0; }
|
||||
.max-width-100 { max-width: 100%; }
|
||||
.block { display: block; }
|
||||
.inline-block { display: inline-block; }
|
||||
.overflow-hidden { overflow: hidden; }
|
||||
.relative { position: relative; }
|
||||
.flex { display: flex; }
|
||||
.flex-1 { flex: 1; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.flex-nowrap { flex-wrap: nowrap; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-stretch { justify-content: stretch; }
|
||||
.justify-evenly { justify-content: space-evenly; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-end { justify-content: end; }
|
||||
.uppercase { text-transform: uppercase; }
|
||||
.grow { flex-grow: 1; }
|
||||
.flex-column { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.items-start { align-items: start; }
|
||||
.items-end { align-items: end; }
|
||||
.gap-5 { gap: 0.5rem; }
|
||||
.gap-7 { gap: 0.7rem; }
|
||||
.gap-10 { gap: 1rem; }
|
||||
.gap-12 { gap: 1.2rem; }
|
||||
.gap-15 { gap: 1.5rem; }
|
||||
.gap-20 { gap: 2rem; }
|
||||
.gap-25 { gap: 2.5rem; }
|
||||
.gap-35 { gap: 3.5rem; }
|
||||
.gap-45 { gap: 4.5rem; }
|
||||
.gap-55 { gap: 5.5rem; }
|
||||
.margin-left-auto { margin-left: auto; }
|
||||
.margin-top-3 { margin-top: 0.3rem; }
|
||||
.margin-top-5 { margin-top: 0.5rem; }
|
||||
.margin-top-7 { margin-top: 0.7rem; }
|
||||
.margin-top-10 { margin-top: 1rem; }
|
||||
.margin-top-15 { margin-top: 1.5rem; }
|
||||
.margin-top-20 { margin-top: 2rem; }
|
||||
.margin-top-25 { margin-top: 2.5rem; }
|
||||
.margin-top-35 { margin-top: 3.5rem; }
|
||||
.margin-top-40 { margin-top: 4rem; }
|
||||
.margin-top-auto { margin-top: auto; }
|
||||
.margin-block-3 { margin-block: 0.3rem; }
|
||||
.margin-block-5 { margin-block: 0.5rem; }
|
||||
.margin-block-7 { margin-block: 0.7rem; }
|
||||
.margin-block-8 { margin-block: 0.8rem; }
|
||||
.margin-block-10 { margin-block: 1rem; }
|
||||
.margin-block-15 { margin-block: 1.5rem; }
|
||||
.margin-bottom-3 { margin-bottom: 0.3rem; }
|
||||
.margin-bottom-5 { margin-bottom: 0.5rem; }
|
||||
.margin-bottom-7 { margin-bottom: 0.7rem; }
|
||||
.margin-bottom-10 { margin-bottom: 1rem; }
|
||||
.margin-bottom-15 { margin-bottom: 1.5rem; }
|
||||
.margin-bottom-auto { margin-bottom: auto; }
|
||||
.margin-bottom-widget { margin-bottom: var(--widget-content-vertical-padding); }
|
||||
.padding-widget { padding: var(--widget-content-padding); }
|
||||
.padding-block-widget { padding-block: var(--widget-content-vertical-padding); }
|
||||
.padding-inline-widget { padding-inline: var(--widget-content-horizontal-padding); }
|
||||
.padding-block-5 { padding-block: 0.5rem; }
|
||||
.scale-half { transform: scale(0.5); }
|
||||
.list { --list-half-gap: 0rem; }
|
||||
.list-gap-2 { --list-half-gap: 0.1rem; }
|
||||
.list-gap-4 { --list-half-gap: 0.2rem; }
|
||||
.list-gap-8 { --list-half-gap: 0.4rem; }
|
||||
.list-gap-10 { --list-half-gap: 0.5rem; }
|
||||
.list-gap-14 { --list-half-gap: 0.7rem; }
|
||||
.list-gap-20 { --list-half-gap: 1rem; }
|
||||
.list-gap-24 { --list-half-gap: 1.2rem; }
|
||||
.list-gap-34 { --list-half-gap: 1.7rem; }
|
||||
31
internal/glance/static/css/widget-bookmarks.css
Normal file
31
internal/glance/static/css/widget-bookmarks.css
Normal file
@@ -0,0 +1,31 @@
|
||||
.bookmarks-group {
|
||||
--bookmarks-group-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.bookmarks-group-title {
|
||||
color: var(--bookmarks-group-color);
|
||||
}
|
||||
|
||||
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
|
||||
content: '↗' / "";
|
||||
margin-left: 0.5em;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.15em;
|
||||
color: var(--bookmarks-group-color);
|
||||
}
|
||||
|
||||
.bookmarks-icon-container {
|
||||
margin-block: 0.1rem;
|
||||
background-color: var(--color-widget-background-highlight);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.5rem;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bookmarks-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
71
internal/glance/static/css/widget-calendar.css
Normal file
71
internal/glance/static/css/widget-calendar.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.old-calendar-day {
|
||||
width: calc(100% / 7);
|
||||
text-align: center;
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
|
||||
.old-calendar-day-today {
|
||||
border-radius: var(--border-radius);
|
||||
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.calendar-dates {
|
||||
text-align: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.calendar-date {
|
||||
padding: 0.4rem 0;
|
||||
color: var(--color-text-base);
|
||||
position: relative;
|
||||
border-radius: var(--border-radius);
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.calendar-current-date {
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--color-popover-border);
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.calendar-spillover-date {
|
||||
color: var(--color-text-subdue);
|
||||
}
|
||||
|
||||
.calendar-header-button {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
z-index: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.calendar-header-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.2rem;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--color-text-subdue);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.calendar-header-button:hover::before {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.calendar-undo-button {
|
||||
display: inline-block;
|
||||
vertical-align: text-top;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin-left: 0.7rem;
|
||||
}
|
||||
7
internal/glance/static/css/widget-clock.css
Normal file
7
internal/glance/static/css/widget-clock.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.clock-time {
|
||||
min-width: 8ch;
|
||||
}
|
||||
|
||||
.clock-time span {
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
120
internal/glance/static/css/widget-dns-stats.css
Normal file
120
internal/glance/static/css/widget-dns-stats.css
Normal file
@@ -0,0 +1,120 @@
|
||||
.dns-stats-totals {
|
||||
transition: opacity .3s;
|
||||
transition-delay: 50ms;
|
||||
}
|
||||
|
||||
.dns-stats:has(.dns-stats-graph .popover-active) .dns-stats-totals {
|
||||
opacity: 0.1;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.dns-stats-graph {
|
||||
--graph-height: 70px;
|
||||
height: var(--graph-height);
|
||||
position: relative;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.dns-stats-graph-gridlines-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.dns-stats-graph-gridlines {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dns-stats-graph-columns {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dns-stats-graph-column {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
width: calc(100% / 8);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dns-stats-graph-column::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 1px 0;
|
||||
opacity: 0;
|
||||
background: var(--color-text-base);
|
||||
transition: opacity .2s;
|
||||
}
|
||||
|
||||
.dns-stats-graph-column:hover::before {
|
||||
opacity: 0.05;
|
||||
}
|
||||
|
||||
.dns-stats-graph-bar {
|
||||
width: 14px;
|
||||
height: calc((var(--bar-height) / 100) * var(--graph-height));
|
||||
border: 1px solid var(--color-progress-border);
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
display: flex;
|
||||
background: var(--color-widget-background);
|
||||
padding: 2px 2px 0 2px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
transition: border-color .2s;
|
||||
min-height: 10px;
|
||||
}
|
||||
|
||||
.dns-stats-graph-column.popover-active .dns-stats-graph-bar {
|
||||
border-color: var(--color-text-subdue);
|
||||
border-bottom-color: var(--color-progress-border);
|
||||
}
|
||||
|
||||
.dns-stats-graph-bar > * {
|
||||
border-radius: 2px;
|
||||
background: var(--color-vertical-progress-value);
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.dns-stats-graph-bar > .queries {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.dns-stats-graph-bar > *:last-child {
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.dns-stats-graph-bar > .blocked {
|
||||
background-color: var(--color-negative);
|
||||
flex-basis: calc(var(--percent) - 1px);
|
||||
}
|
||||
|
||||
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dns-stats-graph-time, .dns-stats-graph-columns:hover .dns-stats-graph-time {
|
||||
position: absolute;
|
||||
font-size: var(--font-size-h6);
|
||||
inset-inline: 0;
|
||||
text-align: center;
|
||||
height: 2.5rem;
|
||||
line-height: 2.5rem;
|
||||
top: 100%;
|
||||
user-select: none;
|
||||
opacity: 0;
|
||||
transform: translateY(-0.5rem);
|
||||
transition: opacity .2s, transform .2s;
|
||||
}
|
||||
|
||||
.dns-stats-graph-column:hover .dns-stats-graph-time {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dns-stats-graph-columns:hover .dns-stats-graph-column:not(:hover) .dns-stats-graph-time {
|
||||
opacity: 0;
|
||||
}
|
||||
26
internal/glance/static/css/widget-docker-containers.css
Normal file
26
internal/glance/static/css/widget-docker-containers.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.docker-container-icon {
|
||||
display: block;
|
||||
filter: grayscale(0.4);
|
||||
object-fit: contain;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 2.7rem;
|
||||
opacity: 0.8;
|
||||
transition: filter 0.3s, opacity 0.3s;
|
||||
}
|
||||
|
||||
.docker-container-icon.flat-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.docker-container:hover .docker-container-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.docker-container:hover .docker-container-icon:not(.flat-icon) {
|
||||
filter: grayscale(0);
|
||||
}
|
||||
|
||||
.docker-container-status-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
49
internal/glance/static/css/widget-group.css
Normal file
49
internal/glance/static/css/widget-group.css
Normal file
@@ -0,0 +1,49 @@
|
||||
.widget-group-header {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.widget-group-title {
|
||||
background: none;
|
||||
font: inherit;
|
||||
border: none;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px dotted transparent;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color .3s, border-color .3s;
|
||||
color: var(--color-text-subdue);
|
||||
line-height: calc(1.6em - 1px);
|
||||
}
|
||||
|
||||
.widget-group-title:hover:not(.widget-group-title-current) {
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
|
||||
.widget-group-title-current {
|
||||
border-bottom-color: var(--color-text-base-muted);
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
13
internal/glance/static/css/widget-markets.css
Normal file
13
internal/glance/static/css/widget-markets.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.market-chart {
|
||||
margin-left: auto;
|
||||
width: 6.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.market-chart svg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.market-values {
|
||||
min-width: 8rem;
|
||||
}
|
||||
36
internal/glance/static/css/widget-monitor.css
Normal file
36
internal/glance/static/css/widget-monitor.css
Normal file
@@ -0,0 +1,36 @@
|
||||
.monitor-site-icon {
|
||||
display: block;
|
||||
opacity: 0.8;
|
||||
filter: grayscale(0.4);
|
||||
object-fit: contain;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 3.2rem;
|
||||
position: relative;
|
||||
top: -0.1rem;
|
||||
transition: filter 0.3s, opacity 0.3s;
|
||||
}
|
||||
|
||||
.monitor-site-icon.flat-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.monitor-site:hover .monitor-site-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
|
||||
filter: grayscale(0);
|
||||
}
|
||||
|
||||
.monitor-site-status-icon {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.monitor-site-status-icon-compact {
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
22
internal/glance/static/css/widget-reddit.css
Normal file
22
internal/glance/static/css/widget-reddit.css
Normal file
@@ -0,0 +1,22 @@
|
||||
.reddit-card-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 0% 20%;
|
||||
opacity: 0.15;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.reddit-card-thumbnail-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.reddit-card-thumbnail-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(0deg, var(--color-widget-background) 10%, transparent);
|
||||
}
|
||||
6
internal/glance/static/css/widget-releases.css
Normal file
6
internal/glance/static/css/widget-releases.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.release-source-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.4;
|
||||
}
|
||||
56
internal/glance/static/css/widget-rss.css
Normal file
56
internal/glance/static/css/widget-rss.css
Normal file
@@ -0,0 +1,56 @@
|
||||
.rss-card-image {
|
||||
height: var(--rss-thumbnail-height, 10rem);
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
.rss-card-2 {
|
||||
position: relative;
|
||||
height: var(--rss-card-height, 27rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rss-card-2::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(
|
||||
0deg,
|
||||
var(--color-widget-background),
|
||||
hsla(var(--color-widget-background-hsl-values), 0.8) 6rem, transparent 14rem
|
||||
);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.rss-card-2-image {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* +1px is required to fix some weird graphical bug where the image overflows on the bottom in firefox */
|
||||
border-radius: calc(var(--border-radius) + 1px);
|
||||
opacity: 0.9;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rss-card-2-content {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: var(--widget-content-vertical-padding);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.rss-detailed-description {
|
||||
max-width: 55rem;
|
||||
color: var(--color-text-base-muted);
|
||||
}
|
||||
|
||||
.rss-detailed-thumbnail {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.rss-detailed-thumbnail > * {
|
||||
aspect-ratio: 3 / 2;
|
||||
height: 8.7rem;
|
||||
}
|
||||
79
internal/glance/static/css/widget-search.css
Normal file
79
internal/glance/static/css/widget-search.css
Normal file
@@ -0,0 +1,79 @@
|
||||
.search-icon {
|
||||
width: 2.3rem;
|
||||
}
|
||||
|
||||
.search-icon-container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* gives a wider hit area for the 3 people that will notice the animation : ) */
|
||||
.search-icon-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1rem;
|
||||
}
|
||||
|
||||
.search-icon-container:hover > .search-icon {
|
||||
animation: searchIconHover 2.9s forwards;
|
||||
}
|
||||
|
||||
@keyframes searchIconHover {
|
||||
0%, 39% { translate: 0 0; }
|
||||
20% { scale: 1.3; }
|
||||
40% { scale: 1; }
|
||||
50% { translate: -30% 30%; }
|
||||
70% { translate: 30% -30%; }
|
||||
90% { translate: -30% -30%; }
|
||||
100% { translate: 0 0; }
|
||||
}
|
||||
|
||||
.search {
|
||||
transition: border-color .2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search:hover {
|
||||
border-color: var(--color-text-subdue);
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
border: 0;
|
||||
background: none;
|
||||
width: 100%;
|
||||
height: 6rem;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--color-text-base-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.search-bangs { display: none; }
|
||||
|
||||
.search-bang {
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background: var(--color-widget-background-highlight);
|
||||
padding: 0.3rem 1rem;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size-h5);
|
||||
animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes searchBangsEntrance {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.search-bang:empty {
|
||||
display: none;
|
||||
}
|
||||
81
internal/glance/static/css/widget-server-stats.css
Normal file
81
internal/glance/static/css/widget-server-stats.css
Normal file
@@ -0,0 +1,81 @@
|
||||
.widget-type-server-info {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.server + .server {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.server {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-details {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.server-spicy-cpu-icon {
|
||||
height: 1em;
|
||||
align-self: center;
|
||||
margin-left: 0.4em;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.server-stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.server-stat-unavailable {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@container widget (min-width: 650px) {
|
||||
.server {
|
||||
gap: 2rem;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.server + .server {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: unset;
|
||||
margin-right: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.server-stats {
|
||||
flex-direction: row;
|
||||
justify-content: right;
|
||||
min-width: 450px;
|
||||
margin-top: 0;
|
||||
gap: 2rem;
|
||||
padding-bottom: 0.8rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.server-stats > * {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
47
internal/glance/static/css/widget-twitch.css
Normal file
47
internal/glance/static/css/widget-twitch.css
Normal file
@@ -0,0 +1,47 @@
|
||||
.twitch-category-thumbnail {
|
||||
width: 5rem;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.twitch-channel-avatar {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.twitch-channel-avatar-container {
|
||||
width: 4.4rem;
|
||||
height: 4.4rem;
|
||||
border: 2px solid var(--color-text-subdue);
|
||||
padding: 2px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.twitch-channel-live .twitch-channel-avatar-container {
|
||||
border: 2px solid var(--color-positive);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.twitch-channel-live .twitch-channel-avatar-container::after {
|
||||
content: 'LIVE';
|
||||
position: absolute;
|
||||
background: var(--color-positive);
|
||||
color: var(--color-widget-background);
|
||||
font-size: var(--font-size-h6);
|
||||
left: 50%;
|
||||
bottom: -35%;
|
||||
border-radius: var(--border-radius);
|
||||
padding-inline: 0.3rem;
|
||||
transform: translate(-50%);
|
||||
border: 2px solid var(--color-widget-background);
|
||||
}
|
||||
|
||||
.twitch-stream-preview {
|
||||
max-width: 100%;
|
||||
width: 400px;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--border-radius);
|
||||
object-fit: cover;
|
||||
}
|
||||
13
internal/glance/static/css/widget-videos.css
Normal file
13
internal/glance/static/css/widget-videos.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.video-thumbnail {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 8.9;
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
.video-horizontal-list-thumbnail {
|
||||
height: 4rem;
|
||||
aspect-ratio: 16 / 8.9;
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
139
internal/glance/static/css/widget-weather.css
Normal file
139
internal/glance/static/css/widget-weather.css
Normal file
@@ -0,0 +1,139 @@
|
||||
.weather-column {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
flex-direction: column;
|
||||
width: calc(100% / 12);
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.weather-column-value, .weather-columns:hover .weather-column-value {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-highlight);
|
||||
letter-spacing: -0.1rem;
|
||||
margin-right: 0.1rem;
|
||||
position: relative;
|
||||
margin-bottom: 0.3rem;
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem);
|
||||
transition: opacity .2s, transform .2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.weather-column-current .weather-column-value, .weather-column:hover .weather-column-value {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.weather-column-value::after {
|
||||
position: absolute;
|
||||
content: '°';
|
||||
left: 100%;
|
||||
color: var(--color-text-subdue);
|
||||
}
|
||||
|
||||
.weather-column-value.weather-column-value-negative::before {
|
||||
position: absolute;
|
||||
content: '-';
|
||||
right: 100%;
|
||||
}
|
||||
|
||||
.weather-bar, .weather-columns:hover .weather-bar {
|
||||
height: calc(20px + var(--weather-bar-height) * 40px);
|
||||
width: 6px;
|
||||
background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 18%)));
|
||||
border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 24%)));
|
||||
border-bottom: 0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
mask-image: linear-gradient(0deg, transparent 0, #000 10px);
|
||||
-webkit-mask-image: linear-gradient(0deg, transparent 0, #000 10px);
|
||||
transition: background-color .2s, border-color .2s, width .2s;
|
||||
}
|
||||
|
||||
.weather-column-current .weather-bar, .weather-column:hover .weather-bar {
|
||||
width: 10px;
|
||||
background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%)));
|
||||
border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 50%)));
|
||||
}
|
||||
|
||||
.weather-column-rain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
bottom: 20%;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(0deg, transparent 40%, #000);
|
||||
-webkit-mask-image: linear-gradient(0deg, transparent 40%, #000);
|
||||
}
|
||||
|
||||
.weather-column-rain::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* TODO: figure out a way to make it look continuous between columns, right now */
|
||||
/* depending on the width of the page the rain inside two columns next to each other */
|
||||
/* can overlap and look bad */
|
||||
background: radial-gradient(circle at 4px 4px, hsl(200, 90%, 70%, 0.4) 1px, transparent 0);
|
||||
background-size: 8px 8px;
|
||||
transform: rotate(45deg) translate(-50%, 25%);
|
||||
height: 130%;
|
||||
aspect-ratio: 1;
|
||||
left: 55%;
|
||||
}
|
||||
|
||||
.weather-column:nth-child(3) .weather-column-time,
|
||||
.weather-column:nth-child(7) .weather-column-time,
|
||||
.weather-column:nth-child(11) .weather-column-time {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.weather-column-time, .weather-columns:hover .weather-column-time {
|
||||
margin-top: 0.3rem;
|
||||
font-size: var(--font-size-h6);
|
||||
opacity: 0;
|
||||
transform: translateY(-0.5rem);
|
||||
transition: opacity .2s, transform .2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.weather-column:hover .weather-column-time {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.weather-column-daylight {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(0deg, transparent 30px, hsl(50, 50%, 30%, 0.2));
|
||||
}
|
||||
|
||||
.weather-column-daylight-sunrise {
|
||||
border-radius: 20px 0 0 0;
|
||||
}
|
||||
|
||||
.weather-column-daylight-sunset {
|
||||
border-radius: 0 20px 0 0;
|
||||
}
|
||||
|
||||
.location-icon {
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
border-radius: 0 50% 50% 50%;
|
||||
background-color: currentColor;
|
||||
transform: rotate(225deg) translate(.1em, .1em);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.location-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: .4em;
|
||||
height: .4em;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-widget-background);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
88
internal/glance/static/css/widgets.css
Normal file
88
internal/glance/static/css/widgets.css
Normal file
@@ -0,0 +1,88 @@
|
||||
@import "widget-bookmarks.css";
|
||||
@import "widget-calendar.css";
|
||||
@import "widget-clock.css";
|
||||
@import "widget-dns-stats.css";
|
||||
@import "widget-docker-containers.css";
|
||||
@import "widget-group.css";
|
||||
@import "widget-markets.css";
|
||||
@import "widget-monitor.css";
|
||||
@import "widget-reddit.css";
|
||||
@import "widget-releases.css";
|
||||
@import "widget-rss.css";
|
||||
@import "widget-search.css";
|
||||
@import "widget-server-stats.css";
|
||||
@import "widget-twitch.css";
|
||||
@import "widget-videos.css";
|
||||
@import "widget-weather.css";
|
||||
|
||||
@import "forum-posts.css";
|
||||
|
||||
.widget-error-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
margin-bottom: 1.8rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.widget-error-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: calc(0rem - (var(--widget-content-vertical-padding) / 2)) calc(0rem - (var(--widget-content-horizontal-padding) / 2));
|
||||
background: var(--color-negative);
|
||||
opacity: 0.05;
|
||||
border-radius: var(--border-radius);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.widget-error-icon {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
flex-shrink: 0;
|
||||
stroke: var(--color-negative);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
container-type: inline-size;
|
||||
container-name: widget;
|
||||
}
|
||||
|
||||
.widget-content:not(.widget-content-frameless) {
|
||||
padding: var(--widget-content-padding);
|
||||
}
|
||||
|
||||
.widget-content:not(.widget-content-frameless), .widget-content-frame {
|
||||
background: var(--color-widget-background);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--color-widget-content-border);
|
||||
box-shadow: 0px 3px 0px 0px hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl)) - 0.5%));
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
|
||||
font-size: var(--font-size-h4);
|
||||
margin-bottom: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.widget-beta-icon {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
flex-shrink: 0;
|
||||
transition: transform .45s, opacity .45s, stroke .45s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.widget-beta-icon:hover, .widget-header .popover-active > .widget-beta-icon {
|
||||
fill: var(--color-text-highlight);
|
||||
transform: translateY(-10%) scale(1.3);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.widget + .widget {
|
||||
margin-top: var(--widget-gap);
|
||||
}
|
||||
33
internal/glance/static/js/animations.js
Normal file
33
internal/glance/static/js/animations.js
Normal file
@@ -0,0 +1,33 @@
|
||||
export const easeOutQuint = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
||||
|
||||
export function directions(anim, opt, ...dirs) {
|
||||
return dirs.map(dir => anim({ direction: dir, ...opt }));
|
||||
}
|
||||
|
||||
export function slideFade({
|
||||
direction = 'left',
|
||||
fill = 'backwards',
|
||||
duration = 200,
|
||||
distance = '1rem',
|
||||
easing = 'ease',
|
||||
offset = 0,
|
||||
}) {
|
||||
const axis = direction === 'left' || direction === 'right' ? 'X' : 'Y';
|
||||
const negative = direction === 'left' || direction === 'up' ? '-' : '';
|
||||
const amount = negative + distance;
|
||||
|
||||
return {
|
||||
keyframes: [
|
||||
{
|
||||
offset: offset,
|
||||
opacity: 0,
|
||||
transform: `translate${axis}(${amount})`,
|
||||
}
|
||||
],
|
||||
options: {
|
||||
duration: duration,
|
||||
easing: easing,
|
||||
fill: fill,
|
||||
},
|
||||
};
|
||||
}
|
||||
212
internal/glance/static/js/calendar.js
Normal file
212
internal/glance/static/js/calendar.js
Normal file
@@ -0,0 +1,212 @@
|
||||
import { directions, easeOutQuint, slideFade } from "./animations.js";
|
||||
import { elem, repeat, text } from "./templating.js";
|
||||
|
||||
const FULL_MONTH_SLOTS = 7*6;
|
||||
const WEEKDAY_ABBRS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
|
||||
const leftArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>`;
|
||||
|
||||
const rightArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>`;
|
||||
|
||||
const undoArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
||||
</svg>`;
|
||||
|
||||
const [datesExitLeft, datesExitRight] = directions(
|
||||
slideFade, { distance: "2rem", duration: 120, offset: 1 },
|
||||
"left", "right"
|
||||
);
|
||||
|
||||
const [datesEntranceLeft, datesEntranceRight] = directions(
|
||||
slideFade, { distance: "0.8rem", duration: 500, easing: easeOutQuint },
|
||||
"left", "right"
|
||||
);
|
||||
|
||||
const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
|
||||
|
||||
export default function(element) {
|
||||
element.swap(Calendar(
|
||||
Number(element.dataset.firstDayOfWeek ?? 1)
|
||||
));
|
||||
}
|
||||
|
||||
// TODO: when viewing the previous/next month, display the current date if it's within the spill-over days
|
||||
function Calendar(firstDay) {
|
||||
let header, dates;
|
||||
let advanceTimeTicker;
|
||||
let now = new Date();
|
||||
let activeDate;
|
||||
|
||||
const update = (newDate) => {
|
||||
header.component.update(now, newDate);
|
||||
dates.component.update(now, newDate);
|
||||
activeDate = newDate;
|
||||
};
|
||||
|
||||
const autoAdvanceNow = () => {
|
||||
advanceTimeTicker = setTimeout(() => {
|
||||
// TODO: don't auto advance if looking at a different month
|
||||
update(now = new Date());
|
||||
autoAdvanceNow();
|
||||
}, msTillNextDay());
|
||||
};
|
||||
|
||||
const adjacentMonth = (dir) => new Date(activeDate.getFullYear(), activeDate.getMonth() + dir, 1);
|
||||
const nextClicked = () => update(adjacentMonth(1));
|
||||
const prevClicked = () => update(adjacentMonth(-1));
|
||||
const undoClicked = () => update(now);
|
||||
|
||||
const calendar = elem().classes("calendar").append(
|
||||
header = Header(nextClicked, prevClicked, undoClicked),
|
||||
dates = Dates(firstDay)
|
||||
);
|
||||
|
||||
update(now);
|
||||
autoAdvanceNow();
|
||||
|
||||
return calendar.component({
|
||||
suspend: () => clearTimeout(advanceTimeTicker)
|
||||
});
|
||||
}
|
||||
|
||||
function Header(nextClicked, prevClicked, undoClicked) {
|
||||
let month, monthNumber, year, undo;
|
||||
const button = () => elem("button").classes("calendar-header-button");
|
||||
|
||||
const monthAndYear = elem().classes("size-h2", "color-highlight").append(
|
||||
month = text(),
|
||||
" ",
|
||||
year = elem("span").classes("size-h3"),
|
||||
undo = button()
|
||||
.hide()
|
||||
.classes("calendar-undo-button")
|
||||
.attr("title", "Back to current month")
|
||||
.on("click", undoClicked)
|
||||
.html(undoArrowSvg)
|
||||
);
|
||||
|
||||
const monthSwitcher = elem()
|
||||
.classes("flex", "gap-7", "items-center")
|
||||
.append(
|
||||
button()
|
||||
.attr("title", "Previous month")
|
||||
.on("click", prevClicked)
|
||||
.html(leftArrowSvg),
|
||||
monthNumber = elem()
|
||||
.classes("color-highlight")
|
||||
.styles({ marginTop: "0.1rem" }),
|
||||
button()
|
||||
.attr("title", "Next month")
|
||||
.on("click", nextClicked)
|
||||
.html(rightArrowSvg),
|
||||
);
|
||||
|
||||
return elem().classes("flex", "justify-between", "items-center").append(
|
||||
monthAndYear,
|
||||
monthSwitcher
|
||||
).component({
|
||||
update: function (now, newDate) {
|
||||
month.text(MONTH_NAMES[newDate.getMonth()]);
|
||||
year.text(newDate.getFullYear());
|
||||
const m = newDate.getMonth() + 1;
|
||||
monthNumber.text((m < 10 ? "0" : "") + m);
|
||||
|
||||
if (!datesWithinSameMonth(now, newDate)) {
|
||||
if (undo.isHidden()) undo.show().animate(undoEntrance);
|
||||
} else {
|
||||
undo.hide();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function Dates(firstDay) {
|
||||
let dates, lastRenderedDate;
|
||||
|
||||
const updateFullMonth = function(now, newDate) {
|
||||
const firstWeekday = new Date(newDate.getFullYear(), newDate.getMonth(), 1).getDay();
|
||||
const previousMonthSpilloverDays = (firstWeekday - firstDay + 7) % 7 || 7;
|
||||
const currentMonthDays = daysInMonth(newDate.getFullYear(), newDate.getMonth());
|
||||
const nextMonthSpilloverDays = FULL_MONTH_SLOTS - (previousMonthSpilloverDays + currentMonthDays);
|
||||
const previousMonthDays = daysInMonth(newDate.getFullYear(), newDate.getMonth() - 1)
|
||||
const isCurrentMonth = datesWithinSameMonth(now, newDate);
|
||||
const currentDate = now.getDate();
|
||||
|
||||
let children = dates.children;
|
||||
let index = 0;
|
||||
|
||||
for (let i = 0; i < FULL_MONTH_SLOTS; i++) {
|
||||
children[i].clearClasses("calendar-spillover-date", "calendar-current-date");
|
||||
}
|
||||
|
||||
for (let i = 0; i < previousMonthSpilloverDays; i++, index++) {
|
||||
children[index].classes("calendar-spillover-date").text(
|
||||
previousMonthDays - previousMonthSpilloverDays + i + 1
|
||||
)
|
||||
}
|
||||
|
||||
for (let i = 1; i <= currentMonthDays; i++, index++) {
|
||||
children[index]
|
||||
.classesIf(isCurrentMonth && i === currentDate, "calendar-current-date")
|
||||
.text(i);
|
||||
}
|
||||
|
||||
for (let i = 0; i < nextMonthSpilloverDays; i++, index++) {
|
||||
children[index].classes("calendar-spillover-date").text(i + 1);
|
||||
}
|
||||
|
||||
lastRenderedDate = newDate;
|
||||
};
|
||||
|
||||
const update = function(now, newDate) {
|
||||
if (lastRenderedDate === undefined || datesWithinSameMonth(newDate, lastRenderedDate)) {
|
||||
updateFullMonth(now, newDate);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = newDate > lastRenderedDate;
|
||||
dates.animateUpdate(
|
||||
() => updateFullMonth(now, newDate),
|
||||
next ? datesExitLeft : datesExitRight,
|
||||
next ? datesEntranceRight : datesEntranceLeft,
|
||||
);
|
||||
}
|
||||
|
||||
return elem().append(
|
||||
elem().classes("calendar-dates", "margin-top-15").append(
|
||||
...repeat(7, (i) => elem().classes("size-h6", "color-subdue").text(
|
||||
WEEKDAY_ABBRS[(firstDay + i) % 7]
|
||||
))
|
||||
),
|
||||
|
||||
dates = elem().classes("calendar-dates", "margin-top-3").append(
|
||||
...elem().classes("calendar-date").duplicate(FULL_MONTH_SLOTS)
|
||||
)
|
||||
).component({ update });
|
||||
}
|
||||
|
||||
function datesWithinSameMonth(d1, d2) {
|
||||
return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth();
|
||||
}
|
||||
|
||||
function daysInMonth(year, month) {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function msTillNextDay(now) {
|
||||
now = now || new Date();
|
||||
|
||||
return 86_400_000 - (
|
||||
now.getMilliseconds() +
|
||||
now.getSeconds() * 1000 +
|
||||
now.getMinutes() * 60_000 +
|
||||
now.getHours() * 3_600_000
|
||||
);
|
||||
}
|
||||
@@ -128,6 +128,7 @@ function setupSearchBoxes() {
|
||||
for (let i = 0; i < searchWidgets.length; i++) {
|
||||
const widget = searchWidgets[i];
|
||||
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
|
||||
const target = widget.dataset.target || "_blank";
|
||||
const newTab = widget.dataset.newTab === "true";
|
||||
const inputElement = widget.getElementsByClassName("search-input")[0];
|
||||
const bangElement = widget.getElementsByClassName("search-bang")[0];
|
||||
@@ -167,7 +168,7 @@ function setupSearchBoxes() {
|
||||
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
|
||||
|
||||
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
|
||||
window.open(url, '_blank').focus();
|
||||
window.open(url, target).focus();
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
@@ -308,7 +309,9 @@ function setupGroups() {
|
||||
|
||||
for (let i = 0; i < titles.length; i++) {
|
||||
titles[i].classList.remove("widget-group-title-current");
|
||||
titles[i].setAttribute("aria-selected", "false");
|
||||
tabs[i].classList.remove("widget-group-content-current");
|
||||
tabs[i].setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
if (current < t) {
|
||||
@@ -320,7 +323,9 @@ function setupGroups() {
|
||||
current = t;
|
||||
|
||||
title.classList.add("widget-group-title-current");
|
||||
title.setAttribute("aria-selected", "true");
|
||||
tabs[t].classList.add("widget-group-content-current");
|
||||
tabs[t].setAttribute("aria-hidden", "false");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -463,7 +468,7 @@ function setupCollapsibleGrids() {
|
||||
|
||||
let cardsPerRow;
|
||||
|
||||
const resolveCollapsibleItems = () => {
|
||||
const resolveCollapsibleItems = () => requestAnimationFrame(() => {
|
||||
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
|
||||
|
||||
if (hideItemsAfterIndex >= gridElement.children.length) {
|
||||
@@ -489,7 +494,7 @@ function setupCollapsibleGrids() {
|
||||
child.style.removeProperty("animation-delay");
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!isElementVisible(gridElement)) {
|
||||
@@ -649,6 +654,17 @@ function setupClocks() {
|
||||
updateClocks();
|
||||
}
|
||||
|
||||
async function setupCalendars() {
|
||||
const elems = document.getElementsByClassName("calendar");
|
||||
if (elems.length == 0) return;
|
||||
|
||||
// TODO: implement prefetching, currently loads as a nasty waterfall of requests
|
||||
const calendar = await import ('./calendar.js');
|
||||
|
||||
for (let i = 0; i < elems.length; i++)
|
||||
calendar.default(elems[i]);
|
||||
}
|
||||
|
||||
function setupTruncatedElementTitles() {
|
||||
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
|
||||
|
||||
@@ -658,7 +674,7 @@ function setupTruncatedElementTitles() {
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
if (element.title === "") element.title = element.textContent;
|
||||
if (element.getAttribute("title") === null) element.title = element.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,6 +784,7 @@ async function setupPage() {
|
||||
setupThemeSwitcher();
|
||||
setupPopovers();
|
||||
setupClocks()
|
||||
await setupCalendars();
|
||||
setupCarousels();
|
||||
setupSearchBoxes();
|
||||
setupCollapsibleLists();
|
||||
@@ -778,6 +795,7 @@ async function setupPage() {
|
||||
setupLazyImages();
|
||||
} finally {
|
||||
pageElement.classList.add("content-ready");
|
||||
pageElement.setAttribute("aria-busy", "false");
|
||||
|
||||
for (let i = 0; i < contentReadyCallbacks.length; i++) {
|
||||
contentReadyCallbacks[i]();
|
||||
|
||||
@@ -25,7 +25,8 @@ frameElement.append(contentElement);
|
||||
containerElement.append(frameElement);
|
||||
document.body.append(containerElement);
|
||||
|
||||
const observer = new ResizeObserver(repositionContainer);
|
||||
const queueRepositionContainer = () => requestAnimationFrame(repositionContainer);
|
||||
const observer = new ResizeObserver(queueRepositionContainer);
|
||||
|
||||
function handleMouseEnter(event) {
|
||||
clearTogglePopoverTimeout();
|
||||
@@ -97,14 +98,15 @@ function showPopover() {
|
||||
}
|
||||
|
||||
contentElement.style.maxWidth = contentMaxWidth;
|
||||
containerElement.style.display = "block";
|
||||
activeTarget.classList.add("popover-active");
|
||||
document.addEventListener("keydown", handleHidePopoverOnEscape);
|
||||
window.addEventListener("resize", repositionContainer);
|
||||
window.addEventListener("resize", queueRepositionContainer);
|
||||
observer.observe(containerElement);
|
||||
}
|
||||
|
||||
function repositionContainer() {
|
||||
containerElement.style.display = "block";
|
||||
|
||||
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
|
||||
? activeTarget.querySelector(activeTarget.dataset.popoverAnchor).getBoundingClientRect()
|
||||
: activeTarget.getBoundingClientRect();
|
||||
@@ -156,7 +158,7 @@ function hidePopover() {
|
||||
activeTarget.classList.remove("popover-active");
|
||||
containerElement.style.display = "none";
|
||||
document.removeEventListener("keydown", handleHidePopoverOnEscape);
|
||||
window.removeEventListener("resize", repositionContainer);
|
||||
window.removeEventListener("resize", queueRepositionContainer);
|
||||
observer.unobserve(containerElement);
|
||||
|
||||
if (cleanupOnHidePopover !== null) {
|
||||
|
||||
190
internal/glance/static/js/templating.js
Normal file
190
internal/glance/static/js/templating.js
Normal file
@@ -0,0 +1,190 @@
|
||||
export function elem(tag = "div") {
|
||||
return document.createElement(tag);
|
||||
}
|
||||
|
||||
export function fragment(...children) {
|
||||
const f = document.createDocumentFragment();
|
||||
if (children) f.append(...children);
|
||||
return f;
|
||||
}
|
||||
|
||||
export function text(str = "") {
|
||||
return document.createTextNode(str);
|
||||
}
|
||||
|
||||
export function repeat(n, fn) {
|
||||
const elems = Array(n);
|
||||
|
||||
for (let i = 0; i < n; i++)
|
||||
elems[i] = fn(i);
|
||||
|
||||
return elems;
|
||||
}
|
||||
|
||||
export function find(selector) {
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
|
||||
export function findAll(selector) {
|
||||
return document.querySelectorAll(selector);
|
||||
}
|
||||
|
||||
const ep = HTMLElement.prototype;
|
||||
const fp = DocumentFragment.prototype;
|
||||
const tp = Text.prototype;
|
||||
|
||||
ep.classes = function(...classes) {
|
||||
this.classList.add(...classes);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.find = function(selector) {
|
||||
return this.querySelector(selector);
|
||||
}
|
||||
|
||||
ep.findAll = function(selector) {
|
||||
return this.querySelectorAll(selector);
|
||||
}
|
||||
|
||||
ep.classesIf = function(cond, ...classes) {
|
||||
cond ? this.classList.add(...classes) : this.classList.remove(...classes);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.hide = function() {
|
||||
this.style.display = "none";
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.show = function() {
|
||||
this.style.removeProperty("display");
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.showIf = function(cond) {
|
||||
cond ? this.show() : this.hide();
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.isHidden = function() {
|
||||
return this.style.display === "none";
|
||||
}
|
||||
|
||||
ep.clearClasses = function(...classes) {
|
||||
classes.length ? this.classList.remove(...classes) : this.className = "";
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.hasClass = function(className) {
|
||||
return this.classList.contains(className);
|
||||
}
|
||||
|
||||
ep.attr = function(name, value) {
|
||||
this.setAttribute(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.attrs = function(attrs) {
|
||||
for (const [name, value] of Object.entries(attrs))
|
||||
this.setAttribute(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.tap = function(fn) {
|
||||
fn(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.text = function(text) {
|
||||
this.innerText = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.html = function(html) {
|
||||
this.innerHTML = html;
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.appendTo = function(parent) {
|
||||
parent.appendChild(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.swap = function(element) {
|
||||
this.replaceWith(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
ep.on = function(event, callback, options) {
|
||||
if (typeof event === "string") {
|
||||
this.addEventListener(event, callback, options);
|
||||
return this;
|
||||
}
|
||||
|
||||
for (let i = 0; i < event.length; i++)
|
||||
this.addEventListener(event[i], callback, options);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
const epAppend = ep.append;
|
||||
ep.append = function(...children) {
|
||||
epAppend.apply(this, children);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.duplicate = function(n) {
|
||||
const elems = Array(n);
|
||||
|
||||
for (let i = 0; i < n; i++)
|
||||
elems[i] = this.cloneNode(true);
|
||||
|
||||
return elems;
|
||||
}
|
||||
|
||||
ep.styles = function(s) {
|
||||
Object.assign(this.style, s);
|
||||
return this;
|
||||
}
|
||||
|
||||
const epAnimate = ep.animate;
|
||||
ep.animate = function(anim, callback) {
|
||||
const a = epAnimate.call(this, anim.keyframes, anim.options);
|
||||
if (callback) a.onfinish = () => callback(this, a);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.animateUpdate = function(update, exit, entrance) {
|
||||
this.animate(exit, () => {
|
||||
update(this);
|
||||
this.animate(entrance);
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.styleVar = function(name, value) {
|
||||
this.style.setProperty(`--${name}`, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.component = function (methods) {
|
||||
this.component = methods;
|
||||
return this;
|
||||
}
|
||||
|
||||
const fpAppend = fp.append;
|
||||
fp.append = function(...children) {
|
||||
fpAppend.apply(this, children);
|
||||
return this;
|
||||
}
|
||||
|
||||
fp.appendTo = function(parent) {
|
||||
parent.appendChild(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
tp.text = function(text) {
|
||||
this.nodeValue = text;
|
||||
return this;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "Glance",
|
||||
"display": "standalone",
|
||||
"background_color": "#151519",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "app-icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"html/template"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
var intl = message.NewPrinter(language.English)
|
||||
|
||||
var globalTemplateFunctions = template.FuncMap{
|
||||
"formatViewerCount": formatViewerCount,
|
||||
"formatNumber": intl.Sprint,
|
||||
"formatApproxNumber": formatApproxNumber,
|
||||
"formatNumber": intl.Sprint,
|
||||
"safeCSS": func(str string) template.CSS {
|
||||
return template.CSS(str)
|
||||
},
|
||||
@@ -28,9 +28,33 @@ var globalTemplateFunctions = template.FuncMap{
|
||||
"formatPrice": func(price float64) string {
|
||||
return intl.Sprintf("%.2f", price)
|
||||
},
|
||||
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
|
||||
return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
|
||||
"formatPriceWithPrecision": func(precision int, price float64) string {
|
||||
return intl.Sprintf("%."+strconv.Itoa(precision)+"f", price)
|
||||
},
|
||||
"dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs,
|
||||
"formatServerMegabytes": func(mb uint64) template.HTML {
|
||||
var value string
|
||||
var label string
|
||||
|
||||
if mb < 1_000 {
|
||||
value = strconv.FormatUint(mb, 10)
|
||||
label = "MB"
|
||||
} else if mb < 1_000_000 {
|
||||
if mb < 10_000 {
|
||||
value = fmt.Sprintf("%.1f", float64(mb)/1_000)
|
||||
} else {
|
||||
value = strconv.FormatUint(mb/1_000, 10)
|
||||
}
|
||||
|
||||
label = "GB"
|
||||
} else {
|
||||
value = fmt.Sprintf("%.1f", float64(mb)/1_000_000)
|
||||
label = "TB"
|
||||
}
|
||||
|
||||
return template.HTML(value + ` <span class="color-base size-h5">` + label + `</span>`)
|
||||
},
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
}
|
||||
|
||||
func mustParseTemplate(primary string, dependencies ...string) *template.Template {
|
||||
@@ -45,18 +69,22 @@ func mustParseTemplate(primary string, dependencies ...string) *template.Templat
|
||||
return t
|
||||
}
|
||||
|
||||
func formatViewerCount(count int) string {
|
||||
func formatApproxNumber(count int) string {
|
||||
if count < 1_000 {
|
||||
return strconv.Itoa(count)
|
||||
}
|
||||
|
||||
if count < 10_000 {
|
||||
return fmt.Sprintf("%.1fk", float64(count)/1_000)
|
||||
return strconv.FormatFloat(float64(count)/1_000, 'f', 1, 64) + "k"
|
||||
}
|
||||
|
||||
if count < 1_000_000 {
|
||||
return fmt.Sprintf("%dk", count/1_000)
|
||||
return strconv.Itoa(count/1_000) + "k"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
|
||||
return strconv.FormatFloat(float64(count)/1_000_000, 'f', 1, 64) + "m"
|
||||
}
|
||||
|
||||
func dynamicRelativeTimeAttrs(t interface{ Unix() int64 }) template.HTMLAttr {
|
||||
return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
|
||||
}
|
||||
|
||||
@@ -2,22 +2,29 @@
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="dynamic-columns list-gap-24 list-with-separator">
|
||||
{{ range .Groups }}
|
||||
{{- range .Groups }}
|
||||
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.String | safeCSS }}"{{ end }}>
|
||||
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
||||
{{- if ne .Title "" }}
|
||||
<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>
|
||||
{{- end }}
|
||||
<ul class="list list-gap-2">
|
||||
{{ range .Links }}
|
||||
<li class="flex items-center gap-10">
|
||||
{{ if ne "" .Icon.URL }}
|
||||
<div class="bookmarks-icon-container">
|
||||
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||
{{- range .Links }}
|
||||
<li>
|
||||
<div class="flex items-center gap-10">
|
||||
{{- if ne "" .Icon.URL }}
|
||||
<div class="bookmarks-icon-container">
|
||||
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||
</div>
|
||||
{{- end }}
|
||||
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
{{- if .Description }}
|
||||
<div class="margin-bottom-5">{{ .Description }}</div>
|
||||
{{- end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -2,33 +2,6 @@
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="widget-small-content-bounds">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
|
||||
<ul class="list-horizontal-text color-highlight size-h4">
|
||||
<li>Week {{ .Calendar.CurrentWeekNumber }}</li>
|
||||
<li>{{ .Calendar.CurrentYear }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
|
||||
{{ if .StartSunday }}
|
||||
<div class="calendar-day">Su</div>
|
||||
{{ end }}
|
||||
<div class="calendar-day">Mo</div>
|
||||
<div class="calendar-day">Tu</div>
|
||||
<div class="calendar-day">We</div>
|
||||
<div class="calendar-day">Th</div>
|
||||
<div class="calendar-day">Fr</div>
|
||||
<div class="calendar-day">Sa</div>
|
||||
{{ if not .StartSunday }}
|
||||
<div class="calendar-day">Su</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
{{ range .Calendar.Days }}
|
||||
<div class="calendar-day{{ if eq . $.Calendar.CurrentDay }} calendar-day-today{{ end }}">{{ . }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="calendar" data-first-day-of-week="{{ .FirstDay }}"></div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="cursor-help" data-popover-type="text" data-popover-text="Total number of blocked domains from all adlists" data-popover-max-width="200px" data-popover-text-align="center">
|
||||
<div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatViewerCount }}</div>
|
||||
<div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatApproxNumber }}</div>
|
||||
<div class="size-h6">DOMAINS</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -59,8 +59,8 @@
|
||||
{{ if ne $column.Queries $column.Blocked }}
|
||||
<div class="queries"></div>
|
||||
{{ end }}
|
||||
{{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }}
|
||||
<div class="blocked" style="flex-basis: {{ $column.PercentBlocked }}%"></div>
|
||||
{{ if gt $column.PercentBlocked 0 }}
|
||||
<div class="blocked" style="--percent: {{ $column.PercentBlocked }}%"></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -76,7 +76,7 @@
|
||||
<summary class="summary">Top blocked domains</summary>
|
||||
<ul class="list list-gap-4 list-with-transition size-h5">
|
||||
{{ range .Stats.TopBlockedDomains }}
|
||||
<li class="flex justify-between">
|
||||
<li class="flex justify-between gap-10">
|
||||
<div class="text-truncate rtl">{{ .Domain }}</div>
|
||||
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
|
||||
</li>
|
||||
|
||||
@@ -1,64 +1,66 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="dynamic-columns list-gap-20 list-with-separator">
|
||||
{{ range .Containers }}
|
||||
<div class="docker-container flex items-center gap-15">
|
||||
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem">
|
||||
{{- define "widget-content" }}
|
||||
<ul class="dynamic-columns list-gap-20 list-with-separator">
|
||||
{{- range .Containers }}
|
||||
<li class="docker-container flex items-center gap-15">
|
||||
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem" data-popover-max-width="400px" aria-hidden="true">
|
||||
<img class="docker-container-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||
<div data-popover-html>
|
||||
<div class="color-highlight text-truncate block">{{ .Image }}</div>
|
||||
<div>{{ .StateText }}</div>
|
||||
{{ if .Children }}
|
||||
{{- if .Children }}
|
||||
<ul class="list list-gap-4 margin-top-10">
|
||||
{{ range .Children }}
|
||||
{{- range .Children }}
|
||||
<li class="flex gap-7 items-center">
|
||||
<div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
|
||||
<div class="color-highlight">{{ .Title }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
|
||||
<div class="color-highlight">{{ .Name }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
|
||||
</li>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-width-0">
|
||||
{{ if .URL }}
|
||||
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
{{ else }}
|
||||
<div class="color-highlight text-truncate size-title-dynamic">{{ .Title }}</div>
|
||||
{{ end }}
|
||||
{{ if .Description }}
|
||||
<div class="min-width-0 grow">
|
||||
{{- if .URL }}
|
||||
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Name }}</a>
|
||||
{{- else }}
|
||||
<div class="color-highlight text-truncate size-title-dynamic">{{ .Name }}</div>
|
||||
{{- end }}
|
||||
{{- if .Description }}
|
||||
<div class="text-truncate">{{ .Description }}</div>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</div>
|
||||
|
||||
<div class="margin-left-auto shrink-0" data-popover-type="text" data-popover-position="above" data-popover-text="{{ .State }}">
|
||||
<div class="margin-left-auto shrink-0" data-popover-type="text" data-popover-position="above" data-popover-text="{{ .State }}" aria-label="{{ .State }}">
|
||||
{{ template "state-icon" .StateIcon }}
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="text-center">No containers available to show.</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ define "state-icon" }}
|
||||
{{ if eq . "ok" }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<div class="visually-hidden" aria-label="{{ .StateText }}"></div>
|
||||
</li>
|
||||
{{- else }}
|
||||
<div class="text-center">No containers available to show.</div>
|
||||
{{- end }}
|
||||
</ul>
|
||||
{{- end }}
|
||||
|
||||
{{- define "state-icon" }}
|
||||
{{- if eq . "ok" }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ else if eq . "warn" }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
{{- else if eq . "warn" }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ else if eq . "paused" }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
{{- else if eq . "paused" }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M2 10a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm5-2.25A.75.75 0 0 1 7.75 7h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Zm4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ else }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
{{- else }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Glance">
|
||||
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
|
||||
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
|
||||
<meta name="apple-mobile-web-app-title" content="{{ .App.Config.Branding.AppName }}">
|
||||
<meta name="theme-color" content="{{ .App.Config.Theme.BackgroundColorAsHex }}">
|
||||
<link rel="apple-touch-icon" sizes="512x512" href='{{ .App.Config.Branding.AppIconURL }}'>
|
||||
<link rel="manifest" href='{{ .App.VersionedAssetPath "manifest.json" }}'>
|
||||
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
|
||||
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
|
||||
<script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
|
||||
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
|
||||
<script type="module" src='{{ .App.StaticAssetPath "js/main.js" }}'></script>
|
||||
{{ block "document-head-after" . }}{{ end }}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
{{- define "widget-content" }}
|
||||
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{ range .Posts }}
|
||||
{{- range .Posts }}
|
||||
<li>
|
||||
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
|
||||
{{ if $.ShowThumbnails }}
|
||||
{{ if .IsCrosspost }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
{{ else if ne .ThumbnailUrl "" }}
|
||||
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
||||
{{ else if ne "" .TargetUrl }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
{{ else }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{- if $.ShowThumbnails }}
|
||||
{{- if .IsCrosspost }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
{{- else if .ThumbnailUrl }}
|
||||
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
||||
{{- else if .TargetUrl }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
{{- else }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
<div class="grow min-width-0">
|
||||
<a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
{{ if gt (len .Tags) 0 }}
|
||||
{{- if .Tags }}
|
||||
<div class="inline-block forum-post-tags-container">
|
||||
<ul class="attachments">
|
||||
{{ range .Tags }}
|
||||
<li>{{ . }}</li>
|
||||
{{ end }}
|
||||
{{- range .Tags }}
|
||||
<li>{{ . }}</li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
||||
<ul class="list-horizontal-text">
|
||||
{{- end }}
|
||||
<ul class="list-horizontal-text flex-nowrap text-compact">
|
||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||
<li>{{ .Score | formatNumber }} points</li>
|
||||
<li>{{ .CommentCount | formatNumber }} comments</li>
|
||||
{{ if ne "" .TargetUrl }}
|
||||
<li class="shrink-0">{{ .Score | formatApproxNumber }} points</li>
|
||||
<li class="shrink-0{{ if .TargetUrl }} forum-post-autohide{{ end }}">{{ .CommentCount | formatApproxNumber }} comments</li>
|
||||
{{- if .TargetUrl }}
|
||||
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -4,17 +4,18 @@
|
||||
|
||||
{{ 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 }}"{{ if ne "" .TitleURL }} data-title-url="{{ .TitleURL }}"{{ end }}>{{ $widget.Title }}</button>
|
||||
{{ end }}
|
||||
<div class="widget-header gap-20" role="tablist">
|
||||
{{- range $i, $widget := .Widgets }}
|
||||
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}"{{ if ne "" .TitleURL }} data-title-url="{{ .TitleURL }}"{{ end }} aria-selected="{{ if eq $i 0 }}true{{ else }}false{{ end }}" arial-level="2" role="tab" aria-controls="widget-{{ .GetID }}-tabpanel-{{ $i }}" id="widget-{{ .GetID }}-tab-{{ $i }}">{{ $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 }}
|
||||
{{- range $i, $widget := .Widgets }}
|
||||
<div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}" id="widget-{{ .GetID }}-tabpanel-{{ $i }}" role="tabpanel" aria-labelledby="widget-{{ .GetID }}-tab-{{ $i }}" aria-hidden="{{ if eq $i 0 }}false{{ else }}true{{ end }}">
|
||||
{{- .Render -}}
|
||||
</div>
|
||||
{{- end }}
|
||||
</div>
|
||||
|
||||
{{ end }}
|
||||
|
||||
15
internal/glance/templates/manifest.json
Normal file
15
internal/glance/templates/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "{{ .App.Config.Branding.AppName }}",
|
||||
"display": "standalone",
|
||||
"background_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
|
||||
"theme_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "{{ .App.Config.Branding.AppIconURL }}",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,13 +11,13 @@
|
||||
|
||||
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
||||
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
|
||||
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
||||
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-linejoin="round" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<div class="market-values shrink-0">
|
||||
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
||||
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
|
||||
<div class="text-right">{{ .Currency }}{{ .Price | formatPriceWithPrecision .PriceHint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
{{ if .Icon.URL }}
|
||||
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||
{{ end }}
|
||||
<div class="min-width-0">
|
||||
<div class="grow min-width-0">
|
||||
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
{{ if not .Status.Error }}
|
||||
|
||||
34
internal/glance/templates/old-calendar.html
Normal file
34
internal/glance/templates/old-calendar.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="widget-small-content-bounds">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
|
||||
<ul class="list-horizontal-text color-highlight size-h4">
|
||||
<li>Week {{ .Calendar.CurrentWeekNumber }}</li>
|
||||
<li>{{ .Calendar.CurrentYear }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
|
||||
{{ if .StartSunday }}
|
||||
<div class="old-calendar-day">Su</div>
|
||||
{{ end }}
|
||||
<div class="old-calendar-day">Mo</div>
|
||||
<div class="old-calendar-day">Tu</div>
|
||||
<div class="old-calendar-day">We</div>
|
||||
<div class="old-calendar-day">Th</div>
|
||||
<div class="old-calendar-day">Fr</div>
|
||||
<div class="old-calendar-day">Sa</div>
|
||||
{{ if not .StartSunday }}
|
||||
<div class="old-calendar-day">Su</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
{{ range .Calendar.Days }}
|
||||
<div class="old-calendar-day{{ if eq . $.Calendar.CurrentDay }} old-calendar-day-today{{ end }}">{{ . }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -16,13 +16,13 @@
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }}
|
||||
{{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }}
|
||||
|
||||
{{ define "document-head-after" }}
|
||||
{{ .App.ParsedThemeStyle }}
|
||||
|
||||
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
||||
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
|
||||
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.CreatedAt.Unix }}">
|
||||
{{ end }}
|
||||
|
||||
{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
{{ define "navigation-links" }}
|
||||
{{ range .App.Config.Pages }}
|
||||
<a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
|
||||
<a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}"{{ if eq .Slug $.Page.Slug }} aria-current="page"{{ end }}>{{ .Title }}</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -54,13 +54,13 @@
|
||||
{{ define "document-body" }}
|
||||
<div class="flex flex-column body-content">
|
||||
{{ if not .Page.HideDesktopNavigation }}
|
||||
<div class="header-container content-bounds">
|
||||
<div class="header-container content-bounds{{ if ne "" .Page.DesktopNavigationWidth }} content-bounds-{{ .Page.DesktopNavigationWidth }} {{ end }}">
|
||||
<div class="header flex padding-inline-widget widget-content-frame">
|
||||
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
||||
<div class="logo">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
|
||||
<div class="nav flex grow">
|
||||
<div class="logo" aria-hidden="true">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
|
||||
<nav class="nav flex grow">
|
||||
{{ template "navigation-links" . }}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="flex items-center">
|
||||
{{ template "theme-switcher" . }}
|
||||
</div>
|
||||
@@ -86,18 +86,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-bounds grow">
|
||||
<div class="page" id="page">
|
||||
<div class="content-bounds grow{{ if ne "" .Page.Width }} content-bounds-{{ .Page.Width }} {{ end }}">
|
||||
<main class="page" id="page" aria-live="polite" aria-busy="true">
|
||||
<h1 class="visually-hidden">{{ .Page.Title }}</h1>
|
||||
<div class="page-content" id="page-content"></div>
|
||||
<div class="page-loading-container">
|
||||
<!-- TODO: add a bigger/better loading indicator -->
|
||||
<div class="loading-icon"></div>
|
||||
<div class="visually-hidden">Loading</div>
|
||||
<div class="loading-icon" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{{ if not .App.Config.Branding.HideFooter }}
|
||||
<div class="footer flex items-center flex-column">
|
||||
<footer class="footer flex items-center flex-column">
|
||||
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
||||
<div>
|
||||
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||
@@ -105,7 +107,7 @@
|
||||
{{ else }}
|
||||
{{ .App.Config.Branding.CustomFooter }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</footer>
|
||||
{{ end }}
|
||||
|
||||
<div class="mobile-navigation-offset"></div>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<a href="{{ .DiscussionUrl }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text margin-top-7">
|
||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||
<li>{{ .Score | formatNumber }} points</li>
|
||||
<li>{{ .Score | formatApproxNumber }} points</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<a href="{{ .DiscussionUrl }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text margin-top-7">
|
||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||
<li>{{ .Score | formatNumber }} points</li>
|
||||
<li>{{ .Score | formatApproxNumber }} points</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}">
|
||||
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}" data-target="{{ .Target }}">
|
||||
<div class="search-bangs">
|
||||
{{ range .Bangs }}
|
||||
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
|
||||
|
||||
140
internal/glance/templates/server-stats.html
Normal file
140
internal/glance/templates/server-stats.html
Normal file
@@ -0,0 +1,140 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{- define "widget-content" }}
|
||||
{{- range .Servers }}
|
||||
<div class="server">
|
||||
<div class="server-info">
|
||||
<div class="server-details">
|
||||
<div class="server-name color-highlight size-h3">{{ if .Name }}{{ .Name }}{{ else }}{{ .Info.Hostname }}{{ end }}</div>
|
||||
<div>
|
||||
{{- if .IsReachable }}
|
||||
{{ if .Info.HostInfoIsAvailable }}<span {{ dynamicRelativeTimeAttrs .Info.BootTime }}></span>{{ else }}unknown{{ end }} uptime
|
||||
{{- else }}
|
||||
unreachable
|
||||
{{- end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0"{{ if .IsReachable }} data-popover-type="html" data-popover-margin="0.2rem" data-popover-max-width="400px"{{ end }}>
|
||||
{{- if .IsReachable }}
|
||||
<div data-popover-html>
|
||||
<div class="size-h5 text-compact">PLATFORM</div>
|
||||
<div class="color-highlight">{{ if .Info.HostInfoIsAvailable }}{{ .Info.Platform }}{{ else }}Unknown{{ end }}</div>
|
||||
</div>
|
||||
{{- end }}
|
||||
<svg class="server-icon" stroke="var(--color-{{ if .IsReachable }}positive{{ else }}negative{{ end }})" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 17.25v-.228a4.5 4.5 0 0 0-.12-1.03l-2.268-9.64a3.375 3.375 0 0 0-3.285-2.602H7.923a3.375 3.375 0 0 0-3.285 2.602l-2.268 9.64a4.5 4.5 0 0 0-.12 1.03v.228m19.5 0a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3m19.5 0a3 3 0 0 0-3-3H5.25a3 3 0 0 0-3 3m16.5 0h.008v.008h-.008v-.008Zm-3 0h.008v.008h-.008v-.008Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-stats">
|
||||
<div class="flex-1{{ if not .Info.CPU.LoadIsAvailable }} server-stat-unavailable{{ end }}">
|
||||
<div class="flex items-end size-h5">
|
||||
<div>CPU</div>
|
||||
{{- if and .Info.CPU.TemperatureIsAvailable (ge .Info.CPU.TemperatureC 80) }}
|
||||
<svg class="server-spicy-cpu-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" >
|
||||
<path fill-rule="evenodd" d="M8.074.945A4.993 4.993 0 0 0 6 5v.032c.004.6.114 1.176.311 1.709.16.428-.204.91-.61.7a5.023 5.023 0 0 1-1.868-1.677c-.202-.304-.648-.363-.848-.058a6 6 0 1 0 8.017-1.901l-.004-.007a4.98 4.98 0 0 1-2.18-2.574c-.116-.31-.477-.472-.744-.28Zm.78 6.178a3.001 3.001 0 1 1-3.473 4.341c-.205-.365.215-.694.62-.59a4.008 4.008 0 0 0 1.873.03c.288-.065.413-.386.321-.666A3.997 3.997 0 0 1 8 8.999c0-.585.126-1.14.351-1.641a.42.42 0 0 1 .503-.235Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{- end }}
|
||||
<div class="color-highlight margin-left-auto text-very-compact">{{ if .Info.CPU.LoadIsAvailable }}{{ .Info.CPU.Load1Percent }} <span class="color-base">%</span>{{ else }}n/a{{ end }}</div>
|
||||
</div>
|
||||
<div{{ if .Info.CPU.LoadIsAvailable }} data-popover-type="html"{{ end }}>
|
||||
{{- if .Info.CPU.LoadIsAvailable }}
|
||||
<div data-popover-html>
|
||||
<div class="flex">
|
||||
<div class="size-h5">1M AVG</div>
|
||||
<div class="value-separator"></div>
|
||||
<div class="color-highlight text-very-compact">{{ .Info.CPU.Load1Percent }} <span class="color-base size-h5">%</span></div>
|
||||
</div>
|
||||
<div class="flex margin-top-3">
|
||||
<div class="size-h5">15M AVG</div>
|
||||
<div class="value-separator"></div>
|
||||
<div class="color-highlight text-very-compact">{{ .Info.CPU.Load15Percent }} <span class="color-base size-h5">%</span></div>
|
||||
</div>
|
||||
{{- if .Info.CPU.TemperatureIsAvailable }}
|
||||
<div class="flex margin-top-3">
|
||||
<div class="size-h5">TEMP C</div>
|
||||
<div class="value-separator"></div>
|
||||
<div class="color-highlight text-very-compact">{{ .Info.CPU.TemperatureC }} <span class="color-base size-h5">°</span></div>
|
||||
</div>
|
||||
{{- end }}
|
||||
</div>
|
||||
{{- end }}
|
||||
<div class="progress-bar progress-bar-combined">
|
||||
{{- if .Info.CPU.LoadIsAvailable }}
|
||||
<div class="progress-value{{ if ge .Info.CPU.Load1Percent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Info.CPU.Load1Percent }}"></div>
|
||||
<div class="progress-value{{ if ge .Info.CPU.Load15Percent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Info.CPU.Load15Percent }}"></div>
|
||||
{{- end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1{{ if not .Info.Memory.IsAvailable }} server-stat-unavailable{{ end }}">
|
||||
<div class="flex justify-between items-end size-h5">
|
||||
<div>RAM</div>
|
||||
<div class="color-highlight text-very-compact">{{ if .Info.Memory.IsAvailable }}{{ .Info.Memory.UsedPercent }} <span class="color-base">%</span>{{ else }}n/a{{ end }}</div>
|
||||
</div>
|
||||
<div{{ if .Info.Memory.IsAvailable }} data-popover-type="html"{{ end }}>
|
||||
{{- if .Info.Memory.IsAvailable }}
|
||||
<div data-popover-html>
|
||||
<div class="flex">
|
||||
<div class="size-h5">RAM</div>
|
||||
<div class="value-separator"></div>
|
||||
<div class="color-highlight text-very-compact">
|
||||
{{ .Info.Memory.UsedMB | formatServerMegabytes }} <span class="color-base size-h5">/</span> {{ .Info.Memory.TotalMB | formatServerMegabytes }}
|
||||
</div>
|
||||
</div>
|
||||
{{- if and (not .HideSwap) .Info.Memory.SwapIsAvailable }}
|
||||
<div class="flex margin-top-3">
|
||||
<div class="size-h5">SWAP</div>
|
||||
<div class="value-separator"></div>
|
||||
<div class="color-highlight text-very-compact">
|
||||
{{ .Info.Memory.SwapUsedMB | formatServerMegabytes }} <span class="color-base size-h5">/</span> {{ .Info.Memory.SwapTotalMB | formatServerMegabytes }}
|
||||
</div>
|
||||
</div>
|
||||
{{- end }}
|
||||
</div>
|
||||
{{- end }}
|
||||
<div class="progress-bar progress-bar-combined">
|
||||
{{- if .Info.Memory.IsAvailable }}
|
||||
<div class="progress-value{{ if ge .Info.Memory.UsedPercent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Info.Memory.UsedPercent }}"></div>
|
||||
{{- if and (not .HideSwap) .Info.Memory.SwapIsAvailable }}
|
||||
<div class="progress-value{{ if ge .Info.Memory.SwapUsedPercent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Info.Memory.SwapUsedPercent }}"></div>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1{{ if not .Info.Mountpoints }} server-stat-unavailable{{ end }}">
|
||||
<div class="flex justify-between items-end size-h5">
|
||||
<div>DISK</div>
|
||||
<div class="color-highlight text-very-compact">{{ if .Info.Mountpoints }}{{ (index .Info.Mountpoints 0).UsedPercent }} <span class="color-base">%</span>{{ else }}n/a{{ end }}</div>
|
||||
</div>
|
||||
<div{{ if .Info.Mountpoints }} data-popover-type="html"{{ end }}>
|
||||
{{- if .Info.Mountpoints }}
|
||||
<div data-popover-html>
|
||||
<ul class="list list-gap-2">
|
||||
{{- range .Info.Mountpoints }}
|
||||
<li class="flex">
|
||||
<div class="size-h5">{{ if .Name }}{{ .Name }}{{ else }}{{ .Path }}{{ end }}</div>
|
||||
<div class="value-separator"></div>
|
||||
<div class="color-highlight text-very-compact">
|
||||
{{ .UsedMB | formatServerMegabytes }} <span class="color-base size-h5">/</span> {{ .TotalMB | formatServerMegabytes }}
|
||||
</div>
|
||||
</li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{- end }}
|
||||
<div class="progress-bar progress-bar-combined">
|
||||
{{- if .Info.Mountpoints }}
|
||||
<div class="progress-value{{ if ge ((index .Info.Mountpoints 0).UsedPercent) 85 }} progress-value-notice{{ end }}" style="--percent: {{ (index .Info.Mountpoints 0).UsedPercent }}"></div>
|
||||
{{- if ge (len .Info.Mountpoints) 2 }}
|
||||
<div class="progress-value{{ if ge ((index .Info.Mountpoints 1).UsedPercent) 85 }} progress-value-notice{{ end }}" style="--percent: {{ (index .Info.Mountpoints 1).UsedPercent }}"></div>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -31,7 +31,7 @@
|
||||
{{ end }}
|
||||
<ul class="list-horizontal-text">
|
||||
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
|
||||
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||
<li>{{ .ViewersCount | formatApproxNumber }} viewers</li>
|
||||
</ul>
|
||||
{{ else }}
|
||||
<div>Offline</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="min-width-0">
|
||||
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||
<li>{{ .ViewersCount | formatApproxNumber }} viewers</li>
|
||||
{{ if .IsNew }}
|
||||
<li class="color-primary">NEW</li>
|
||||
{{ end }}
|
||||
|
||||
@@ -24,15 +24,13 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- TODO: update - add links -->
|
||||
|
||||
<div class="content-bounds color-highlight">
|
||||
<div class="content-bounds color-paragraph">
|
||||
<p class="uppercase size-h5 color-negative padding-inline-widget">UPDATE NOTICE</p>
|
||||
<div class="widget-content-frame margin-top-10 padding-widget">
|
||||
<p class="comfy-line-height">
|
||||
The default location of glance.yml in the Docker image has
|
||||
changed since v0.7.0, please see the <a class="color-primary" href="#">migration guide</a>
|
||||
for instructions or visit the <a class="color-primary" href="#">release notes</a>
|
||||
changed since v0.7.0, please see the <a class="color-primary" href="https://github.com/glanceapp/glance/blob/main/docs/v0.7.0-upgrade.md" target="_blank">migration guide</a>
|
||||
for instructions or visit the <a class="color-primary" href="https://github.com/glanceapp/glance/releases/tag/v0.7.0" target="_blank">release notes</a>
|
||||
to find out more about why this change was necessary. Sorry for the inconvenience.
|
||||
</p>
|
||||
|
||||
|
||||
20
internal/glance/templates/videos-vertical-list.html
Normal file
20
internal/glance/templates/videos-vertical-list.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{- define "widget-content" }}
|
||||
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{- range .Videos }}
|
||||
<li class="flex thumbnail-parent gap-10 items-center">
|
||||
<img class="video-horizontal-list-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||
<div class="min-width-0">
|
||||
<a class="block text-truncate color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text flex-nowrap">
|
||||
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||
<li class="min-width-0">
|
||||
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
{{- end }}
|
||||
@@ -1,18 +1,34 @@
|
||||
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
|
||||
{{ if not .HideHeader}}
|
||||
{{- if not .HideHeader}}
|
||||
<div class="widget-header">
|
||||
{{ if ne "" .TitleURL }}<a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
|
||||
{{ if and .Error .ContentAvailable }}
|
||||
{{- if ne "" .TitleURL }}
|
||||
<h2><a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a></h2>
|
||||
{{- else }}
|
||||
<h2 class="uppercase">{{ .Title }}</h2>
|
||||
{{- end }}
|
||||
{{- if .IsWIP }}
|
||||
<div data-popover-type="html" data-popover-position="above">
|
||||
<div data-popover-html>
|
||||
<p class="size-h5">WORK IN PROGRESS</p>
|
||||
<p class="margin-block-10 color-paragraph">This widget is still in development, certain features may not work as expected or may change drastically.</p>
|
||||
<a class="color-primary visited-indicator" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
|
||||
</div>
|
||||
<svg class="widget-beta-icon cursor-help" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M19 5.5a4.5 4.5 0 0 1-4.791 4.49c-.873-.055-1.808.128-2.368.8l-6.024 7.23a2.724 2.724 0 1 1-3.837-3.837L9.21 8.16c.672-.56.855-1.495.8-2.368a4.5 4.5 0 0 1 5.873-4.575c.324.105.39.51.15.752L13.34 4.66a.455.455 0 0 0-.11.494 3.01 3.01 0 0 0 1.617 1.617c.17.07.363.02.493-.111l2.692-2.692c.241-.241.647-.174.752.15.14.435.216.9.216 1.382ZM4 17a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
{{- end }}
|
||||
{{- if and .Error .ContentAvailable }}
|
||||
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
|
||||
{{ else if .Notice }}
|
||||
{{- else if .Notice }}
|
||||
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
<div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
|
||||
{{ if .ContentAvailable }}
|
||||
{{ block "widget-content" . }}{{ end }}
|
||||
{{ else }}
|
||||
{{- if .ContentAvailable }}
|
||||
{{ block "widget-content" . }}{{ end }}
|
||||
{{- else }}
|
||||
<div class="widget-error-header">
|
||||
<div class="color-negative size-h3">ERROR</div>
|
||||
<svg class="widget-error-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
@@ -20,6 +36,6 @@
|
||||
</svg>
|
||||
</div>
|
||||
<p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
|
||||
{{ end}}
|
||||
{{- end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -119,14 +120,6 @@ func parseRFC3339Time(t string) time.Time {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func boolToString(b bool, trueValue, falseValue string) string {
|
||||
if b {
|
||||
return trueValue
|
||||
}
|
||||
|
||||
return falseValue
|
||||
}
|
||||
|
||||
func normalizeVersionFormat(version string) string {
|
||||
version = strings.ToLower(strings.TrimSpace(version))
|
||||
|
||||
@@ -178,3 +171,70 @@ func itemAtIndexOrDefault[T any](items []T, index int, def T) T {
|
||||
|
||||
return items[index]
|
||||
}
|
||||
|
||||
func ternary[T any](condition bool, a, b T) T {
|
||||
if condition {
|
||||
return a
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// Having compile time errors about unused variables is cool and all, but I don't want to
|
||||
// have to constantly comment out my code while I'm working on it and testing things out
|
||||
func ItsUsedTrustMeBro(...any) {}
|
||||
|
||||
func hslToHex(h, s, l float64) string {
|
||||
s /= 100.0
|
||||
l /= 100.0
|
||||
|
||||
var r, g, b float64
|
||||
|
||||
if s == 0 {
|
||||
r, g, b = l, l, l
|
||||
} else {
|
||||
hueToRgb := func(p, q, t float64) float64 {
|
||||
if t < 0 {
|
||||
t += 1
|
||||
}
|
||||
if t > 1 {
|
||||
t -= 1
|
||||
}
|
||||
if t < 1.0/6.0 {
|
||||
return p + (q-p)*6.0*t
|
||||
}
|
||||
if t < 1.0/2.0 {
|
||||
return q
|
||||
}
|
||||
if t < 2.0/3.0 {
|
||||
return p + (q-p)*(2.0/3.0-t)*6.0
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
q := 0.0
|
||||
if l < 0.5 {
|
||||
q = l * (1 + s)
|
||||
} else {
|
||||
q = l + s - l*s
|
||||
}
|
||||
|
||||
p := 2*l - q
|
||||
|
||||
h /= 360.0
|
||||
|
||||
r = hueToRgb(p, q, h+1.0/3.0)
|
||||
g = hueToRgb(p, q, h)
|
||||
b = hueToRgb(p, q, h-1.0/3.0)
|
||||
}
|
||||
|
||||
ir := int(math.Round(r * 255.0))
|
||||
ig := int(math.Round(g * 255.0))
|
||||
ib := int(math.Round(b * 255.0))
|
||||
|
||||
ir = int(math.Max(0, math.Min(255, float64(ir))))
|
||||
ig = int(math.Max(0, math.Min(255, float64(ig))))
|
||||
ib = int(math.Max(0, math.Min(255, float64(ib))))
|
||||
|
||||
return fmt.Sprintf("#%02x%02x%02x", ir, ig, ib)
|
||||
}
|
||||
|
||||
@@ -14,19 +14,22 @@ type bookmarksWidget struct {
|
||||
Color *hslColorField `yaml:"color"`
|
||||
SameTab bool `yaml:"same-tab"`
|
||||
HideArrow bool `yaml:"hide-arrow"`
|
||||
Target string `yaml:"target"`
|
||||
Links []struct {
|
||||
Title string `yaml:"title"`
|
||||
URL string `yaml:"url"`
|
||||
Icon customIconField `yaml:"icon"`
|
||||
Title string `yaml:"title"`
|
||||
URL string `yaml:"url"`
|
||||
Description string `yaml:"description"`
|
||||
Icon customIconField `yaml:"icon"`
|
||||
// we need a pointer to bool to know whether a value was provided,
|
||||
// however there's no way to dereference a pointer in a template so
|
||||
// {{ if not .SameTab }} would return true for any non-nil pointer
|
||||
// which leaves us with no way of checking if the value is true or
|
||||
// false, hence the duplicated fields below
|
||||
SameTabRaw *bool `yaml:"same-tab"`
|
||||
SameTab bool `yaml:"-"`
|
||||
HideArrowRaw *bool `yaml:"hide-arrow"`
|
||||
HideArrow bool `yaml:"-"`
|
||||
SameTabRaw *bool `yaml:"same-tab"`
|
||||
SameTab bool `yaml:"-"`
|
||||
HideArrowRaw *bool `yaml:"hide-arrow"`
|
||||
HideArrow bool `yaml:"-"`
|
||||
Target string `yaml:"target"`
|
||||
} `yaml:"links"`
|
||||
} `yaml:"groups"`
|
||||
}
|
||||
@@ -49,6 +52,18 @@ func (widget *bookmarksWidget) initialize() error {
|
||||
} else {
|
||||
link.HideArrow = *link.HideArrowRaw
|
||||
}
|
||||
|
||||
if link.Target == "" {
|
||||
if group.Target != "" {
|
||||
link.Target = group.Target
|
||||
} else {
|
||||
if link.SameTab {
|
||||
link.Target = ""
|
||||
} else {
|
||||
link.Target = "_blank"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,86 +1,45 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
var calendarWidgetTemplate = mustParseTemplate("calendar.html", "widget-base.html")
|
||||
|
||||
var calendarWeekdaysToInt = map[string]time.Weekday{
|
||||
"sunday": time.Sunday,
|
||||
"monday": time.Monday,
|
||||
"tuesday": time.Tuesday,
|
||||
"wednesday": time.Wednesday,
|
||||
"thursday": time.Thursday,
|
||||
"friday": time.Friday,
|
||||
"saturday": time.Saturday,
|
||||
}
|
||||
|
||||
type calendarWidget struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Calendar *calendar
|
||||
StartSunday bool `yaml:"start-sunday"`
|
||||
widgetBase `yaml:",inline"`
|
||||
FirstDayOfWeek string `yaml:"first-day-of-week"`
|
||||
FirstDay int `yaml:"-"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *calendarWidget) initialize() error {
|
||||
widget.withTitle("Calendar").withCacheOnTheHour()
|
||||
widget.withTitle("Calendar").withError(nil)
|
||||
|
||||
if widget.FirstDayOfWeek == "" {
|
||||
widget.FirstDayOfWeek = "monday"
|
||||
} else if _, ok := calendarWeekdaysToInt[widget.FirstDayOfWeek]; !ok {
|
||||
return errors.New("invalid first day of week")
|
||||
}
|
||||
|
||||
widget.FirstDay = int(calendarWeekdaysToInt[widget.FirstDayOfWeek])
|
||||
widget.cachedHTML = widget.renderTemplate(widget, calendarWidgetTemplate)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *calendarWidget) update(ctx context.Context) {
|
||||
widget.Calendar = newCalendar(time.Now(), widget.StartSunday)
|
||||
widget.withError(nil).scheduleNextUpdate()
|
||||
}
|
||||
|
||||
func (widget *calendarWidget) Render() template.HTML {
|
||||
return widget.renderTemplate(widget, calendarWidgetTemplate)
|
||||
}
|
||||
|
||||
type calendar struct {
|
||||
CurrentDay int
|
||||
CurrentWeekNumber int
|
||||
CurrentMonthName string
|
||||
CurrentYear int
|
||||
Days []int
|
||||
}
|
||||
|
||||
// TODO: very inflexible, refactor to allow more customizability
|
||||
// TODO: allow changing between showing the previous and next week and the entire month
|
||||
func newCalendar(now time.Time, startSunday bool) *calendar {
|
||||
year, week := now.ISOWeek()
|
||||
weekday := now.Weekday()
|
||||
if !startSunday {
|
||||
weekday = (weekday + 6) % 7 // Shift Monday to 0
|
||||
}
|
||||
|
||||
currentMonthDays := daysInMonth(now.Month(), year)
|
||||
|
||||
var previousMonthDays int
|
||||
|
||||
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
|
||||
previousMonthDays = daysInMonth(12, year-1)
|
||||
} else {
|
||||
previousMonthDays = daysInMonth(previousMonthNumber, year)
|
||||
}
|
||||
|
||||
startDaysFrom := now.Day() - int(weekday) - 7
|
||||
|
||||
days := make([]int, 21)
|
||||
|
||||
for i := 0; i < 21; i++ {
|
||||
day := startDaysFrom + i
|
||||
|
||||
if day < 1 {
|
||||
day = previousMonthDays + day
|
||||
} else if day > currentMonthDays {
|
||||
day = day - currentMonthDays
|
||||
}
|
||||
|
||||
days[i] = day
|
||||
}
|
||||
|
||||
return &calendar{
|
||||
CurrentDay: now.Day(),
|
||||
CurrentWeekNumber: week,
|
||||
CurrentMonthName: now.Month().String(),
|
||||
CurrentYear: year,
|
||||
Days: days,
|
||||
}
|
||||
}
|
||||
|
||||
func daysInMonth(m time.Month, year int) int {
|
||||
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
return widget.cachedHTML
|
||||
}
|
||||
|
||||
@@ -3,12 +3,19 @@ package glance
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -16,22 +23,41 @@ import (
|
||||
|
||||
var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
|
||||
|
||||
// Needs to be exported for the YAML unmarshaler to work
|
||||
type CustomAPIRequest struct {
|
||||
URL string `yaml:"url"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
Parameters queryParametersField `yaml:"parameters"`
|
||||
Method string `yaml:"method"`
|
||||
BodyType string `yaml:"body-type"`
|
||||
Body any `yaml:"body"`
|
||||
SkipJSONValidation bool `yaml:"skip-json-validation"`
|
||||
bodyReader io.ReadSeeker `yaml:"-"`
|
||||
httpRequest *http.Request `yaml:"-"`
|
||||
}
|
||||
|
||||
type customAPIWidget struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
URL string `yaml:"url"`
|
||||
Template string `yaml:"template"`
|
||||
Frameless bool `yaml:"frameless"`
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
APIRequest *http.Request `yaml:"-"`
|
||||
compiledTemplate *template.Template `yaml:"-"`
|
||||
CompiledHTML template.HTML `yaml:"-"`
|
||||
widgetBase `yaml:",inline"`
|
||||
*CustomAPIRequest `yaml:",inline"` // the primary request
|
||||
Subrequests map[string]*CustomAPIRequest `yaml:"subrequests"`
|
||||
Template string `yaml:"template"`
|
||||
Frameless bool `yaml:"frameless"`
|
||||
compiledTemplate *template.Template `yaml:"-"`
|
||||
CompiledHTML template.HTML `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *customAPIWidget) initialize() error {
|
||||
widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
|
||||
|
||||
if widget.URL == "" {
|
||||
return errors.New("URL is required")
|
||||
if err := widget.CustomAPIRequest.initialize(); err != nil {
|
||||
return fmt.Errorf("initializing primary request: %v", err)
|
||||
}
|
||||
|
||||
for key := range widget.Subrequests {
|
||||
if err := widget.Subrequests[key].initialize(); err != nil {
|
||||
return fmt.Errorf("initializing subrequest %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
if widget.Template == "" {
|
||||
@@ -45,22 +71,11 @@ func (widget *customAPIWidget) initialize() error {
|
||||
|
||||
widget.compiledTemplate = compiledTemplate
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, widget.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for key, value := range widget.Headers {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
|
||||
widget.APIRequest = req
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *customAPIWidget) update(ctx context.Context) {
|
||||
compiledHTML, err := fetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
|
||||
compiledHTML, err := fetchAndParseCustomAPI(widget.CustomAPIRequest, widget.Subrequests, widget.compiledTemplate)
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
@@ -72,39 +87,212 @@ func (widget *customAPIWidget) Render() template.HTML {
|
||||
return widget.renderTemplate(widget, customAPIWidgetTemplate)
|
||||
}
|
||||
|
||||
func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
|
||||
emptyBody := template.HTML("")
|
||||
func (req *CustomAPIRequest) initialize() error {
|
||||
if req.URL == "" {
|
||||
return errors.New("URL is required")
|
||||
}
|
||||
|
||||
resp, err := defaultHTTPClient.Do(req)
|
||||
if req.Body != nil {
|
||||
if req.Method == "" {
|
||||
req.Method = http.MethodPost
|
||||
}
|
||||
|
||||
if req.BodyType == "" {
|
||||
req.BodyType = "json"
|
||||
}
|
||||
|
||||
if req.BodyType != "json" && req.BodyType != "string" {
|
||||
return errors.New("invalid body type, must be either 'json' or 'string'")
|
||||
}
|
||||
|
||||
switch req.BodyType {
|
||||
case "json":
|
||||
encoded, err := json.Marshal(req.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling body: %v", err)
|
||||
}
|
||||
|
||||
req.bodyReader = bytes.NewReader(encoded)
|
||||
case "string":
|
||||
bodyAsString, ok := req.Body.(string)
|
||||
if !ok {
|
||||
return errors.New("body must be a string when body-type is 'string'")
|
||||
}
|
||||
|
||||
req.bodyReader = strings.NewReader(bodyAsString)
|
||||
}
|
||||
|
||||
} else if req.Method == "" {
|
||||
req.Method = http.MethodGet
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(strings.ToUpper(req.Method), req.URL, req.bodyReader)
|
||||
if err != nil {
|
||||
return emptyBody, err
|
||||
return err
|
||||
}
|
||||
|
||||
if len(req.Parameters) > 0 {
|
||||
httpReq.URL.RawQuery = req.Parameters.toQueryString()
|
||||
}
|
||||
|
||||
if req.BodyType == "json" {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
for key, value := range req.Headers {
|
||||
httpReq.Header.Add(key, value)
|
||||
}
|
||||
|
||||
req.httpRequest = httpReq
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type customAPIResponseData struct {
|
||||
JSON decoratedGJSONResult
|
||||
Response *http.Response
|
||||
}
|
||||
|
||||
type customAPITemplateData struct {
|
||||
*customAPIResponseData
|
||||
subrequests map[string]*customAPIResponseData
|
||||
}
|
||||
|
||||
func (data *customAPITemplateData) JSONLines() []decoratedGJSONResult {
|
||||
result := make([]decoratedGJSONResult, 0, 5)
|
||||
|
||||
gjson.ForEachLine(data.JSON.Raw, func(line gjson.Result) bool {
|
||||
result = append(result, decoratedGJSONResult{line})
|
||||
return true
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData {
|
||||
req, exists := data.subrequests[key]
|
||||
if !exists {
|
||||
// We have to panic here since there's nothing sensible we can return and the
|
||||
// lack of an error would cause requested data to return zero values which
|
||||
// would be confusing from the user's perspective. Go's template module
|
||||
// handles recovering from panics and will return the panic message as an
|
||||
// error during template execution.
|
||||
panic(fmt.Sprintf("subrequest with key %q has not been defined", key))
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) {
|
||||
if req.bodyReader != nil {
|
||||
req.bodyReader.Seek(0, io.SeekStart)
|
||||
}
|
||||
|
||||
client := ternary(req.AllowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
|
||||
resp, err := client.Do(req.httpRequest.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return emptyBody, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body := string(bodyBytes)
|
||||
body := strings.TrimSpace(string(bodyBytes))
|
||||
|
||||
if !gjson.Valid(body) {
|
||||
truncatedBody, isTruncated := limitStringLength(body, 100)
|
||||
if isTruncated {
|
||||
truncatedBody += "... <truncated>"
|
||||
if !req.SkipJSONValidation && body != "" && !gjson.Valid(body) {
|
||||
if 200 <= resp.StatusCode && resp.StatusCode < 300 {
|
||||
truncatedBody, isTruncated := limitStringLength(body, 100)
|
||||
if isTruncated {
|
||||
truncatedBody += "... <truncated>"
|
||||
}
|
||||
|
||||
slog.Error("Invalid response JSON in custom API widget", "url", req.httpRequest.URL.String(), "body", truncatedBody)
|
||||
return nil, errors.New("invalid response JSON")
|
||||
}
|
||||
|
||||
slog.Error("Invalid response JSON in custom API widget", "url", req.URL.String(), "body", truncatedBody)
|
||||
return emptyBody, errors.New("invalid response JSON")
|
||||
return nil, errors.New(fmt.Sprintf("%d %s", resp.StatusCode, http.StatusText(resp.StatusCode)))
|
||||
|
||||
}
|
||||
|
||||
var templateBuffer bytes.Buffer
|
||||
|
||||
data := CustomAPITemplateData{
|
||||
data := &customAPIResponseData{
|
||||
JSON: decoratedGJSONResult{gjson.Parse(body)},
|
||||
Response: resp,
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func fetchAndParseCustomAPI(
|
||||
primaryReq *CustomAPIRequest,
|
||||
subReqs map[string]*CustomAPIRequest,
|
||||
tmpl *template.Template,
|
||||
) (template.HTML, error) {
|
||||
var primaryData *customAPIResponseData
|
||||
subData := make(map[string]*customAPIResponseData, len(subReqs))
|
||||
var err error
|
||||
|
||||
if len(subReqs) == 0 {
|
||||
// If there are no subrequests, we can fetch the primary request in a much simpler way
|
||||
primaryData, err = fetchCustomAPIRequest(context.Background(), primaryReq)
|
||||
} else {
|
||||
// If there are subrequests, we need to fetch them concurrently
|
||||
// and cancel all requests if any of them fail. There's probably
|
||||
// a more elegant way to do this, but this works for now.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex // protects subData and err
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var localErr error
|
||||
primaryData, localErr = fetchCustomAPIRequest(ctx, primaryReq)
|
||||
mu.Lock()
|
||||
if localErr != nil && err == nil {
|
||||
err = localErr
|
||||
cancel()
|
||||
}
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
for key, req := range subReqs {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var localErr error
|
||||
var data *customAPIResponseData
|
||||
data, localErr = fetchCustomAPIRequest(ctx, req)
|
||||
mu.Lock()
|
||||
if localErr == nil {
|
||||
subData[key] = data
|
||||
} else if err == nil {
|
||||
err = localErr
|
||||
cancel()
|
||||
}
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
emptyBody := template.HTML("")
|
||||
|
||||
if err != nil {
|
||||
return emptyBody, err
|
||||
}
|
||||
|
||||
data := customAPITemplateData{
|
||||
customAPIResponseData: primaryData,
|
||||
subrequests: subData,
|
||||
}
|
||||
|
||||
var templateBuffer bytes.Buffer
|
||||
err = tmpl.Execute(&templateBuffer, &data)
|
||||
if err != nil {
|
||||
return emptyBody, err
|
||||
@@ -117,11 +305,6 @@ type decoratedGJSONResult struct {
|
||||
gjson.Result
|
||||
}
|
||||
|
||||
type CustomAPITemplateData struct {
|
||||
JSON decoratedGJSONResult
|
||||
Response *http.Response
|
||||
}
|
||||
|
||||
func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult {
|
||||
decoratedResults := make([]decoratedGJSONResult, len(results))
|
||||
|
||||
@@ -132,12 +315,16 @@ func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedG
|
||||
return decoratedResults
|
||||
}
|
||||
|
||||
func (r *decoratedGJSONResult) Exists(key string) bool {
|
||||
return r.Result.Get(key).Exists()
|
||||
}
|
||||
|
||||
func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
|
||||
if key == "" {
|
||||
return gJsonResultArrayToDecoratedResultArray(r.Result.Array())
|
||||
}
|
||||
|
||||
return gJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
|
||||
return gJsonResultArrayToDecoratedResultArray(r.Result.Get(key).Array())
|
||||
}
|
||||
|
||||
func (r *decoratedGJSONResult) String(key string) string {
|
||||
@@ -145,15 +332,15 @@ func (r *decoratedGJSONResult) String(key string) string {
|
||||
return r.Result.String()
|
||||
}
|
||||
|
||||
return r.Get(key).String()
|
||||
return r.Result.Get(key).String()
|
||||
}
|
||||
|
||||
func (r *decoratedGJSONResult) Int(key string) int64 {
|
||||
func (r *decoratedGJSONResult) Int(key string) int {
|
||||
if key == "" {
|
||||
return r.Result.Int()
|
||||
return int(r.Result.Int())
|
||||
}
|
||||
|
||||
return r.Get(key).Int()
|
||||
return int(r.Result.Get(key).Int())
|
||||
}
|
||||
|
||||
func (r *decoratedGJSONResult) Float(key string) float64 {
|
||||
@@ -161,7 +348,7 @@ func (r *decoratedGJSONResult) Float(key string) float64 {
|
||||
return r.Result.Float()
|
||||
}
|
||||
|
||||
return r.Get(key).Float()
|
||||
return r.Result.Get(key).Float()
|
||||
}
|
||||
|
||||
func (r *decoratedGJSONResult) Bool(key string) bool {
|
||||
@@ -169,40 +356,253 @@ func (r *decoratedGJSONResult) Bool(key string) bool {
|
||||
return r.Result.Bool()
|
||||
}
|
||||
|
||||
return r.Get(key).Bool()
|
||||
return r.Result.Get(key).Bool()
|
||||
}
|
||||
|
||||
func (r *decoratedGJSONResult) Get(key string) *decoratedGJSONResult {
|
||||
return &decoratedGJSONResult{r.Result.Get(key)}
|
||||
}
|
||||
|
||||
func customAPIDoMathOp[T int | float64](a, b T, op string) T {
|
||||
switch op {
|
||||
case "add":
|
||||
return a + b
|
||||
case "sub":
|
||||
return a - b
|
||||
case "mul":
|
||||
return a * b
|
||||
case "div":
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return a / b
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var customAPITemplateFuncs = func() template.FuncMap {
|
||||
var regexpCacheMu sync.Mutex
|
||||
var regexpCache = make(map[string]*regexp.Regexp)
|
||||
|
||||
getCachedRegexp := func(pattern string) *regexp.Regexp {
|
||||
regexpCacheMu.Lock()
|
||||
defer regexpCacheMu.Unlock()
|
||||
|
||||
regex, exists := regexpCache[pattern]
|
||||
if !exists {
|
||||
regex = regexp.MustCompile(pattern)
|
||||
regexpCache[pattern] = regex
|
||||
}
|
||||
|
||||
return regex
|
||||
}
|
||||
|
||||
doMathOpWithAny := func(a, b any, op string) any {
|
||||
switch at := a.(type) {
|
||||
case int:
|
||||
switch bt := b.(type) {
|
||||
case int:
|
||||
return customAPIDoMathOp(at, bt, op)
|
||||
case float64:
|
||||
return customAPIDoMathOp(float64(at), bt, op)
|
||||
default:
|
||||
return math.NaN()
|
||||
}
|
||||
case float64:
|
||||
switch bt := b.(type) {
|
||||
case int:
|
||||
return customAPIDoMathOp(at, float64(bt), op)
|
||||
case float64:
|
||||
return customAPIDoMathOp(at, bt, op)
|
||||
default:
|
||||
return math.NaN()
|
||||
}
|
||||
default:
|
||||
return math.NaN()
|
||||
}
|
||||
}
|
||||
|
||||
funcs := template.FuncMap{
|
||||
"toFloat": func(a int64) float64 {
|
||||
"toFloat": func(a int) float64 {
|
||||
return float64(a)
|
||||
},
|
||||
"toInt": func(a float64) int64 {
|
||||
return int64(a)
|
||||
"toInt": func(a float64) int {
|
||||
return int(a)
|
||||
},
|
||||
"mathexpr": func(left float64, op string, right float64) float64 {
|
||||
if right == 0 {
|
||||
"add": func(a, b any) any {
|
||||
return doMathOpWithAny(a, b, "add")
|
||||
},
|
||||
"sub": func(a, b any) any {
|
||||
return doMathOpWithAny(a, b, "sub")
|
||||
},
|
||||
"mul": func(a, b any) any {
|
||||
return doMathOpWithAny(a, b, "mul")
|
||||
},
|
||||
"div": func(a, b any) any {
|
||||
return doMathOpWithAny(a, b, "div")
|
||||
},
|
||||
"now": func() time.Time {
|
||||
return time.Now()
|
||||
},
|
||||
"offsetNow": func(offset string) time.Time {
|
||||
d, err := time.ParseDuration(offset)
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
return time.Now().Add(d)
|
||||
},
|
||||
"duration": func(str string) time.Duration {
|
||||
d, err := time.ParseDuration(str)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "+":
|
||||
return left + right
|
||||
case "-":
|
||||
return left - right
|
||||
case "*":
|
||||
return left * right
|
||||
case "/":
|
||||
return left / right
|
||||
default:
|
||||
return 0
|
||||
return d
|
||||
},
|
||||
"parseTime": func(layout, value string) time.Time {
|
||||
return customAPIFuncParseTimeInLocation(layout, value, time.UTC)
|
||||
},
|
||||
"parseLocalTime": func(layout, value string) time.Time {
|
||||
return customAPIFuncParseTimeInLocation(layout, value, time.Local)
|
||||
},
|
||||
"toRelativeTime": dynamicRelativeTimeAttrs,
|
||||
"parseRelativeTime": func(layout, value string) template.HTMLAttr {
|
||||
// Shorthand to do both of the above with a single function call
|
||||
return dynamicRelativeTimeAttrs(customAPIFuncParseTimeInLocation(layout, value, time.UTC))
|
||||
},
|
||||
// The reason we flip the parameter order is so that you can chain multiple calls together like this:
|
||||
// {{ .JSON.String "foo" | trimPrefix "bar" | doSomethingElse }}
|
||||
// instead of doing this:
|
||||
// {{ trimPrefix (.JSON.String "foo") "bar" | doSomethingElse }}
|
||||
// since the piped value gets passed as the last argument to the function.
|
||||
"trimPrefix": func(prefix, s string) string {
|
||||
return strings.TrimPrefix(s, prefix)
|
||||
},
|
||||
"trimSuffix": func(suffix, s string) string {
|
||||
return strings.TrimSuffix(s, suffix)
|
||||
},
|
||||
"trimSpace": strings.TrimSpace,
|
||||
"replaceAll": func(old, new, s string) string {
|
||||
return strings.ReplaceAll(s, old, new)
|
||||
},
|
||||
"replaceMatches": func(pattern, replacement, s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return getCachedRegexp(pattern).ReplaceAllString(s, replacement)
|
||||
},
|
||||
"findMatch": func(pattern, s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return getCachedRegexp(pattern).FindString(s)
|
||||
},
|
||||
"findSubmatch": func(pattern, s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
regex := getCachedRegexp(pattern)
|
||||
return itemAtIndexOrDefault(regex.FindStringSubmatch(s), 1, "")
|
||||
},
|
||||
"sortByString": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
|
||||
sort.Slice(results, func(a, b int) bool {
|
||||
if order == "asc" {
|
||||
return results[a].String(key) < results[b].String(key)
|
||||
}
|
||||
|
||||
return results[a].String(key) > results[b].String(key)
|
||||
})
|
||||
|
||||
return results
|
||||
},
|
||||
"sortByInt": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
|
||||
sort.Slice(results, func(a, b int) bool {
|
||||
if order == "asc" {
|
||||
return results[a].Int(key) < results[b].Int(key)
|
||||
}
|
||||
|
||||
return results[a].Int(key) > results[b].Int(key)
|
||||
})
|
||||
|
||||
return results
|
||||
},
|
||||
"sortByFloat": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
|
||||
sort.Slice(results, func(a, b int) bool {
|
||||
if order == "asc" {
|
||||
return results[a].Float(key) < results[b].Float(key)
|
||||
}
|
||||
|
||||
return results[a].Float(key) > results[b].Float(key)
|
||||
})
|
||||
|
||||
return results
|
||||
},
|
||||
"sortByTime": func(key, layout, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
|
||||
sort.Slice(results, func(a, b int) bool {
|
||||
timeA := customAPIFuncParseTimeInLocation(layout, results[a].String(key), time.UTC)
|
||||
timeB := customAPIFuncParseTimeInLocation(layout, results[b].String(key), time.UTC)
|
||||
|
||||
if order == "asc" {
|
||||
return timeA.Before(timeB)
|
||||
}
|
||||
|
||||
return timeA.After(timeB)
|
||||
})
|
||||
|
||||
return results
|
||||
},
|
||||
"concat": func(items ...string) string {
|
||||
return strings.Join(items, "")
|
||||
},
|
||||
"unique": func(key string, results []decoratedGJSONResult) []decoratedGJSONResult {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]decoratedGJSONResult, 0, len(results))
|
||||
for _, result := range results {
|
||||
val := result.String(key)
|
||||
if _, ok := seen[val]; !ok {
|
||||
seen[val] = struct{}{}
|
||||
out = append(out, result)
|
||||
}
|
||||
}
|
||||
return out
|
||||
},
|
||||
}
|
||||
|
||||
for key, value := range globalTemplateFunctions {
|
||||
funcs[key] = value
|
||||
if _, exists := funcs[key]; !exists {
|
||||
funcs[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return funcs
|
||||
}()
|
||||
|
||||
func customAPIFuncParseTimeInLocation(layout, value string, loc *time.Location) time.Time {
|
||||
switch strings.ToLower(layout) {
|
||||
case "unix":
|
||||
asInt, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return time.Unix(0, 0)
|
||||
}
|
||||
|
||||
return time.Unix(asInt, 0)
|
||||
case "rfc3339":
|
||||
layout = time.RFC3339
|
||||
case "rfc3339nano":
|
||||
layout = time.RFC3339Nano
|
||||
case "datetime":
|
||||
layout = time.DateTime
|
||||
case "dateonly":
|
||||
layout = time.DateOnly
|
||||
}
|
||||
|
||||
parsed, err := time.ParseInLocation(layout, value, loc)
|
||||
if err != nil {
|
||||
return time.Unix(0, 0)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html")
|
||||
|
||||
const (
|
||||
dnsStatsBars = 8
|
||||
dnsStatsHoursSpan = 24
|
||||
dnsStatsHoursPerBar int = dnsStatsHoursSpan / dnsStatsBars
|
||||
)
|
||||
|
||||
type dnsStatsWidget struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
|
||||
TimeLabels [8]string `yaml:"-"`
|
||||
Stats *dnsStats `yaml:"-"`
|
||||
TimeLabels [8]string `yaml:"-"`
|
||||
Stats *dnsStats `yaml:"-"`
|
||||
piholeSessionID string `yaml:"-"`
|
||||
|
||||
HourFormat string `yaml:"hour-format"`
|
||||
HideGraph bool `yaml:"hide-graph"`
|
||||
@@ -31,11 +42,18 @@ type dnsStatsWidget struct {
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
const (
|
||||
dnsServiceAdguard = "adguard"
|
||||
dnsServicePihole = "pihole"
|
||||
dnsServiceTechnitium = "technitium"
|
||||
dnsServicePiholeV6 = "pihole-v6"
|
||||
)
|
||||
|
||||
func makeDNSWidgetTimeLabels(format string) [8]string {
|
||||
now := time.Now()
|
||||
var labels [8]string
|
||||
var labels [dnsStatsBars]string
|
||||
|
||||
for h := 24; h > 0; h -= 3 {
|
||||
for h := dnsStatsHoursSpan; h > 0; h -= dnsStatsHoursPerBar {
|
||||
labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format))
|
||||
}
|
||||
|
||||
@@ -43,13 +61,24 @@ func makeDNSWidgetTimeLabels(format string) [8]string {
|
||||
}
|
||||
|
||||
func (widget *dnsStatsWidget) initialize() error {
|
||||
titleURL := strings.TrimRight(widget.URL, "/")
|
||||
switch widget.Service {
|
||||
case dnsServicePihole, dnsServicePiholeV6:
|
||||
titleURL = titleURL + "/admin"
|
||||
}
|
||||
|
||||
widget.
|
||||
withTitle("DNS Stats").
|
||||
withTitleURL(string(widget.URL)).
|
||||
withTitleURL(titleURL).
|
||||
withCacheDuration(10 * time.Minute)
|
||||
|
||||
if widget.Service != "adguard" && widget.Service != "pihole" {
|
||||
return errors.New("service must be either 'adguard' or 'pihole'")
|
||||
switch widget.Service {
|
||||
case dnsServiceAdguard:
|
||||
case dnsServicePiholeV6:
|
||||
case dnsServicePihole:
|
||||
case dnsServiceTechnitium:
|
||||
default:
|
||||
return fmt.Errorf("service must be one of: %s, %s, %s, %s", dnsServiceAdguard, dnsServicePihole, dnsServicePiholeV6, dnsServiceTechnitium)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -59,10 +88,26 @@ func (widget *dnsStatsWidget) update(ctx context.Context) {
|
||||
var stats *dnsStats
|
||||
var err error
|
||||
|
||||
if widget.Service == "adguard" {
|
||||
switch widget.Service {
|
||||
case dnsServiceAdguard:
|
||||
stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph)
|
||||
} else {
|
||||
stats, err = fetchPiholeStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
|
||||
case dnsServicePihole:
|
||||
stats, err = fetchPihole5Stats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
|
||||
case dnsServiceTechnitium:
|
||||
stats, err = fetchTechnitiumStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
|
||||
case dnsServicePiholeV6:
|
||||
var newSessionID string
|
||||
stats, newSessionID, err = fetchPiholeStats(
|
||||
widget.URL,
|
||||
widget.AllowInsecure,
|
||||
widget.Password,
|
||||
widget.piholeSessionID,
|
||||
!widget.HideGraph,
|
||||
!widget.HideTopDomains,
|
||||
)
|
||||
if err == nil {
|
||||
widget.piholeSessionID = newSessionID
|
||||
}
|
||||
}
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
@@ -84,11 +129,11 @@ func (widget *dnsStatsWidget) Render() template.HTML {
|
||||
|
||||
type dnsStats struct {
|
||||
TotalQueries int
|
||||
BlockedQueries int
|
||||
BlockedQueries int // we don't actually use this anywhere in templates, maybe remove it later?
|
||||
BlockedPercent int
|
||||
ResponseTime int
|
||||
DomainsBlocked int
|
||||
Series [8]dnsStatsSeries
|
||||
Series [dnsStatsBars]dnsStatsSeries
|
||||
TopBlockedDomains []dnsStatsBlockedDomain
|
||||
}
|
||||
|
||||
@@ -123,13 +168,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
||||
|
||||
request.SetBasicAuth(username, password)
|
||||
|
||||
var client requestDoer
|
||||
if !allowInsecure {
|
||||
client = defaultHTTPClient
|
||||
} else {
|
||||
client = defaultInsecureHTTPClient
|
||||
}
|
||||
|
||||
var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
|
||||
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -150,7 +189,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
||||
|
||||
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
|
||||
|
||||
for i := 0; i < topBlockedDomainsCount; i++ {
|
||||
for i := range topBlockedDomainsCount {
|
||||
domain := responseJson.TopBlockedDomains[i]
|
||||
var firstDomain string
|
||||
|
||||
@@ -179,31 +218,27 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
||||
queriesSeries := responseJson.QueriesSeries
|
||||
blockedSeries := responseJson.BlockedSeries
|
||||
|
||||
const bars = 8
|
||||
const hoursSpan = 24
|
||||
const hoursPerBar int = hoursSpan / bars
|
||||
|
||||
if len(queriesSeries) > hoursSpan {
|
||||
queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
|
||||
} else if len(queriesSeries) < hoursSpan {
|
||||
queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
|
||||
if len(queriesSeries) > dnsStatsHoursSpan {
|
||||
queriesSeries = queriesSeries[len(queriesSeries)-dnsStatsHoursSpan:]
|
||||
} else if len(queriesSeries) < dnsStatsHoursSpan {
|
||||
queriesSeries = append(make([]int, dnsStatsHoursSpan-len(queriesSeries)), queriesSeries...)
|
||||
}
|
||||
|
||||
if len(blockedSeries) > hoursSpan {
|
||||
blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
|
||||
} else if len(blockedSeries) < hoursSpan {
|
||||
blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
|
||||
if len(blockedSeries) > dnsStatsHoursSpan {
|
||||
blockedSeries = blockedSeries[len(blockedSeries)-dnsStatsHoursSpan:]
|
||||
} else if len(blockedSeries) < dnsStatsHoursSpan {
|
||||
blockedSeries = append(make([]int, dnsStatsHoursSpan-len(blockedSeries)), blockedSeries...)
|
||||
}
|
||||
|
||||
maxQueriesInSeries := 0
|
||||
|
||||
for i := 0; i < bars; i++ {
|
||||
for i := range dnsStatsBars {
|
||||
queries := 0
|
||||
blocked := 0
|
||||
|
||||
for j := 0; j < hoursPerBar; j++ {
|
||||
queries += queriesSeries[i*hoursPerBar+j]
|
||||
blocked += blockedSeries[i*hoursPerBar+j]
|
||||
for j := range dnsStatsHoursPerBar {
|
||||
queries += queriesSeries[i*dnsStatsHoursPerBar+j]
|
||||
blocked += blockedSeries[i*dnsStatsHoursPerBar+j]
|
||||
}
|
||||
|
||||
stats.Series[i] = dnsStatsSeries{
|
||||
@@ -220,35 +255,36 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < bars; i++ {
|
||||
for i := range dnsStatsBars {
|
||||
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
type piholeStatsResponse struct {
|
||||
TotalQueries int `json:"dns_queries_today"`
|
||||
QueriesSeries piholeQueriesSeries `json:"domains_over_time"`
|
||||
BlockedQueries int `json:"ads_blocked_today"`
|
||||
BlockedSeries map[int64]int `json:"ads_over_time"`
|
||||
BlockedPercentage float64 `json:"ads_percentage_today"`
|
||||
TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
|
||||
DomainsBlocked int `json:"domains_being_blocked"`
|
||||
// Legacy Pi-hole stats response (before v6)
|
||||
type pihole5StatsResponse struct {
|
||||
TotalQueries int `json:"dns_queries_today"`
|
||||
QueriesSeries pihole5QueriesSeries `json:"domains_over_time"`
|
||||
BlockedQueries int `json:"ads_blocked_today"`
|
||||
BlockedSeries map[int64]int `json:"ads_over_time"`
|
||||
BlockedPercentage float64 `json:"ads_percentage_today"`
|
||||
TopBlockedDomains pihole5TopBlockedDomains `json:"top_ads"`
|
||||
DomainsBlocked int `json:"domains_being_blocked"`
|
||||
}
|
||||
|
||||
// If the user has query logging disabled it's possible for domains_over_time to be returned as an
|
||||
// empty array rather than a map which will prevent unmashalling the rest of the data so we use
|
||||
// custom unmarshal behavior to fallback to an empty map.
|
||||
// See https://github.com/glanceapp/glance/issues/289
|
||||
type piholeQueriesSeries map[int64]int
|
||||
type pihole5QueriesSeries map[int64]int
|
||||
|
||||
func (p *piholeQueriesSeries) UnmarshalJSON(data []byte) error {
|
||||
func (p *pihole5QueriesSeries) UnmarshalJSON(data []byte) error {
|
||||
temp := make(map[int64]int)
|
||||
|
||||
err := json.Unmarshal(data, &temp)
|
||||
if err != nil {
|
||||
*p = make(piholeQueriesSeries)
|
||||
*p = make(pihole5QueriesSeries)
|
||||
} else {
|
||||
*p = temp
|
||||
}
|
||||
@@ -258,16 +294,16 @@ func (p *piholeQueriesSeries) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
|
||||
// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
|
||||
type piholeTopBlockedDomains map[string]int
|
||||
type pihole5TopBlockedDomains map[string]int
|
||||
|
||||
func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
|
||||
func (p *pihole5TopBlockedDomains) UnmarshalJSON(data []byte) error {
|
||||
// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
|
||||
// because of the UnmarshalJSON method getting called recursively
|
||||
temp := make(map[string]int)
|
||||
|
||||
err := json.Unmarshal(data, &temp)
|
||||
if err != nil {
|
||||
*p = make(piholeTopBlockedDomains)
|
||||
*p = make(pihole5TopBlockedDomains)
|
||||
} else {
|
||||
*p = temp
|
||||
}
|
||||
@@ -275,7 +311,7 @@ func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) {
|
||||
func fetchPihole5Stats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) {
|
||||
if token == "" {
|
||||
return nil, errors.New("missing API token")
|
||||
}
|
||||
@@ -288,14 +324,8 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var client requestDoer
|
||||
if !allowInsecure {
|
||||
client = defaultHTTPClient
|
||||
} else {
|
||||
client = defaultInsecureHTTPClient
|
||||
}
|
||||
|
||||
responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, request)
|
||||
var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
|
||||
responseJson, err := decodeJsonFromRequest[pihole5StatsResponse](client, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -339,7 +369,6 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
||||
}
|
||||
|
||||
var lowestTimestamp int64 = 0
|
||||
|
||||
for timestamp := range responseJson.QueriesSeries {
|
||||
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
|
||||
lowestTimestamp = timestamp
|
||||
@@ -348,11 +377,11 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
||||
|
||||
maxQueriesInSeries := 0
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
for i := range dnsStatsBars {
|
||||
queries := 0
|
||||
blocked := 0
|
||||
|
||||
for j := 0; j < 18; j++ {
|
||||
for j := range 18 {
|
||||
index := lowestTimestamp + int64(i*10800+j*600)
|
||||
|
||||
queries += responseJson.QueriesSeries[index]
|
||||
@@ -373,7 +402,417 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
for i := range dnsStatsBars {
|
||||
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func fetchPiholeStats(
|
||||
instanceURL string,
|
||||
allowInsecure bool,
|
||||
password string,
|
||||
sessionID string,
|
||||
includeGraph bool,
|
||||
includeTopDomains bool,
|
||||
) (*dnsStats, string, error) {
|
||||
instanceURL = strings.TrimRight(instanceURL, "/")
|
||||
var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
|
||||
|
||||
fetchNewSessionID := func() error {
|
||||
newSessionID, err := fetchPiholeSessionID(instanceURL, client, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sessionID = newSessionID
|
||||
return nil
|
||||
}
|
||||
|
||||
if sessionID == "" {
|
||||
if err := fetchNewSessionID(); err != nil {
|
||||
slog.Error("Failed to fetch Pihole v6 session ID", "error", err)
|
||||
return nil, "", fmt.Errorf("fetching session ID: %v", err)
|
||||
}
|
||||
} else {
|
||||
isValid, err := checkPiholeSessionIDIsValid(instanceURL, client, sessionID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to check Pihole v6 session ID validity", "error", err)
|
||||
return nil, "", fmt.Errorf("checking session ID: %v", err)
|
||||
}
|
||||
|
||||
if !isValid {
|
||||
if err := fetchNewSessionID(); err != nil {
|
||||
slog.Error("Failed to renew Pihole v6 session ID", "error", err)
|
||||
return nil, "", fmt.Errorf("renewing session ID: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
type statsResponseJson struct {
|
||||
Queries struct {
|
||||
Total int `json:"total"`
|
||||
Blocked int `json:"blocked"`
|
||||
PercentBlocked float64 `json:"percent_blocked"`
|
||||
} `json:"queries"`
|
||||
Gravity struct {
|
||||
DomainsBlocked int `json:"domains_being_blocked"`
|
||||
} `json:"gravity"`
|
||||
}
|
||||
|
||||
statsRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/stats/summary", nil)
|
||||
statsRequest.Header.Set("x-ftl-sid", sessionID)
|
||||
|
||||
var statsResponse statsResponseJson
|
||||
var statsErr error
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
statsResponse, statsErr = decodeJsonFromRequest[statsResponseJson](client, statsRequest)
|
||||
if statsErr != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
type seriesResponseJson struct {
|
||||
History []struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Total int `json:"total"`
|
||||
Blocked int `json:"blocked"`
|
||||
} `json:"history"`
|
||||
}
|
||||
|
||||
var seriesResponse seriesResponseJson
|
||||
var seriesErr error
|
||||
|
||||
if includeGraph {
|
||||
seriesRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/history", nil)
|
||||
seriesRequest.Header.Set("x-ftl-sid", sessionID)
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
seriesResponse, seriesErr = decodeJsonFromRequest[seriesResponseJson](client, seriesRequest)
|
||||
}()
|
||||
}
|
||||
|
||||
type topDomainsResponseJson struct {
|
||||
Domains []struct {
|
||||
Domain string `json:"domain"`
|
||||
Count int `json:"count"`
|
||||
} `json:"domains"`
|
||||
TotalQueries int `json:"total_queries"`
|
||||
BlockedQueries int `json:"blocked_queries"`
|
||||
Took float64 `json:"took"`
|
||||
}
|
||||
|
||||
var topDomainsResponse topDomainsResponseJson
|
||||
var topDomainsErr error
|
||||
|
||||
if includeTopDomains {
|
||||
topDomainsRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/stats/top_domains?blocked=true", nil)
|
||||
topDomainsRequest.Header.Set("x-ftl-sid", sessionID)
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
topDomainsResponse, topDomainsErr = decodeJsonFromRequest[topDomainsResponseJson](client, topDomainsRequest)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
partialContent := false
|
||||
|
||||
if statsErr != nil {
|
||||
return nil, "", fmt.Errorf("fetching stats: %v", statsErr)
|
||||
}
|
||||
|
||||
if includeGraph && seriesErr != nil {
|
||||
slog.Error("Failed to fetch Pihole v6 graph data", "error", seriesErr)
|
||||
partialContent = true
|
||||
}
|
||||
|
||||
if includeTopDomains && topDomainsErr != nil {
|
||||
slog.Error("Failed to fetch Pihole v6 top domains", "error", topDomainsErr)
|
||||
partialContent = true
|
||||
}
|
||||
|
||||
stats := &dnsStats{
|
||||
TotalQueries: statsResponse.Queries.Total,
|
||||
BlockedQueries: statsResponse.Queries.Blocked,
|
||||
BlockedPercent: int(statsResponse.Queries.PercentBlocked),
|
||||
DomainsBlocked: statsResponse.Gravity.DomainsBlocked,
|
||||
}
|
||||
|
||||
if includeGraph && seriesErr == nil {
|
||||
if len(seriesResponse.History) != 145 {
|
||||
slog.Error(
|
||||
"Pihole v6 graph data has unexpected length",
|
||||
"length", len(seriesResponse.History),
|
||||
"expected", 145,
|
||||
)
|
||||
partialContent = true
|
||||
} else {
|
||||
// The API from v5 used to return 144 data points, but v6 returns 145.
|
||||
// We only show data from the last 24 hours hours, Pihole returns data
|
||||
// points in a 10 minute interval, 24*(60/10) = 144. Why is there an extra
|
||||
// data point? I don't know, but we'll just ignore the first one since it's
|
||||
// the oldest data point.
|
||||
history := seriesResponse.History[1:]
|
||||
|
||||
const interval = 10
|
||||
const dataPointsPerBar = dnsStatsHoursPerBar * (60 / interval)
|
||||
|
||||
maxQueriesInSeries := 0
|
||||
|
||||
for i := range dnsStatsBars {
|
||||
queries := 0
|
||||
blocked := 0
|
||||
for j := range dataPointsPerBar {
|
||||
index := i*dataPointsPerBar + j
|
||||
queries += history[index].Total
|
||||
blocked += history[index].Blocked
|
||||
}
|
||||
if queries > maxQueriesInSeries {
|
||||
maxQueriesInSeries = queries
|
||||
}
|
||||
stats.Series[i] = dnsStatsSeries{
|
||||
Queries: queries,
|
||||
Blocked: blocked,
|
||||
}
|
||||
if queries > 0 {
|
||||
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range dnsStatsBars {
|
||||
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if includeTopDomains && topDomainsErr == nil && len(topDomainsResponse.Domains) > 0 {
|
||||
domains := make([]dnsStatsBlockedDomain, 0, len(topDomainsResponse.Domains))
|
||||
for i := range topDomainsResponse.Domains {
|
||||
d := &topDomainsResponse.Domains[i]
|
||||
domains = append(domains, dnsStatsBlockedDomain{
|
||||
Domain: d.Domain,
|
||||
PercentBlocked: int(float64(d.Count) / float64(statsResponse.Queries.Blocked) * 100),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(domains, func(a, b int) bool {
|
||||
return domains[a].PercentBlocked > domains[b].PercentBlocked
|
||||
})
|
||||
stats.TopBlockedDomains = domains[:min(len(domains), 5)]
|
||||
}
|
||||
|
||||
return stats, sessionID, ternary(partialContent, errPartialContent, nil)
|
||||
}
|
||||
|
||||
func fetchPiholeSessionID(instanceURL string, client *http.Client, password string) (string, error) {
|
||||
requestBody := []byte(`{"password":"` + password + `"}`)
|
||||
|
||||
request, err := http.NewRequest("POST", instanceURL+"/api/auth", bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating authentication request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sending authentication request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading authentication response: %v", err)
|
||||
}
|
||||
|
||||
var jsonResponse struct {
|
||||
Session struct {
|
||||
SID string `json:"sid"`
|
||||
Message string `json:"message"`
|
||||
} `json:"session"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &jsonResponse); err != nil {
|
||||
return "", fmt.Errorf("parsing authentication response: %v", err)
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf(
|
||||
"authentication request returned status %s with message '%s'",
|
||||
response.Status, jsonResponse.Session.Message,
|
||||
)
|
||||
}
|
||||
|
||||
if jsonResponse.Session.SID == "" {
|
||||
return "", fmt.Errorf(
|
||||
"authentication response returned empty session ID, status code %d, message '%s'",
|
||||
response.StatusCode, jsonResponse.Session.Message,
|
||||
)
|
||||
}
|
||||
|
||||
return jsonResponse.Session.SID, nil
|
||||
}
|
||||
|
||||
func checkPiholeSessionIDIsValid(instanceURL string, client *http.Client, sessionID string) (bool, error) {
|
||||
request, err := http.NewRequest("GET", instanceURL+"/api/auth", nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("creating session ID check request: %v", err)
|
||||
}
|
||||
request.Header.Set("x-ftl-sid", sessionID)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusUnauthorized {
|
||||
return false, fmt.Errorf("session ID check request returned status %s", response.Status)
|
||||
}
|
||||
|
||||
return response.StatusCode == http.StatusOK, nil
|
||||
}
|
||||
|
||||
type technitiumStatsResponse struct {
|
||||
Response struct {
|
||||
Stats struct {
|
||||
TotalQueries int `json:"totalQueries"`
|
||||
BlockedQueries int `json:"totalBlocked"`
|
||||
BlockedZones int `json:"blockedZones"`
|
||||
BlockListZones int `json:"blockListZones"`
|
||||
} `json:"stats"`
|
||||
MainChartData struct {
|
||||
Datasets []struct {
|
||||
Label string `json:"label"`
|
||||
Data []int `json:"data"`
|
||||
} `json:"datasets"`
|
||||
} `json:"mainChartData"`
|
||||
TopBlockedDomains []struct {
|
||||
Domain string `json:"name"`
|
||||
Count int `json:"hits"`
|
||||
}
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
func fetchTechnitiumStats(instanceUrl string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) {
|
||||
if token == "" {
|
||||
return nil, errors.New("missing API token")
|
||||
}
|
||||
|
||||
requestURL := strings.TrimRight(instanceUrl, "/") + "/api/dashboard/stats/get?token=" + token + "&type=LastDay"
|
||||
|
||||
request, err := http.NewRequest("GET", requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var client requestDoer
|
||||
if !allowInsecure {
|
||||
client = defaultHTTPClient
|
||||
} else {
|
||||
client = defaultInsecureHTTPClient
|
||||
}
|
||||
|
||||
responseJson, err := decodeJsonFromRequest[technitiumStatsResponse](client, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var topBlockedDomainsCount = min(len(responseJson.Response.TopBlockedDomains), 5)
|
||||
|
||||
stats := &dnsStats{
|
||||
TotalQueries: responseJson.Response.Stats.TotalQueries,
|
||||
BlockedQueries: responseJson.Response.Stats.BlockedQueries,
|
||||
TopBlockedDomains: make([]dnsStatsBlockedDomain, 0, topBlockedDomainsCount),
|
||||
DomainsBlocked: responseJson.Response.Stats.BlockedZones + responseJson.Response.Stats.BlockListZones,
|
||||
}
|
||||
|
||||
if stats.TotalQueries <= 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
stats.BlockedPercent = int(float64(responseJson.Response.Stats.BlockedQueries) / float64(responseJson.Response.Stats.TotalQueries) * 100)
|
||||
|
||||
for i := 0; i < topBlockedDomainsCount; i++ {
|
||||
domain := responseJson.Response.TopBlockedDomains[i]
|
||||
firstDomain := domain.Domain
|
||||
|
||||
if firstDomain == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
stats.TopBlockedDomains = append(stats.TopBlockedDomains, dnsStatsBlockedDomain{
|
||||
Domain: firstDomain,
|
||||
})
|
||||
|
||||
if stats.BlockedQueries > 0 {
|
||||
stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain.Count) / float64(responseJson.Response.Stats.BlockedQueries) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
if noGraph {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
var queriesSeries, blockedSeries []int
|
||||
|
||||
for _, label := range responseJson.Response.MainChartData.Datasets {
|
||||
switch label.Label {
|
||||
case "Total":
|
||||
queriesSeries = label.Data
|
||||
case "Blocked":
|
||||
blockedSeries = label.Data
|
||||
}
|
||||
}
|
||||
|
||||
if len(queriesSeries) > dnsStatsHoursSpan {
|
||||
queriesSeries = queriesSeries[len(queriesSeries)-dnsStatsHoursSpan:]
|
||||
} else if len(queriesSeries) < dnsStatsHoursSpan {
|
||||
queriesSeries = append(make([]int, dnsStatsHoursSpan-len(queriesSeries)), queriesSeries...)
|
||||
}
|
||||
|
||||
if len(blockedSeries) > dnsStatsHoursSpan {
|
||||
blockedSeries = blockedSeries[len(blockedSeries)-dnsStatsHoursSpan:]
|
||||
} else if len(blockedSeries) < dnsStatsHoursSpan {
|
||||
blockedSeries = append(make([]int, dnsStatsHoursSpan-len(blockedSeries)), blockedSeries...)
|
||||
}
|
||||
|
||||
maxQueriesInSeries := 0
|
||||
|
||||
for i := 0; i < dnsStatsBars; i++ {
|
||||
queries := 0
|
||||
blocked := 0
|
||||
|
||||
for j := 0; j < dnsStatsHoursPerBar; j++ {
|
||||
queries += queriesSeries[i*dnsStatsHoursPerBar+j]
|
||||
blocked += blockedSeries[i*dnsStatsHoursPerBar+j]
|
||||
}
|
||||
|
||||
stats.Series[i] = dnsStatsSeries{
|
||||
Queries: queries,
|
||||
Blocked: blocked,
|
||||
}
|
||||
|
||||
if queries > 0 {
|
||||
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
|
||||
}
|
||||
|
||||
if queries > maxQueriesInSeries {
|
||||
maxQueriesInSeries = queries
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < dnsStatsBars; i++ {
|
||||
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,10 +16,14 @@ import (
|
||||
var dockerContainersWidgetTemplate = mustParseTemplate("docker-containers.html", "widget-base.html")
|
||||
|
||||
type dockerContainersWidget struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
HideByDefault bool `yaml:"hide-by-default"`
|
||||
SockPath string `yaml:"sock-path"`
|
||||
Containers dockerContainerList `yaml:"-"`
|
||||
widgetBase `yaml:",inline"`
|
||||
HideByDefault bool `yaml:"hide-by-default"`
|
||||
RunningOnly bool `yaml:"running-only"`
|
||||
Category string `yaml:"category"`
|
||||
SockPath string `yaml:"sock-path"`
|
||||
FormatContainerNames bool `yaml:"format-container-names"`
|
||||
Containers dockerContainerList `yaml:"-"`
|
||||
LabelOverrides map[string]map[string]string `yaml:"containers"`
|
||||
}
|
||||
|
||||
func (widget *dockerContainersWidget) initialize() error {
|
||||
@@ -32,7 +37,14 @@ func (widget *dockerContainersWidget) initialize() error {
|
||||
}
|
||||
|
||||
func (widget *dockerContainersWidget) update(ctx context.Context) {
|
||||
containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault)
|
||||
containers, err := fetchDockerContainers(
|
||||
widget.SockPath,
|
||||
widget.HideByDefault,
|
||||
widget.Category,
|
||||
widget.RunningOnly,
|
||||
widget.FormatContainerNames,
|
||||
widget.LabelOverrides,
|
||||
)
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
@@ -47,13 +59,14 @@ func (widget *dockerContainersWidget) Render() template.HTML {
|
||||
|
||||
const (
|
||||
dockerContainerLabelHide = "glance.hide"
|
||||
dockerContainerLabelTitle = "glance.title"
|
||||
dockerContainerLabelName = "glance.name"
|
||||
dockerContainerLabelURL = "glance.url"
|
||||
dockerContainerLabelDescription = "glance.description"
|
||||
dockerContainerLabelSameTab = "glance.same-tab"
|
||||
dockerContainerLabelIcon = "glance.icon"
|
||||
dockerContainerLabelID = "glance.id"
|
||||
dockerContainerLabelParent = "glance.parent"
|
||||
dockerContainerLabelCategory = "glance.category"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -98,7 +111,7 @@ func (l *dockerContainerLabels) getOrDefault(label, def string) string {
|
||||
}
|
||||
|
||||
type dockerContainer struct {
|
||||
Title string
|
||||
Name string
|
||||
URL string
|
||||
SameTab bool
|
||||
Image string
|
||||
@@ -120,7 +133,7 @@ func (containers dockerContainerList) sortByStateIconThenTitle() {
|
||||
return (*p)[containers[a].StateIcon] < (*p)[containers[b].StateIcon]
|
||||
}
|
||||
|
||||
return strings.ToLower(containers[a].Title) < strings.ToLower(containers[b].Title)
|
||||
return strings.ToLower(containers[a].Name) < strings.ToLower(containers[b].Name)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,8 +150,15 @@ func dockerContainerStateToStateIcon(state string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContainerList, error) {
|
||||
containers, err := fetchAllDockerContainersFromSock(socketPath)
|
||||
func fetchDockerContainers(
|
||||
socketPath string,
|
||||
hideByDefault bool,
|
||||
category string,
|
||||
runningOnly bool,
|
||||
formatNames bool,
|
||||
labelOverrides map[string]map[string]string,
|
||||
) (dockerContainerList, error) {
|
||||
containers, err := fetchDockerContainersFromSource(socketPath, category, runningOnly, labelOverrides)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching containers: %w", err)
|
||||
}
|
||||
@@ -150,7 +170,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain
|
||||
container := &containers[i]
|
||||
|
||||
dc := dockerContainer{
|
||||
Title: deriveDockerContainerTitle(container),
|
||||
Name: deriveDockerContainerName(container, formatNames),
|
||||
URL: container.Labels.getOrDefault(dockerContainerLabelURL, ""),
|
||||
Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""),
|
||||
SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")),
|
||||
@@ -165,7 +185,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain
|
||||
for i := range children {
|
||||
child := &children[i]
|
||||
dc.Children = append(dc.Children, dockerContainer{
|
||||
Title: deriveDockerContainerTitle(child),
|
||||
Name: deriveDockerContainerName(child, formatNames),
|
||||
StateText: child.Status,
|
||||
StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)),
|
||||
})
|
||||
@@ -193,12 +213,31 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain
|
||||
return dockerContainers, nil
|
||||
}
|
||||
|
||||
func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string {
|
||||
if v := container.Labels.getOrDefault(dockerContainerLabelTitle, ""); v != "" {
|
||||
func deriveDockerContainerName(container *dockerContainerJsonResponse, formatNames bool) string {
|
||||
if v := container.Labels.getOrDefault(dockerContainerLabelName, ""); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
return strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, "n/a"), "/")
|
||||
if len(container.Names) == 0 || container.Names[0] == "" {
|
||||
return "n/a"
|
||||
}
|
||||
|
||||
name := strings.TrimLeft(container.Names[0], "/")
|
||||
|
||||
if formatNames {
|
||||
name = strings.ReplaceAll(name, "_", " ")
|
||||
name = strings.ReplaceAll(name, "-", " ")
|
||||
|
||||
words := strings.Split(name, " ")
|
||||
for i := range words {
|
||||
if len(words[i]) > 0 {
|
||||
words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
|
||||
}
|
||||
}
|
||||
name = strings.Join(words, " ")
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
func groupDockerContainerChildren(
|
||||
@@ -239,17 +278,46 @@ func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefau
|
||||
return hideByDefault
|
||||
}
|
||||
|
||||
func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) {
|
||||
client := &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
return net.Dial("unix", socketPath)
|
||||
|
||||
func fetchDockerContainersFromSource(
|
||||
source string,
|
||||
category string,
|
||||
runningOnly bool,
|
||||
labelOverrides map[string]map[string]string,
|
||||
) ([]dockerContainerJsonResponse, error) {
|
||||
var hostname string
|
||||
|
||||
var client *http.Client
|
||||
if strings.HasPrefix(source, "tcp://") || strings.HasPrefix(source, "http://") {
|
||||
client = &http.Client{}
|
||||
parsed, err := url.Parse(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing URL: %w", err)
|
||||
}
|
||||
|
||||
port := parsed.Port()
|
||||
if port == "" {
|
||||
port = "80"
|
||||
}
|
||||
|
||||
hostname = parsed.Hostname() + ":" + port
|
||||
} else {
|
||||
hostname = "docker"
|
||||
client = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
return net.Dial("unix", source)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("GET", "http://docker/containers/json?all=true", nil)
|
||||
|
||||
fetchAll := ternary(runningOnly, "false", "true")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, "GET", "http://"+hostname+"/containers/json?all="+fetchAll, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
@@ -269,5 +337,43 @@ func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonR
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
for i := range containers {
|
||||
container := &containers[i]
|
||||
name := strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, ""), "/")
|
||||
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
overrides, ok := labelOverrides[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if container.Labels == nil {
|
||||
container.Labels = make(dockerContainerLabels)
|
||||
}
|
||||
|
||||
for label, value := range overrides {
|
||||
container.Labels["glance."+label] = value
|
||||
}
|
||||
}
|
||||
|
||||
// We have to filter here instead of using the `filters` parameter of Docker's API
|
||||
// because the user may define a category override within their config
|
||||
if category != "" {
|
||||
filtered := make([]dockerContainerJsonResponse, 0, len(containers))
|
||||
|
||||
for i := range containers {
|
||||
container := &containers[i]
|
||||
|
||||
if container.Labels.getOrDefault(dockerContainerLabelCategory, "") == category {
|
||||
filtered = append(filtered, *container)
|
||||
}
|
||||
}
|
||||
|
||||
containers = filtered
|
||||
}
|
||||
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
@@ -19,12 +19,13 @@ const extensionWidgetDefaultTitle = "Extension"
|
||||
|
||||
type extensionWidget struct {
|
||||
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 extension `yaml:"-"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
URL string `yaml:"url"`
|
||||
FallbackContentType string `yaml:"fallback-content-type"`
|
||||
Parameters queryParametersField `yaml:"parameters"`
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
Extension extension `yaml:"-"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *extensionWidget) initialize() error {
|
||||
@@ -46,6 +47,7 @@ func (widget *extensionWidget) update(ctx context.Context) {
|
||||
URL: widget.URL,
|
||||
FallbackContentType: widget.FallbackContentType,
|
||||
Parameters: widget.Parameters,
|
||||
Headers: widget.Headers,
|
||||
AllowHtml: widget.AllowHtml,
|
||||
})
|
||||
|
||||
@@ -57,6 +59,10 @@ func (widget *extensionWidget) update(ctx context.Context) {
|
||||
widget.Title = extension.Title
|
||||
}
|
||||
|
||||
if widget.TitleURL == "" && extension.TitleURL != "" {
|
||||
widget.TitleURL = extension.TitleURL
|
||||
}
|
||||
|
||||
widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate)
|
||||
}
|
||||
|
||||
@@ -67,8 +73,8 @@ func (widget *extensionWidget) Render() template.HTML {
|
||||
type extensionType int
|
||||
|
||||
const (
|
||||
extensionContentHTML extensionType = iota
|
||||
extensionContentUnknown = iota
|
||||
extensionContentHTML extensionType = iota
|
||||
extensionContentUnknown
|
||||
)
|
||||
|
||||
var extensionStringToType = map[string]extensionType{
|
||||
@@ -77,19 +83,22 @@ var extensionStringToType = map[string]extensionType{
|
||||
|
||||
const (
|
||||
extensionHeaderTitle = "Widget-Title"
|
||||
extensionHeaderTitleURL = "Widget-Title-URL"
|
||||
extensionHeaderContentType = "Widget-Content-Type"
|
||||
extensionHeaderContentFrameless = "Widget-Content-Frameless"
|
||||
)
|
||||
|
||||
type extensionRequestOptions struct {
|
||||
URL string `yaml:"url"`
|
||||
FallbackContentType string `yaml:"fallback-content-type"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
URL string `yaml:"url"`
|
||||
FallbackContentType string `yaml:"fallback-content-type"`
|
||||
Parameters queryParametersField `yaml:"parameters"`
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
}
|
||||
|
||||
type extension struct {
|
||||
Title string
|
||||
TitleURL string
|
||||
Content template.HTML
|
||||
Frameless bool
|
||||
}
|
||||
@@ -109,14 +118,13 @@ func convertExtensionContent(options extensionRequestOptions, content []byte, co
|
||||
|
||||
func fetchExtension(options extensionRequestOptions) (extension, error) {
|
||||
request, _ := http.NewRequest("GET", options.URL, nil)
|
||||
|
||||
query := url.Values{}
|
||||
|
||||
for key, value := range options.Parameters {
|
||||
query.Set(key, value)
|
||||
if len(options.Parameters) > 0 {
|
||||
request.URL.RawQuery = options.Parameters.toQueryString()
|
||||
}
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
for key, value := range options.Headers {
|
||||
request.Header.Add(key, value)
|
||||
}
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
@@ -140,6 +148,10 @@ func fetchExtension(options extensionRequestOptions) (extension, error) {
|
||||
extension.Title = response.Header.Get(extensionHeaderTitle)
|
||||
}
|
||||
|
||||
if response.Header.Get(extensionHeaderTitleURL) != "" {
|
||||
extension.TitleURL = response.Header.Get(extensionHeaderTitleURL)
|
||||
}
|
||||
|
||||
contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)]
|
||||
|
||||
if !ok {
|
||||
|
||||
@@ -8,17 +8,20 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var marketsWidgetTemplate = mustParseTemplate("markets.html", "widget-base.html")
|
||||
|
||||
type marketsWidget struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
StocksRequests []marketRequest `yaml:"stocks"`
|
||||
MarketRequests []marketRequest `yaml:"markets"`
|
||||
Sort string `yaml:"sort-by"`
|
||||
Markets marketList `yaml:"-"`
|
||||
widgetBase `yaml:",inline"`
|
||||
StocksRequests []marketRequest `yaml:"stocks"`
|
||||
MarketRequests []marketRequest `yaml:"markets"`
|
||||
ChartLinkTemplate string `yaml:"chart-link-template"`
|
||||
SymbolLinkTemplate string `yaml:"symbol-link-template"`
|
||||
Sort string `yaml:"sort-by"`
|
||||
Markets marketList `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *marketsWidget) initialize() error {
|
||||
@@ -29,6 +32,18 @@ func (widget *marketsWidget) initialize() error {
|
||||
widget.MarketRequests = widget.StocksRequests
|
||||
}
|
||||
|
||||
for i := range widget.MarketRequests {
|
||||
m := &widget.MarketRequests[i]
|
||||
|
||||
if widget.ChartLinkTemplate != "" && m.ChartLink == "" {
|
||||
m.ChartLink = strings.ReplaceAll(widget.ChartLinkTemplate, "{SYMBOL}", m.Symbol)
|
||||
}
|
||||
|
||||
if widget.SymbolLinkTemplate != "" && m.SymbolLink == "" {
|
||||
m.SymbolLink = strings.ReplaceAll(widget.SymbolLinkTemplate, "{SYMBOL}", m.Symbol)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -41,9 +56,7 @@ func (widget *marketsWidget) update(ctx context.Context) {
|
||||
|
||||
if widget.Sort == "absolute-change" {
|
||||
markets.sortByAbsChange()
|
||||
}
|
||||
|
||||
if widget.Sort == "change" {
|
||||
} else if widget.Sort == "change" {
|
||||
markets.sortByChange()
|
||||
}
|
||||
|
||||
@@ -55,7 +68,7 @@ func (widget *marketsWidget) Render() template.HTML {
|
||||
}
|
||||
|
||||
type marketRequest struct {
|
||||
Name string `yaml:"name"`
|
||||
CustomName string `yaml:"name"`
|
||||
Symbol string `yaml:"symbol"`
|
||||
ChartLink string `yaml:"chart-link"`
|
||||
SymbolLink string `yaml:"symbol-link"`
|
||||
@@ -63,8 +76,10 @@ type marketRequest struct {
|
||||
|
||||
type market struct {
|
||||
marketRequest
|
||||
Name string
|
||||
Currency string
|
||||
Price float64
|
||||
PriceHint int
|
||||
PercentChange float64
|
||||
SvgChartPoints string
|
||||
}
|
||||
@@ -91,6 +106,8 @@ type marketResponseJson struct {
|
||||
Symbol string `json:"symbol"`
|
||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
||||
ShortName string `json:"shortName"`
|
||||
PriceHint int `json:"priceHint"`
|
||||
} `json:"meta"`
|
||||
Indicators struct {
|
||||
Quote []struct {
|
||||
@@ -109,6 +126,7 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro
|
||||
|
||||
for i := range marketRequests {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
|
||||
setBrowserUserAgentHeader(request)
|
||||
requests = append(requests, request)
|
||||
}
|
||||
|
||||
@@ -136,13 +154,14 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro
|
||||
continue
|
||||
}
|
||||
|
||||
prices := response.Chart.Result[0].Indicators.Quote[0].Close
|
||||
result := &response.Chart.Result[0]
|
||||
prices := result.Indicators.Quote[0].Close
|
||||
|
||||
if len(prices) > marketChartDays {
|
||||
prices = prices[len(prices)-marketChartDays:]
|
||||
}
|
||||
|
||||
previous := response.Chart.Result[0].Meta.RegularMarketPrice
|
||||
previous := result.Meta.RegularMarketPrice
|
||||
|
||||
if len(prices) >= 2 && prices[len(prices)-2] != 0 {
|
||||
previous = prices[len(prices)-2]
|
||||
@@ -150,18 +169,22 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro
|
||||
|
||||
points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
||||
|
||||
currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
|
||||
|
||||
currency, exists := currencyToSymbol[strings.ToUpper(result.Meta.Currency)]
|
||||
if !exists {
|
||||
currency = response.Chart.Result[0].Meta.Currency
|
||||
currency = result.Meta.Currency
|
||||
}
|
||||
|
||||
markets = append(markets, market{
|
||||
marketRequest: marketRequests[i],
|
||||
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||
Price: result.Meta.RegularMarketPrice,
|
||||
Currency: currency,
|
||||
PriceHint: result.Meta.PriceHint,
|
||||
Name: ternary(marketRequests[i].CustomName == "",
|
||||
result.Meta.ShortName,
|
||||
marketRequests[i].CustomName,
|
||||
),
|
||||
PercentChange: percentChange(
|
||||
response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||
result.Meta.RegularMarketPrice,
|
||||
previous,
|
||||
),
|
||||
SvgChartPoints: points,
|
||||
|
||||
@@ -20,6 +20,8 @@ type monitorWidget struct {
|
||||
Sites []struct {
|
||||
*SiteStatusRequest `yaml:",inline"`
|
||||
Status *siteStatus `yaml:"-"`
|
||||
URL string `yaml:"-"`
|
||||
ErrorURL string `yaml:"error-url"`
|
||||
Title string `yaml:"title"`
|
||||
Icon customIconField `yaml:"icon"`
|
||||
SameTab bool `yaml:"same-tab"`
|
||||
@@ -58,14 +60,18 @@ func (widget *monitorWidget) update(ctx context.Context) {
|
||||
status := &statuses[i]
|
||||
site.Status = status
|
||||
|
||||
if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.TimedOut || status.Error != nil) {
|
||||
if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.Error != nil) {
|
||||
widget.HasFailing = true
|
||||
}
|
||||
|
||||
if !status.TimedOut {
|
||||
site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes)
|
||||
site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes)
|
||||
if status.Error != nil && site.ErrorURL != "" {
|
||||
site.URL = site.ErrorURL
|
||||
} else {
|
||||
site.URL = site.DefaultURL
|
||||
}
|
||||
|
||||
site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes)
|
||||
site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,12 +96,12 @@ func statusCodeToText(status int, altStatusCodes []int) string {
|
||||
if status == 401 {
|
||||
return "Unauthorized"
|
||||
}
|
||||
if status >= 400 {
|
||||
return "Client Error"
|
||||
}
|
||||
if status >= 500 {
|
||||
return "Server Error"
|
||||
}
|
||||
if status >= 400 {
|
||||
return "Client Error"
|
||||
}
|
||||
|
||||
return strconv.Itoa(status)
|
||||
}
|
||||
@@ -109,9 +115,13 @@ func statusCodeToStyle(status int, altStatusCodes []int) string {
|
||||
}
|
||||
|
||||
type SiteStatusRequest struct {
|
||||
URL string `yaml:"url"`
|
||||
DefaultURL string `yaml:"url"`
|
||||
CheckURL string `yaml:"check-url"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
BasicAuth struct {
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
} `yaml:"basic-auth"`
|
||||
}
|
||||
|
||||
type siteStatus struct {
|
||||
@@ -126,7 +136,7 @@ func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (siteStatus, error) {
|
||||
if statusRequest.CheckURL != "" {
|
||||
url = statusRequest.CheckURL
|
||||
} else {
|
||||
url = statusRequest.URL
|
||||
url = statusRequest.DefaultURL
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
@@ -135,6 +145,10 @@ func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (siteStatus, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
if statusRequest.BasicAuth.Username != "" || statusRequest.BasicAuth.Password != "" {
|
||||
request.SetBasicAuth(statusRequest.BasicAuth.Username, statusRequest.BasicAuth.Password)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||
defer cancel()
|
||||
request = request.WithContext(ctx)
|
||||
|
||||
86
internal/glance/widget-old-calendar.go
Normal file
86
internal/glance/widget-old-calendar.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
var oldCalendarWidgetTemplate = mustParseTemplate("old-calendar.html", "widget-base.html")
|
||||
|
||||
type oldCalendarWidget struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Calendar *calendar
|
||||
StartSunday bool `yaml:"start-sunday"`
|
||||
}
|
||||
|
||||
func (widget *oldCalendarWidget) initialize() error {
|
||||
widget.withTitle("Calendar").withCacheOnTheHour()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *oldCalendarWidget) update(ctx context.Context) {
|
||||
widget.Calendar = newCalendar(time.Now(), widget.StartSunday)
|
||||
widget.withError(nil).scheduleNextUpdate()
|
||||
}
|
||||
|
||||
func (widget *oldCalendarWidget) Render() template.HTML {
|
||||
return widget.renderTemplate(widget, oldCalendarWidgetTemplate)
|
||||
}
|
||||
|
||||
type calendar struct {
|
||||
CurrentDay int
|
||||
CurrentWeekNumber int
|
||||
CurrentMonthName string
|
||||
CurrentYear int
|
||||
Days []int
|
||||
}
|
||||
|
||||
// TODO: very inflexible, refactor to allow more customizability
|
||||
// TODO: allow changing between showing the previous and next week and the entire month
|
||||
func newCalendar(now time.Time, startSunday bool) *calendar {
|
||||
year, week := now.ISOWeek()
|
||||
weekday := now.Weekday()
|
||||
if !startSunday {
|
||||
weekday = (weekday + 6) % 7 // Shift Monday to 0
|
||||
}
|
||||
|
||||
currentMonthDays := daysInMonth(now.Month(), year)
|
||||
|
||||
var previousMonthDays int
|
||||
|
||||
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
|
||||
previousMonthDays = daysInMonth(12, year-1)
|
||||
} else {
|
||||
previousMonthDays = daysInMonth(previousMonthNumber, year)
|
||||
}
|
||||
|
||||
startDaysFrom := now.Day() - int(weekday) - 7
|
||||
|
||||
days := make([]int, 21)
|
||||
|
||||
for i := 0; i < 21; i++ {
|
||||
day := startDaysFrom + i
|
||||
|
||||
if day < 1 {
|
||||
day = previousMonthDays + day
|
||||
} else if day > currentMonthDays {
|
||||
day = day - currentMonthDays
|
||||
}
|
||||
|
||||
days[i] = day
|
||||
}
|
||||
|
||||
return &calendar{
|
||||
CurrentDay: now.Day(),
|
||||
CurrentWeekNumber: week,
|
||||
CurrentMonthName: now.Month().String(),
|
||||
CurrentYear: year,
|
||||
Days: days,
|
||||
}
|
||||
}
|
||||
|
||||
func daysInMonth(m time.Month, year int) int {
|
||||
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -19,19 +20,30 @@ var (
|
||||
|
||||
type redditWidget struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Posts forumPostList `yaml:"-"`
|
||||
Subreddit string `yaml:"subreddit"`
|
||||
Style string `yaml:"style"`
|
||||
ShowThumbnails bool `yaml:"show-thumbnails"`
|
||||
ShowFlairs bool `yaml:"show-flairs"`
|
||||
SortBy string `yaml:"sort-by"`
|
||||
TopPeriod string `yaml:"top-period"`
|
||||
Search string `yaml:"search"`
|
||||
ExtraSortBy string `yaml:"extra-sort-by"`
|
||||
CommentsUrlTemplate string `yaml:"comments-url-template"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
RequestUrlTemplate string `yaml:"request-url-template"`
|
||||
Posts forumPostList `yaml:"-"`
|
||||
Subreddit string `yaml:"subreddit"`
|
||||
Proxy proxyOptionsField `yaml:"proxy"`
|
||||
Style string `yaml:"style"`
|
||||
ShowThumbnails bool `yaml:"show-thumbnails"`
|
||||
ShowFlairs bool `yaml:"show-flairs"`
|
||||
SortBy string `yaml:"sort-by"`
|
||||
TopPeriod string `yaml:"top-period"`
|
||||
Search string `yaml:"search"`
|
||||
ExtraSortBy string `yaml:"extra-sort-by"`
|
||||
CommentsURLTemplate string `yaml:"comments-url-template"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
RequestURLTemplate string `yaml:"request-url-template"`
|
||||
|
||||
AppAuth struct {
|
||||
Name string `yaml:"name"`
|
||||
ID string `yaml:"id"`
|
||||
Secret string `yaml:"secret"`
|
||||
|
||||
enabled bool
|
||||
accessToken string
|
||||
tokenExpiresAt time.Time
|
||||
} `yaml:"app-auth"`
|
||||
}
|
||||
|
||||
func (widget *redditWidget) initialize() error {
|
||||
@@ -47,56 +59,40 @@ func (widget *redditWidget) initialize() error {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
if !isValidRedditSortType(widget.SortBy) {
|
||||
s := widget.SortBy
|
||||
if s != "hot" && s != "new" && s != "top" && s != "rising" {
|
||||
widget.SortBy = "hot"
|
||||
}
|
||||
|
||||
if !isValidRedditTopPeriod(widget.TopPeriod) {
|
||||
p := widget.TopPeriod
|
||||
if p != "hour" && p != "day" && p != "week" && p != "month" && p != "year" && p != "all" {
|
||||
widget.TopPeriod = "day"
|
||||
}
|
||||
|
||||
if widget.RequestUrlTemplate != "" {
|
||||
if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") {
|
||||
if widget.RequestURLTemplate != "" {
|
||||
if !strings.Contains(widget.RequestURLTemplate, "{REQUEST-URL}") {
|
||||
return errors.New("no `{REQUEST-URL}` placeholder specified")
|
||||
}
|
||||
}
|
||||
|
||||
a := &widget.AppAuth
|
||||
if a.Name != "" || a.ID != "" || a.Secret != "" {
|
||||
if a.Name == "" || a.ID == "" || a.Secret == "" {
|
||||
return errors.New("application name, client ID and client secret are required")
|
||||
}
|
||||
a.enabled = true
|
||||
}
|
||||
|
||||
widget.
|
||||
withTitle("/r/" + widget.Subreddit).
|
||||
withTitle("r/" + widget.Subreddit).
|
||||
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
|
||||
withCacheDuration(30 * time.Minute)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidRedditSortType(sortBy string) bool {
|
||||
return sortBy == "hot" ||
|
||||
sortBy == "new" ||
|
||||
sortBy == "top" ||
|
||||
sortBy == "rising"
|
||||
}
|
||||
|
||||
func isValidRedditTopPeriod(period string) bool {
|
||||
return period == "hour" ||
|
||||
period == "day" ||
|
||||
period == "week" ||
|
||||
period == "month" ||
|
||||
period == "year" ||
|
||||
period == "all"
|
||||
}
|
||||
|
||||
func (widget *redditWidget) update(ctx context.Context) {
|
||||
// TODO: refactor, use a struct to pass all of these
|
||||
posts, err := fetchSubredditPosts(
|
||||
widget.Subreddit,
|
||||
widget.SortBy,
|
||||
widget.TopPeriod,
|
||||
widget.Search,
|
||||
widget.CommentsUrlTemplate,
|
||||
widget.RequestUrlTemplate,
|
||||
widget.ShowFlairs,
|
||||
)
|
||||
|
||||
posts, err := widget.fetchSubredditPosts()
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
@@ -153,45 +149,70 @@ type subredditResponseJson struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
|
||||
template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
|
||||
func (widget *redditWidget) parseCustomCommentsURL(subreddit, postId, postPath string) string {
|
||||
template := strings.ReplaceAll(widget.CommentsURLTemplate, "{SUBREDDIT}", subreddit)
|
||||
template = strings.ReplaceAll(template, "{POST-ID}", postId)
|
||||
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (forumPostList, error) {
|
||||
func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) {
|
||||
var client requestDoer = defaultHTTPClient
|
||||
var baseURL string
|
||||
var requestURL string
|
||||
var headers http.Header
|
||||
query := url.Values{}
|
||||
var requestUrl string
|
||||
app := &widget.AppAuth
|
||||
|
||||
if search != "" {
|
||||
query.Set("q", search+" subreddit:"+subreddit)
|
||||
query.Set("sort", sort)
|
||||
}
|
||||
|
||||
if sort == "top" {
|
||||
query.Set("t", topPeriod)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
|
||||
if !app.enabled {
|
||||
baseURL = "https://www.reddit.com"
|
||||
headers = http.Header{
|
||||
"User-Agent": []string{getBrowserUserAgentHeader()},
|
||||
}
|
||||
} else {
|
||||
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
|
||||
baseURL = "https://oauth.reddit.com"
|
||||
|
||||
if app.accessToken == "" || time.Now().Add(time.Minute).After(app.tokenExpiresAt) {
|
||||
if err := widget.fetchNewAppAccessToken(); err != nil {
|
||||
return nil, fmt.Errorf("fetching new app access token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
headers = http.Header{
|
||||
"Authorization": []string{"Bearer " + app.accessToken},
|
||||
"User-Agent": []string{app.Name + "/1.0"},
|
||||
}
|
||||
}
|
||||
|
||||
if requestUrlTemplate != "" {
|
||||
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
|
||||
if widget.Limit > 25 {
|
||||
query.Set("limit", strconv.Itoa(widget.Limit))
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("GET", requestUrl, nil)
|
||||
if widget.Search != "" {
|
||||
query.Set("q", widget.Search+" subreddit:"+widget.Subreddit)
|
||||
query.Set("sort", widget.SortBy)
|
||||
requestURL = fmt.Sprintf("%s/search.json?%s", baseURL, query.Encode())
|
||||
} else {
|
||||
if widget.SortBy == "top" {
|
||||
query.Set("t", widget.TopPeriod)
|
||||
}
|
||||
requestURL = fmt.Sprintf("%s/r/%s/%s.json?%s", baseURL, widget.Subreddit, widget.SortBy, query.Encode())
|
||||
}
|
||||
|
||||
if widget.RequestURLTemplate != "" {
|
||||
requestURL = strings.ReplaceAll(widget.RequestURLTemplate, "{REQUEST-URL}", requestURL)
|
||||
} else if widget.Proxy.client != nil {
|
||||
client = widget.Proxy.client
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("GET", requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header = headers
|
||||
|
||||
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
|
||||
setBrowserUserAgentHeader(request)
|
||||
responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultHTTPClient, request)
|
||||
responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -211,10 +232,10 @@ func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
|
||||
|
||||
var commentsUrl string
|
||||
|
||||
if commentsUrlTemplate == "" {
|
||||
if widget.CommentsURLTemplate == "" {
|
||||
commentsUrl = "https://www.reddit.com" + post.Permalink
|
||||
} else {
|
||||
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
|
||||
commentsUrl = widget.parseCustomCommentsURL(widget.Subreddit, post.Id, post.Permalink)
|
||||
}
|
||||
|
||||
forumPost := forumPost{
|
||||
@@ -234,7 +255,7 @@ func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
|
||||
forumPost.TargetUrl = post.Url
|
||||
}
|
||||
|
||||
if showFlairs && post.Flair != "" {
|
||||
if widget.ShowFlairs && post.Flair != "" {
|
||||
forumPost.Tags = append(forumPost.Tags, post.Flair)
|
||||
}
|
||||
|
||||
@@ -242,11 +263,10 @@ func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
|
||||
forumPost.IsCrosspost = true
|
||||
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
|
||||
|
||||
if commentsUrlTemplate == "" {
|
||||
if widget.CommentsURLTemplate == "" {
|
||||
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
|
||||
} else {
|
||||
forumPost.TargetUrl = templateRedditCommentsURL(
|
||||
commentsUrlTemplate,
|
||||
forumPost.TargetUrl = widget.parseCustomCommentsURL(
|
||||
post.ParentList[0].Subreddit,
|
||||
post.ParentList[0].Id,
|
||||
post.ParentList[0].Permalink,
|
||||
@@ -259,3 +279,32 @@ func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (widget *redditWidget) fetchNewAppAccessToken() error {
|
||||
body := strings.NewReader("grant_type=client_credentials")
|
||||
req, err := http.NewRequest("POST", "https://www.reddit.com/api/v1/access_token", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request for app access token: %v", err)
|
||||
}
|
||||
|
||||
app := &widget.AppAuth
|
||||
req.SetBasicAuth(app.ID, app.Secret)
|
||||
req.Header.Add("User-Agent", app.Name+"/1.0")
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
client := ternary(widget.Proxy.client != nil, widget.Proxy.client, defaultHTTPClient)
|
||||
response, err := decodeJsonFromRequest[tokenResponse](client, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.accessToken = response.AccessToken
|
||||
app.tokenExpiresAt = time.Now().Add(time.Duration(response.ExpiresIn) * time.Second)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,20 +11,21 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var releasesWidgetTemplate = mustParseTemplate("releases.html", "widget-base.html")
|
||||
|
||||
type releasesWidget struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Releases appReleaseList `yaml:"-"`
|
||||
releaseRequests []*releaseRequest `yaml:"-"`
|
||||
Repositories []string `yaml:"repositories"`
|
||||
Token string `yaml:"token"`
|
||||
GitLabToken string `yaml:"gitlab-token"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
ShowSourceIcon bool `yaml:"show-source-icon"`
|
||||
widgetBase `yaml:",inline"`
|
||||
Releases appReleaseList `yaml:"-"`
|
||||
Repositories []*releaseRequest `yaml:"repositories"`
|
||||
Token string `yaml:"token"`
|
||||
GitLabToken string `yaml:"gitlab-token"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
ShowSourceIcon bool `yaml:"show-source-icon"`
|
||||
}
|
||||
|
||||
func (widget *releasesWidget) initialize() error {
|
||||
@@ -38,51 +39,21 @@ func (widget *releasesWidget) initialize() error {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
for _, repository := range widget.Repositories {
|
||||
parts := strings.SplitN(repository, ":", 2)
|
||||
var request *releaseRequest
|
||||
if len(parts) == 1 {
|
||||
request = &releaseRequest{
|
||||
source: releaseSourceGithub,
|
||||
repository: repository,
|
||||
}
|
||||
for i := range widget.Repositories {
|
||||
r := widget.Repositories[i]
|
||||
|
||||
if widget.Token != "" {
|
||||
request.token = &widget.Token
|
||||
}
|
||||
} else if len(parts) == 2 {
|
||||
if parts[0] == string(releaseSourceGitlab) {
|
||||
request = &releaseRequest{
|
||||
source: releaseSourceGitlab,
|
||||
repository: parts[1],
|
||||
}
|
||||
|
||||
if widget.GitLabToken != "" {
|
||||
request.token = &widget.GitLabToken
|
||||
}
|
||||
} else if parts[0] == string(releaseSourceDockerHub) {
|
||||
request = &releaseRequest{
|
||||
source: releaseSourceDockerHub,
|
||||
repository: parts[1],
|
||||
}
|
||||
} else if parts[0] == string(releaseSourceCodeberg) {
|
||||
request = &releaseRequest{
|
||||
source: releaseSourceCodeberg,
|
||||
repository: parts[1],
|
||||
}
|
||||
} else {
|
||||
return errors.New("invalid repository source " + parts[0])
|
||||
}
|
||||
if r.source == releaseSourceGithub && widget.Token != "" {
|
||||
r.token = &widget.Token
|
||||
} else if r.source == releaseSourceGitlab && widget.GitLabToken != "" {
|
||||
r.token = &widget.GitLabToken
|
||||
}
|
||||
|
||||
widget.releaseRequests = append(widget.releaseRequests, request)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *releasesWidget) update(ctx context.Context) {
|
||||
releases, err := fetchLatestReleases(widget.releaseRequests)
|
||||
releases, err := fetchLatestReleases(widget.Repositories)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
@@ -133,9 +104,53 @@ func (r appReleaseList) sortByNewest() appReleaseList {
|
||||
}
|
||||
|
||||
type releaseRequest struct {
|
||||
source releaseSource
|
||||
repository string
|
||||
token *string
|
||||
IncludePreleases bool `yaml:"include-prereleases"`
|
||||
Repository string `yaml:"repository"`
|
||||
|
||||
source releaseSource
|
||||
token *string
|
||||
}
|
||||
|
||||
func (r *releaseRequest) UnmarshalYAML(node *yaml.Node) error {
|
||||
type releaseRequestAlias releaseRequest
|
||||
alias := (*releaseRequestAlias)(r)
|
||||
var repository string
|
||||
|
||||
if err := node.Decode(&repository); err != nil {
|
||||
if err := node.Decode(alias); err != nil {
|
||||
return fmt.Errorf("could not umarshal repository into string or struct: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.Repository == "" {
|
||||
if repository == "" {
|
||||
return errors.New("repository is required")
|
||||
} else {
|
||||
r.Repository = repository
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.SplitN(repository, ":", 2)
|
||||
if len(parts) == 1 {
|
||||
r.source = releaseSourceGithub
|
||||
} else if len(parts) == 2 {
|
||||
r.Repository = parts[1]
|
||||
|
||||
switch parts[0] {
|
||||
case string(releaseSourceGithub):
|
||||
r.source = releaseSourceGithub
|
||||
case string(releaseSourceGitlab):
|
||||
r.source = releaseSourceGitlab
|
||||
case string(releaseSourceDockerHub):
|
||||
r.source = releaseSourceDockerHub
|
||||
case string(releaseSourceCodeberg):
|
||||
r.source = releaseSourceCodeberg
|
||||
default:
|
||||
return errors.New("invalid source")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchLatestReleases(requests []*releaseRequest) (appReleaseList, error) {
|
||||
@@ -152,7 +167,7 @@ func fetchLatestReleases(requests []*releaseRequest) (appReleaseList, error) {
|
||||
for i := range results {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to fetch release", "source", requests[i].source, "repository", requests[i].repository, "error", errs[i])
|
||||
slog.Error("Failed to fetch release", "source", requests[i].source, "repository", requests[i].Repository, "error", errs[i])
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -187,7 +202,7 @@ func fetchLatestReleaseTask(request *releaseRequest) (*appRelease, error) {
|
||||
return nil, errors.New("unsupported source")
|
||||
}
|
||||
|
||||
type githubReleaseLatestResponseJson struct {
|
||||
type githubReleaseResponseJson struct {
|
||||
TagName string `json:"tag_name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HtmlUrl string `json:"html_url"`
|
||||
@@ -197,12 +212,14 @@ type githubReleaseLatestResponseJson struct {
|
||||
}
|
||||
|
||||
func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) {
|
||||
httpRequest, err := http.NewRequest(
|
||||
"GET",
|
||||
fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.repository),
|
||||
nil,
|
||||
)
|
||||
var requestURL string
|
||||
if !request.IncludePreleases {
|
||||
requestURL = fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository)
|
||||
} else {
|
||||
requestURL = fmt.Sprintf("https://api.github.com/repos/%s/releases", request.Repository)
|
||||
}
|
||||
|
||||
httpRequest, err := http.NewRequest("GET", requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -211,14 +228,29 @@ func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) {
|
||||
httpRequest.Header.Add("Authorization", "Bearer "+(*request.token))
|
||||
}
|
||||
|
||||
response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultHTTPClient, httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var response githubReleaseResponseJson
|
||||
|
||||
if !request.IncludePreleases {
|
||||
response, err = decodeJsonFromRequest[githubReleaseResponseJson](defaultHTTPClient, httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
responses, err := decodeJsonFromRequest[[]githubReleaseResponseJson](defaultHTTPClient, httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(responses) == 0 {
|
||||
return nil, fmt.Errorf("no releases found for repository %s", request.Repository)
|
||||
}
|
||||
|
||||
response = responses[0]
|
||||
}
|
||||
|
||||
return &appRelease{
|
||||
Source: releaseSourceGithub,
|
||||
Name: request.repository,
|
||||
Name: request.Repository,
|
||||
Version: normalizeVersionFormat(response.TagName),
|
||||
NotesUrl: response.HtmlUrl,
|
||||
TimeReleased: parseRFC3339Time(response.PublishedAt),
|
||||
@@ -241,17 +273,15 @@ const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/reposito
|
||||
const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s"
|
||||
|
||||
func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) {
|
||||
|
||||
nameParts := strings.Split(request.repository, "/")
|
||||
nameParts := strings.Split(request.Repository, "/")
|
||||
|
||||
if len(nameParts) > 2 {
|
||||
return nil, fmt.Errorf("invalid repository name: %s", request.repository)
|
||||
return nil, fmt.Errorf("invalid repository name: %s", request.Repository)
|
||||
} else if len(nameParts) == 1 {
|
||||
nameParts = []string{"library", nameParts[0]}
|
||||
}
|
||||
|
||||
tagParts := strings.SplitN(nameParts[1], ":", 2)
|
||||
|
||||
var requestURL string
|
||||
|
||||
if len(tagParts) == 2 {
|
||||
@@ -278,7 +308,7 @@ func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) {
|
||||
}
|
||||
|
||||
if len(response.Results) == 0 {
|
||||
return nil, fmt.Errorf("no tags found for repository: %s", request.repository)
|
||||
return nil, fmt.Errorf("no tags found for repository: %s", request.Repository)
|
||||
}
|
||||
|
||||
tag = &response.Results[0]
|
||||
@@ -331,7 +361,7 @@ func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) {
|
||||
"GET",
|
||||
fmt.Sprintf(
|
||||
"https://gitlab.com/api/v4/projects/%s/releases/permalink/latest",
|
||||
url.QueryEscape(request.repository),
|
||||
url.QueryEscape(request.Repository),
|
||||
),
|
||||
nil,
|
||||
)
|
||||
@@ -350,7 +380,7 @@ func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) {
|
||||
|
||||
return &appRelease{
|
||||
Source: releaseSourceGitlab,
|
||||
Name: request.repository,
|
||||
Name: request.Repository,
|
||||
Version: normalizeVersionFormat(response.TagName),
|
||||
NotesUrl: response.Links.Self,
|
||||
TimeReleased: parseRFC3339Time(response.ReleasedAt),
|
||||
@@ -368,7 +398,7 @@ func fetchLatestCodebergRelease(request *releaseRequest) (*appRelease, error) {
|
||||
"GET",
|
||||
fmt.Sprintf(
|
||||
"https://codeberg.org/api/v1/repos/%s/releases/latest",
|
||||
request.repository,
|
||||
request.Repository,
|
||||
),
|
||||
nil,
|
||||
)
|
||||
@@ -383,7 +413,7 @@ func fetchLatestCodebergRelease(request *releaseRequest) (*appRelease, error) {
|
||||
|
||||
return &appRelease{
|
||||
Source: releaseSourceCodeberg,
|
||||
Name: request.repository,
|
||||
Name: request.Repository,
|
||||
Version: normalizeVersionFormat(response.TagName),
|
||||
NotesUrl: response.HtmlUrl,
|
||||
TimeReleased: parseRFC3339Time(response.PublishedAt),
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mmcdole/gofeed"
|
||||
@@ -25,21 +26,28 @@ var (
|
||||
rssWidgetHorizontalCards2Template = mustParseTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
||||
)
|
||||
|
||||
var feedParser = gofeed.NewParser()
|
||||
|
||||
type rssWidget struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
FeedRequests []rssFeedRequest `yaml:"feeds"`
|
||||
Style string `yaml:"style"`
|
||||
ThumbnailHeight float64 `yaml:"thumbnail-height"`
|
||||
CardHeight float64 `yaml:"card-height"`
|
||||
Items rssFeedItemList `yaml:"-"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
SingleLineTitles bool `yaml:"single-line-titles"`
|
||||
NoItemsMessage string `yaml:"-"`
|
||||
PreserveOrder bool `yaml:"preserve-order"`
|
||||
|
||||
Items rssFeedItemList `yaml:"-"`
|
||||
NoItemsMessage string `yaml:"-"`
|
||||
|
||||
feedCacheMutex sync.Mutex
|
||||
cachedFeeds map[string]*cachedRSSFeed `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *rssWidget) initialize() error {
|
||||
widget.withTitle("RSS Feed").withCacheDuration(1 * time.Hour)
|
||||
widget.withTitle("RSS Feed").withCacheDuration(2 * time.Hour)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 25
|
||||
@@ -64,17 +72,22 @@ func (widget *rssWidget) initialize() error {
|
||||
}
|
||||
|
||||
widget.NoItemsMessage = "No items were returned from the feeds."
|
||||
widget.cachedFeeds = make(map[string]*cachedRSSFeed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *rssWidget) update(ctx context.Context) {
|
||||
items, err := fetchItemsFromRSSFeeds(widget.FeedRequests)
|
||||
items, err := widget.fetchItemsFromFeeds()
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if !widget.PreserveOrder {
|
||||
items.sortByNewest()
|
||||
}
|
||||
|
||||
if len(items) > widget.Limit {
|
||||
items = items[:widget.Limit]
|
||||
}
|
||||
@@ -98,6 +111,12 @@ func (widget *rssWidget) Render() template.HTML {
|
||||
return widget.renderTemplate(widget, rssWidgetTemplate)
|
||||
}
|
||||
|
||||
type cachedRSSFeed struct {
|
||||
etag string
|
||||
lastModified string
|
||||
items []rssFeedItem
|
||||
}
|
||||
|
||||
type rssFeedItem struct {
|
||||
ChannelName string
|
||||
ChannelURL string
|
||||
@@ -109,40 +128,12 @@ type rssFeedItem struct {
|
||||
PublishedAt time.Time
|
||||
}
|
||||
|
||||
// doesn't cover all cases but works the vast majority of the time
|
||||
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
|
||||
|
||||
func sanitizeFeedDescription(description string) string {
|
||||
if description == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
description = strings.ReplaceAll(description, "\n", " ")
|
||||
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
|
||||
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
|
||||
description = strings.TrimSpace(description)
|
||||
description = html.UnescapeString(description)
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
func shortenFeedDescriptionLen(description string, maxLen int) string {
|
||||
description, _ = limitStringLength(description, 1000)
|
||||
description = sanitizeFeedDescription(description)
|
||||
description, limited := limitStringLength(description, maxLen)
|
||||
|
||||
if limited {
|
||||
description += "…"
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
type rssFeedRequest struct {
|
||||
URL string `yaml:"url"`
|
||||
Title string `yaml:"title"`
|
||||
HideCategories bool `yaml:"hide-categories"`
|
||||
HideDescription bool `yaml:"hide-description"`
|
||||
Limit int `yaml:"limit"`
|
||||
ItemLinkPrefix string `yaml:"item-link-prefix"`
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
IsDetailed bool `yaml:"-"`
|
||||
@@ -158,16 +149,68 @@ func (f rssFeedItemList) sortByNewest() rssFeedItemList {
|
||||
return f
|
||||
}
|
||||
|
||||
var feedParser = gofeed.NewParser()
|
||||
func (widget *rssWidget) fetchItemsFromFeeds() (rssFeedItemList, error) {
|
||||
requests := widget.FeedRequests
|
||||
|
||||
func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
|
||||
job := newJob(widget.fetchItemsFromFeedTask, requests).withWorkers(30)
|
||||
feeds, errs, err := workerPoolDo(job)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", errNoContent, err)
|
||||
}
|
||||
|
||||
failed := 0
|
||||
entries := make(rssFeedItemList, 0, len(feeds)*10)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for i := range feeds {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to get RSS feed", "url", requests[i].URL, "error", errs[i])
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range feeds[i] {
|
||||
if _, exists := seen[item.Link]; exists {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, item)
|
||||
seen[item.Link] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if failed == len(requests) {
|
||||
return nil, errNoContent
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return entries, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
|
||||
req, err := http.NewRequest("GET", request.URL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("User-Agent", glanceUserAgentString)
|
||||
|
||||
widget.feedCacheMutex.Lock()
|
||||
cache, isCached := widget.cachedFeeds[request.URL]
|
||||
if isCached {
|
||||
if cache.etag != "" {
|
||||
req.Header.Add("If-None-Match", cache.etag)
|
||||
}
|
||||
if cache.lastModified != "" {
|
||||
req.Header.Add("If-Modified-Since", cache.lastModified)
|
||||
}
|
||||
}
|
||||
widget.feedCacheMutex.Unlock()
|
||||
|
||||
for key, value := range request.Headers {
|
||||
req.Header.Add(key, value)
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := defaultHTTPClient.Do(req)
|
||||
@@ -176,6 +219,10 @@ func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotModified && isCached {
|
||||
return cache.items, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, request.URL)
|
||||
}
|
||||
@@ -190,6 +237,10 @@ func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if request.Limit > 0 && len(feed.Items) > request.Limit {
|
||||
feed.Items = feed.Items[:request.Limit]
|
||||
}
|
||||
|
||||
items := make(rssFeedItemList, 0, len(feed.Items))
|
||||
|
||||
for i := range feed.Items {
|
||||
@@ -279,9 +330,29 @@ func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
|
||||
items = append(items, rssItem)
|
||||
}
|
||||
|
||||
if resp.Header.Get("ETag") != "" || resp.Header.Get("Last-Modified") != "" {
|
||||
widget.feedCacheMutex.Lock()
|
||||
widget.cachedFeeds[request.URL] = &cachedRSSFeed{
|
||||
etag: resp.Header.Get("ETag"),
|
||||
lastModified: resp.Header.Get("Last-Modified"),
|
||||
items: items,
|
||||
}
|
||||
widget.feedCacheMutex.Unlock()
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func findThumbnailInItemExtensions(item *gofeed.Item) string {
|
||||
media, ok := item.Extensions["media"]
|
||||
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return recursiveFindThumbnailInExtensions(media)
|
||||
}
|
||||
|
||||
func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
|
||||
for _, exts := range extensions {
|
||||
for _, ext := range exts {
|
||||
@@ -302,46 +373,30 @@ func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extens
|
||||
return ""
|
||||
}
|
||||
|
||||
func findThumbnailInItemExtensions(item *gofeed.Item) string {
|
||||
media, ok := item.Extensions["media"]
|
||||
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
|
||||
|
||||
if !ok {
|
||||
func sanitizeFeedDescription(description string) string {
|
||||
if description == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return recursiveFindThumbnailInExtensions(media)
|
||||
description = strings.ReplaceAll(description, "\n", " ")
|
||||
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
|
||||
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
|
||||
description = strings.TrimSpace(description)
|
||||
description = html.UnescapeString(description)
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
func fetchItemsFromRSSFeeds(requests []rssFeedRequest) (rssFeedItemList, error) {
|
||||
job := newJob(fetchItemsFromRSSFeedTask, requests).withWorkers(30)
|
||||
feeds, errs, err := workerPoolDo(job)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", errNoContent, err)
|
||||
func shortenFeedDescriptionLen(description string, maxLen int) string {
|
||||
description, _ = limitStringLength(description, 1000)
|
||||
description = sanitizeFeedDescription(description)
|
||||
description, limited := limitStringLength(description, maxLen)
|
||||
|
||||
if limited {
|
||||
description += "…"
|
||||
}
|
||||
|
||||
failed := 0
|
||||
|
||||
entries := make(rssFeedItemList, 0, len(feeds)*10)
|
||||
|
||||
for i := range feeds {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to get RSS feed", "url", requests[i].URL, "error", errs[i])
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, feeds[i]...)
|
||||
}
|
||||
|
||||
if failed == len(requests) {
|
||||
return nil, errNoContent
|
||||
}
|
||||
|
||||
entries.sortByNewest()
|
||||
|
||||
if failed > 0 {
|
||||
return entries, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
return description
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type searchWidget struct {
|
||||
SearchEngine string `yaml:"search-engine"`
|
||||
Bangs []SearchBang `yaml:"bangs"`
|
||||
NewTab bool `yaml:"new-tab"`
|
||||
Target string `yaml:"target"`
|
||||
Autofocus bool `yaml:"autofocus"`
|
||||
Placeholder string `yaml:"placeholder"`
|
||||
}
|
||||
@@ -33,6 +34,10 @@ func convertSearchUrl(url string) string {
|
||||
var searchEngines = map[string]string{
|
||||
"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
|
||||
"google": "https://www.google.com/search?q={QUERY}",
|
||||
"bing": "https://www.bing.com/search?q={QUERY}",
|
||||
"perplexity": "https://www.perplexity.ai/search?q={QUERY}",
|
||||
"kagi": "https://kagi.com/search?q={QUERY}",
|
||||
"startpage": "https://www.startpage.com/search?q={QUERY}",
|
||||
}
|
||||
|
||||
func (widget *searchWidget) initialize() error {
|
||||
|
||||
117
internal/glance/widget-server-stats.go
Normal file
117
internal/glance/widget-server-stats.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/pkg/sysinfo"
|
||||
)
|
||||
|
||||
var serverStatsWidgetTemplate = mustParseTemplate("server-stats.html", "widget-base.html")
|
||||
|
||||
type serverStatsWidget struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Servers []serverStatsRequest `yaml:"servers"`
|
||||
}
|
||||
|
||||
func (widget *serverStatsWidget) initialize() error {
|
||||
widget.withTitle("Server Stats").withCacheDuration(15 * time.Second)
|
||||
widget.widgetBase.WIP = true
|
||||
|
||||
if len(widget.Servers) == 0 {
|
||||
widget.Servers = []serverStatsRequest{{Type: "local"}}
|
||||
}
|
||||
|
||||
for i := range widget.Servers {
|
||||
widget.Servers[i].URL = strings.TrimRight(widget.Servers[i].URL, "/")
|
||||
|
||||
if widget.Servers[i].Timeout == 0 {
|
||||
widget.Servers[i].Timeout = durationField(3 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *serverStatsWidget) update(context.Context) {
|
||||
// Refactor later, most of it may change depending on feedback
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range widget.Servers {
|
||||
serv := &widget.Servers[i]
|
||||
|
||||
if serv.Type == "local" {
|
||||
info, errs := sysinfo.Collect(serv.SystemInfoRequest)
|
||||
|
||||
if len(errs) > 0 {
|
||||
for i := range errs {
|
||||
slog.Warn("Getting system info: " + errs[i].Error())
|
||||
}
|
||||
}
|
||||
|
||||
serv.IsReachable = true
|
||||
serv.Info = info
|
||||
} else {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
info, err := fetchRemoteServerInfo(serv)
|
||||
if err != nil {
|
||||
slog.Warn("Getting remote system info: " + err.Error())
|
||||
serv.IsReachable = false
|
||||
serv.Info = &sysinfo.SystemInfo{
|
||||
Hostname: "Unnamed server #" + strconv.Itoa(i+1),
|
||||
}
|
||||
} else {
|
||||
serv.IsReachable = true
|
||||
serv.Info = info
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
widget.withError(nil).scheduleNextUpdate()
|
||||
}
|
||||
|
||||
func (widget *serverStatsWidget) Render() template.HTML {
|
||||
return widget.renderTemplate(widget, serverStatsWidgetTemplate)
|
||||
}
|
||||
|
||||
type serverStatsRequest struct {
|
||||
*sysinfo.SystemInfoRequest `yaml:",inline"`
|
||||
Info *sysinfo.SystemInfo `yaml:"-"`
|
||||
IsReachable bool `yaml:"-"`
|
||||
StatusText string `yaml:"-"`
|
||||
Name string `yaml:"name"`
|
||||
HideSwap bool `yaml:"hide-swap"`
|
||||
Type string `yaml:"type"`
|
||||
URL string `yaml:"url"`
|
||||
Token string `yaml:"token"`
|
||||
Timeout durationField `yaml:"timeout"`
|
||||
// Support for other agents
|
||||
// Provider string `yaml:"provider"`
|
||||
}
|
||||
|
||||
func fetchRemoteServerInfo(infoReq *serverStatsRequest) (*sysinfo.SystemInfo, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(infoReq.Timeout))
|
||||
defer cancel()
|
||||
|
||||
request, _ := http.NewRequestWithContext(ctx, "GET", infoReq.URL+"/api/sysinfo/all", nil)
|
||||
if infoReq.Token != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+infoReq.Token)
|
||||
}
|
||||
|
||||
info, err := decodeJsonFromRequest[*sysinfo.SystemInfo](defaultHTTPClient, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
@@ -196,6 +196,10 @@ func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) {
|
||||
slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This prevents live channels with 0 viewers from being
|
||||
// incorrectly sorted lower than offline channels
|
||||
result.ViewersCount = -1
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -21,6 +24,9 @@ var (
|
||||
const defaultClientTimeout = 5 * time.Second
|
||||
|
||||
var defaultHTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConnsPerHost: 10,
|
||||
},
|
||||
Timeout: defaultClientTimeout,
|
||||
}
|
||||
|
||||
@@ -35,8 +41,20 @@ type requestDoer interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
var glanceUserAgentString = "Glance/" + buildVersion + " +https://github.com/glanceapp/glance"
|
||||
var userAgentPersistentVersion atomic.Int32
|
||||
|
||||
func getBrowserUserAgentHeader() string {
|
||||
if rand.IntN(2000) == 0 {
|
||||
userAgentPersistentVersion.Store(rand.Int32N(5))
|
||||
}
|
||||
|
||||
version := strconv.Itoa(130 + int(userAgentPersistentVersion.Load()))
|
||||
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:" + version + ".0) Gecko/20100101 Firefox/" + version + ".0"
|
||||
}
|
||||
|
||||
func setBrowserUserAgentHeader(request *http.Request) {
|
||||
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0")
|
||||
request.Header.Set("User-Agent", getBrowserUserAgentHeader())
|
||||
}
|
||||
|
||||
func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) {
|
||||
@@ -57,7 +75,7 @@ func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T,
|
||||
truncatedBody, _ := limitStringLength(string(body), 256)
|
||||
|
||||
return result, fmt.Errorf(
|
||||
"unexpected status code %d for %s, response: %s",
|
||||
"unexpected status code %d from %s, response: %s",
|
||||
response.StatusCode,
|
||||
request.URL,
|
||||
truncatedBody,
|
||||
@@ -137,10 +155,8 @@ const defaultNumWorkers = 10
|
||||
func (job *workerPoolJob[I, O]) withWorkers(workers int) *workerPoolJob[I, O] {
|
||||
if workers == 0 {
|
||||
job.workers = defaultNumWorkers
|
||||
} else if workers > len(job.data) {
|
||||
job.workers = len(job.data)
|
||||
} else {
|
||||
job.workers = workers
|
||||
job.workers = min(workers, len(job.data))
|
||||
}
|
||||
|
||||
return job
|
||||
@@ -171,6 +187,11 @@ func workerPoolDo[I any, O any](job *workerPoolJob[I, O]) ([]O, []error, error)
|
||||
return results, errs, nil
|
||||
}
|
||||
|
||||
if len(job.data) == 1 {
|
||||
results[0], errs[0] = job.task(job.data[0])
|
||||
return results, errs, nil
|
||||
}
|
||||
|
||||
tasksQueue := make(chan *workerPoolTask[I, O])
|
||||
resultsQueue := make(chan *workerPoolTask[I, O])
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ import (
|
||||
const videosWidgetPlaylistPrefix = "playlist:"
|
||||
|
||||
var (
|
||||
videosWidgetTemplate = mustParseTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
||||
videosWidgetGridTemplate = mustParseTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
||||
videosWidgetTemplate = mustParseTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
||||
videosWidgetGridTemplate = mustParseTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
||||
videosWidgetVerticalListTemplate = mustParseTemplate("videos-vertical-list.html", "widget-base.html")
|
||||
)
|
||||
|
||||
type videosWidget struct {
|
||||
@@ -24,8 +25,10 @@ type videosWidget struct {
|
||||
Videos videoList `yaml:"-"`
|
||||
VideoUrlTemplate string `yaml:"video-url-template"`
|
||||
Style string `yaml:"style"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
CollapseAfterRows int `yaml:"collapse-after-rows"`
|
||||
Channels []string `yaml:"channels"`
|
||||
Playlists []string `yaml:"playlists"`
|
||||
Limit int `yaml:"limit"`
|
||||
IncludeShorts bool `yaml:"include-shorts"`
|
||||
}
|
||||
@@ -41,6 +44,22 @@ func (widget *videosWidget) initialize() error {
|
||||
widget.CollapseAfterRows = 4
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 7
|
||||
}
|
||||
|
||||
// A bit cheeky, but from a user's perspective it makes more sense when channels and
|
||||
// playlists are separate things rather than specifying a list of channels and some of
|
||||
// them awkwardly have a "playlist:" prefix
|
||||
if len(widget.Playlists) > 0 {
|
||||
initialLen := len(widget.Channels)
|
||||
widget.Channels = append(widget.Channels, make([]string, len(widget.Playlists))...)
|
||||
|
||||
for i := range widget.Playlists {
|
||||
widget.Channels[initialLen+i] = videosWidgetPlaylistPrefix + widget.Playlists[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -59,11 +78,18 @@ func (widget *videosWidget) update(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (widget *videosWidget) Render() template.HTML {
|
||||
if widget.Style == "grid-cards" {
|
||||
return widget.renderTemplate(widget, videosWidgetGridTemplate)
|
||||
var template *template.Template
|
||||
|
||||
switch widget.Style {
|
||||
case "grid-cards":
|
||||
template = videosWidgetGridTemplate
|
||||
case "vertical-list":
|
||||
template = videosWidgetVerticalListTemplate
|
||||
default:
|
||||
template = videosWidgetTemplate
|
||||
}
|
||||
|
||||
return widget.renderTemplate(widget, videosWidgetTemplate)
|
||||
return widget.renderTemplate(widget, template)
|
||||
}
|
||||
|
||||
type youtubeFeedResponseXml struct {
|
||||
|
||||
@@ -170,7 +170,7 @@ func parsePlaceName(name string) (string, string) {
|
||||
|
||||
func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, error) {
|
||||
location, area := parsePlaceName(location)
|
||||
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location))
|
||||
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=20&language=en&format=json", url.QueryEscape(location))
|
||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||
responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultHTTPClient, request)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,11 +18,17 @@ import (
|
||||
var widgetIDCounter atomic.Uint64
|
||||
|
||||
func newWidget(widgetType string) (widget, error) {
|
||||
if widgetType == "" {
|
||||
return nil, errors.New("widget 'type' property is empty or not specified")
|
||||
}
|
||||
|
||||
var w widget
|
||||
|
||||
switch widgetType {
|
||||
case "calendar":
|
||||
w = &calendarWidget{}
|
||||
case "calendar-legacy":
|
||||
w = &oldCalendarWidget{}
|
||||
case "clock":
|
||||
w = &clockWidget{}
|
||||
case "weather":
|
||||
@@ -71,6 +77,8 @@ func newWidget(widgetType string) (widget, error) {
|
||||
w = &customAPIWidget{}
|
||||
case "docker-containers":
|
||||
w = &dockerContainersWidget{}
|
||||
case "server-stats":
|
||||
w = &serverStatsWidget{}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||
}
|
||||
@@ -100,7 +108,7 @@ func (w *widgets) UnmarshalYAML(node *yaml.Node) error {
|
||||
|
||||
widget, err := newWidget(meta.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("line %d: %w", node.Line, err)
|
||||
}
|
||||
|
||||
if err = node.Decode(widget); err != nil {
|
||||
@@ -117,13 +125,13 @@ type widget interface {
|
||||
// These need to be exported because they get called in templates
|
||||
Render() template.HTML
|
||||
GetType() string
|
||||
GetID() uint64
|
||||
|
||||
initialize() error
|
||||
requiresUpdate(*time.Time) bool
|
||||
setProviders(*widgetProviders)
|
||||
update(context.Context)
|
||||
setID(uint64)
|
||||
id() uint64
|
||||
handleRequest(w http.ResponseWriter, r *http.Request)
|
||||
setHideHeader(bool)
|
||||
}
|
||||
@@ -145,6 +153,7 @@ type widgetBase struct {
|
||||
CSSClass string `yaml:"css-class"`
|
||||
CustomCacheDuration durationField `yaml:"cache"`
|
||||
ContentAvailable bool `yaml:"-"`
|
||||
WIP bool `yaml:"-"`
|
||||
Error error `yaml:"-"`
|
||||
Notice error `yaml:"-"`
|
||||
templateBuffer bytes.Buffer `yaml:"-"`
|
||||
@@ -171,11 +180,15 @@ func (w *widgetBase) requiresUpdate(now *time.Time) bool {
|
||||
return now.After(w.nextUpdate)
|
||||
}
|
||||
|
||||
func (w *widgetBase) IsWIP() bool {
|
||||
return w.WIP
|
||||
}
|
||||
|
||||
func (w *widgetBase) update(ctx context.Context) {
|
||||
|
||||
}
|
||||
|
||||
func (w *widgetBase) id() uint64 {
|
||||
func (w *widgetBase) GetID() uint64 {
|
||||
return w.ID
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user