mirror of
https://github.com/Xevion/glance.git
synced 2025-12-17 18:12:02 -06:00
Merge branch 'dev' into rss-feed-conditional-requests
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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package glance
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -13,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
|
||||
@@ -22,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 {
|
||||
@@ -44,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
|
||||
}
|
||||
@@ -53,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
|
||||
}
|
||||
@@ -62,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
|
||||
}
|
||||
@@ -71,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
|
||||
}
|
||||
@@ -115,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
|
||||
@@ -123,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]
|
||||
@@ -152,12 +163,12 @@ func newCustomIconField(value string) customIconField {
|
||||
}
|
||||
|
||||
if prefix == "di" {
|
||||
field.URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + 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/" + 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
|
||||
@@ -219,3 +230,58 @@ func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error {
|
||||
|
||||
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,13 +17,20 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -32,6 +39,7 @@ type config struct {
|
||||
|
||||
Theme struct {
|
||||
BackgroundColor *hslColorField `yaml:"background-color"`
|
||||
BackgroundColorAsHex string `yaml:"-"`
|
||||
PrimaryColor *hslColorField `yaml:"primary-color"`
|
||||
PositiveColor *hslColorField `yaml:"positive-color"`
|
||||
NegativeColor *hslColorField `yaml:"negative-color"`
|
||||
@@ -42,11 +50,14 @@ 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"`
|
||||
@@ -56,6 +67,7 @@ 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"`
|
||||
@@ -69,7 +81,7 @@ type page struct {
|
||||
}
|
||||
|
||||
func newConfigFromYAML(contents []byte) (*config, error) {
|
||||
contents, err := parseConfigEnvVariables(contents)
|
||||
contents, err := parseConfigVariables(contents)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -99,23 +111,33 @@ func newConfigFromYAML(contents []byte) (*config, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// TODO: change the pattern so that it doesn't match commented out lines
|
||||
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:]
|
||||
@@ -124,13 +146,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 {
|
||||
@@ -140,33 +169,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
|
||||
@@ -181,13 +267,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)))
|
||||
})
|
||||
|
||||
@@ -308,7 +395,7 @@ func configFilesWatcher(
|
||||
|
||||
// wait for file to maybe get created again
|
||||
// see https://github.com/glanceapp/glance/pull/358
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
if _, err := os.Stat(event.Name); err == nil {
|
||||
break
|
||||
}
|
||||
@@ -352,36 +439,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
|
||||
}()
|
||||
|
||||
@@ -18,13 +18,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
|
||||
}
|
||||
@@ -32,6 +38,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),
|
||||
@@ -40,7 +47,7 @@ func newApplication(config *config) (*application, error) {
|
||||
app.slugToPage[""] = &config.Pages[0]
|
||||
|
||||
providers := &widgetProviders{
|
||||
assetResolver: app.AssetPath,
|
||||
assetResolver: app.StaticAssetPath,
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -59,6 +66,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]
|
||||
|
||||
@@ -78,15 +93,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
|
||||
}
|
||||
@@ -116,7 +154,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
|
||||
}
|
||||
@@ -210,10 +248,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
|
||||
@@ -230,9 +273,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)
|
||||
@@ -246,7 +309,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()
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
295
internal/glance/static/css/site.css
Normal file
295
internal/glance/static/css/site.css
Normal file
@@ -0,0 +1,295 @@
|
||||
.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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -104,6 +104,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];
|
||||
@@ -143,7 +144,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;
|
||||
}
|
||||
@@ -649,7 +650,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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,6 +5,7 @@ import (
|
||||
"html/template"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
@@ -27,9 +28,10 @@ var globalTemplateFunctions = template.FuncMap{
|
||||
"formatPrice": func(price float64) string {
|
||||
return intl.Sprintf("%.2f", price)
|
||||
},
|
||||
"dynamicRelativeTimeAttrs": func(t interface{ Unix() int64 }) 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
|
||||
@@ -52,6 +54,7 @@ var globalTemplateFunctions = template.FuncMap{
|
||||
|
||||
return template.HTML(value + ` <span class="color-base size-h5">` + label + `</span>`)
|
||||
},
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
}
|
||||
|
||||
func mustParseTemplate(primary string, dependencies ...string) *template.Template {
|
||||
@@ -81,3 +84,7 @@ func formatApproxNumber(count int) string {
|
||||
|
||||
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 .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
{{- if .Description }}
|
||||
<div class="margin-bottom-5">{{ .Description }}</div>
|
||||
{{- end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{{- 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 }}
|
||||
</ul>
|
||||
@@ -22,11 +22,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-width-0">
|
||||
<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">{{ .Title }}</a>
|
||||
<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">{{ .Title }}</div>
|
||||
<div class="color-highlight text-truncate size-title-dynamic">{{ .Name }}</div>
|
||||
{{- end }}
|
||||
{{- if .Description }}
|
||||
<div class="text-truncate">{{ .Description }}</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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 }}
|
||||
|
||||
@@ -11,13 +11,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 }}
|
||||
@@ -32,7 +32,7 @@
|
||||
{{ 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" 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>
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-bounds grow">
|
||||
<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>
|
||||
|
||||
@@ -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 }}">
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -190,3 +183,58 @@ func ternary[T any](condition bool, a, b T) T {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,10 @@ type bookmarksWidget struct {
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@ package glance
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@@ -10,6 +11,11 @@ import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -17,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 == "" {
|
||||
@@ -46,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
|
||||
}
|
||||
@@ -73,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
|
||||
@@ -118,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))
|
||||
|
||||
@@ -134,7 +316,7 @@ func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedG
|
||||
}
|
||||
|
||||
func (r *decoratedGJSONResult) Exists(key string) bool {
|
||||
return r.Get(key).Exists()
|
||||
return r.Result.Get(key).Exists()
|
||||
}
|
||||
|
||||
func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
|
||||
@@ -142,7 +324,7 @@ func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
|
||||
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 {
|
||||
@@ -150,7 +332,7 @@ 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) int {
|
||||
@@ -158,7 +340,7 @@ func (r *decoratedGJSONResult) Int(key string) int {
|
||||
return int(r.Result.Int())
|
||||
}
|
||||
|
||||
return int(r.Get(key).Int())
|
||||
return int(r.Result.Get(key).Int())
|
||||
}
|
||||
|
||||
func (r *decoratedGJSONResult) Float(key string) float64 {
|
||||
@@ -166,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 {
|
||||
@@ -174,10 +356,72 @@ 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 int) float64 {
|
||||
return float64(a)
|
||||
@@ -185,21 +429,145 @@ var customAPITemplateFuncs = func() template.FuncMap {
|
||||
"toInt": func(a float64) int {
|
||||
return int(a)
|
||||
},
|
||||
"add": func(a, b float64) float64 {
|
||||
return a + b
|
||||
"add": func(a, b any) any {
|
||||
return doMathOpWithAny(a, b, "add")
|
||||
},
|
||||
"sub": func(a, b float64) float64 {
|
||||
return a - b
|
||||
"sub": func(a, b any) any {
|
||||
return doMathOpWithAny(a, b, "sub")
|
||||
},
|
||||
"mul": func(a, b float64) float64 {
|
||||
return a * b
|
||||
"mul": func(a, b any) any {
|
||||
return doMathOpWithAny(a, b, "mul")
|
||||
},
|
||||
"div": func(a, b float64) float64 {
|
||||
if b == 0 {
|
||||
return math.NaN()
|
||||
"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
|
||||
}
|
||||
|
||||
return a / b
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -211,3 +579,30 @@ var customAPITemplateFuncs = func() template.FuncMap {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -43,9 +43,10 @@ type dnsStatsWidget struct {
|
||||
}
|
||||
|
||||
const (
|
||||
dnsServiceAdguard = "adguard"
|
||||
dnsServicePihole = "pihole"
|
||||
dnsServicePiholeV6 = "pihole-v6"
|
||||
dnsServiceAdguard = "adguard"
|
||||
dnsServicePihole = "pihole"
|
||||
dnsServiceTechnitium = "technitium"
|
||||
dnsServicePiholeV6 = "pihole-v6"
|
||||
)
|
||||
|
||||
func makeDNSWidgetTimeLabels(format string) [8]string {
|
||||
@@ -60,17 +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)
|
||||
|
||||
switch widget.Service {
|
||||
case dnsServiceAdguard:
|
||||
case dnsServicePiholeV6:
|
||||
case dnsServicePihole:
|
||||
case dnsServiceTechnitium:
|
||||
default:
|
||||
return fmt.Errorf("service must be one of: %s, %s, %s", dnsServiceAdguard, dnsServicePihole, dnsServicePiholeV6)
|
||||
return fmt.Errorf("service must be one of: %s, %s, %s, %s", dnsServiceAdguard, dnsServicePihole, dnsServicePiholeV6, dnsServiceTechnitium)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -85,6 +93,8 @@ func (widget *dnsStatsWidget) update(ctx context.Context) {
|
||||
stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, 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(
|
||||
@@ -672,3 +682,139 @@ func checkPiholeSessionIDIsValid(instanceURL string, client *http.Client, sessio
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -54,6 +66,7 @@ const (
|
||||
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 {
|
||||
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: 5 * 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 {
|
||||
|
||||
@@ -79,6 +79,7 @@ type market struct {
|
||||
Name string
|
||||
Currency string
|
||||
Price float64
|
||||
PriceHint int
|
||||
PercentChange float64
|
||||
SvgChartPoints string
|
||||
}
|
||||
@@ -106,6 +107,7 @@ type marketResponseJson struct {
|
||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
||||
ShortName string `json:"shortName"`
|
||||
PriceHint int `json:"priceHint"`
|
||||
} `json:"meta"`
|
||||
Indicators struct {
|
||||
Quote []struct {
|
||||
@@ -152,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]
|
||||
@@ -166,21 +169,22 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro
|
||||
|
||||
points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
||||
|
||||
currency, exists := currencyToSymbol[strings.ToUpper(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 == "",
|
||||
response.Chart.Result[0].Meta.ShortName,
|
||||
result.Meta.ShortName,
|
||||
marketRequests[i].CustomName,
|
||||
),
|
||||
PercentChange: percentChange(
|
||||
response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||
result.Meta.RegularMarketPrice,
|
||||
previous,
|
||||
),
|
||||
SvgChartPoints: points,
|
||||
|
||||
@@ -118,6 +118,10 @@ type SiteStatusRequest struct {
|
||||
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 {
|
||||
@@ -141,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)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -29,10 +30,20 @@ type redditWidget struct {
|
||||
TopPeriod string `yaml:"top-period"`
|
||||
Search string `yaml:"search"`
|
||||
ExtraSortBy string `yaml:"extra-sort-by"`
|
||||
CommentsUrlTemplate string `yaml:"comments-url-template"`
|
||||
CommentsURLTemplate string `yaml:"comments-url-template"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
RequestUrlTemplate string `yaml:"request-url-template"`
|
||||
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 {
|
||||
@@ -48,20 +59,30 @@ 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).
|
||||
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
|
||||
@@ -70,35 +91,8 @@ func (widget *redditWidget) initialize() error {
|
||||
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.Proxy.client,
|
||||
widget.ShowFlairs,
|
||||
)
|
||||
|
||||
posts, err := widget.fetchSubredditPosts()
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
@@ -155,57 +149,69 @@ 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,
|
||||
proxyClient *http.Client,
|
||||
showFlairs bool,
|
||||
) (forumPostList, error) {
|
||||
query := url.Values{}
|
||||
var requestUrl string
|
||||
|
||||
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())
|
||||
} else {
|
||||
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
|
||||
}
|
||||
|
||||
func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) {
|
||||
var client requestDoer = defaultHTTPClient
|
||||
var baseURL string
|
||||
var requestURL string
|
||||
var headers http.Header
|
||||
query := url.Values{}
|
||||
app := &widget.AppAuth
|
||||
|
||||
if requestUrlTemplate != "" {
|
||||
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
|
||||
} else if proxyClient != nil {
|
||||
client = proxyClient
|
||||
if !app.enabled {
|
||||
baseURL = "https://www.reddit.com"
|
||||
headers = http.Header{
|
||||
"User-Agent": []string{getBrowserUserAgentHeader()},
|
||||
}
|
||||
} else {
|
||||
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"},
|
||||
}
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("GET", requestUrl, nil)
|
||||
if widget.Limit > 25 {
|
||||
query.Set("limit", strconv.Itoa(widget.Limit))
|
||||
}
|
||||
|
||||
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](client, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -226,10 +232,10 @@ func fetchSubredditPosts(
|
||||
|
||||
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{
|
||||
@@ -249,7 +255,7 @@ func fetchSubredditPosts(
|
||||
forumPost.TargetUrl = post.Url
|
||||
}
|
||||
|
||||
if showFlairs && post.Flair != "" {
|
||||
if widget.ShowFlairs && post.Flair != "" {
|
||||
forumPost.Tags = append(forumPost.Tags, post.Flair)
|
||||
}
|
||||
|
||||
@@ -257,11 +263,10 @@ func fetchSubredditPosts(
|
||||
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,
|
||||
@@ -274,3 +279,32 @@ func fetchSubredditPosts(
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,9 @@ var (
|
||||
const defaultClientTimeout = 5 * time.Second
|
||||
|
||||
var defaultHTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConnsPerHost: 10,
|
||||
},
|
||||
Timeout: defaultClientTimeout,
|
||||
}
|
||||
|
||||
@@ -41,13 +44,17 @@ type requestDoer interface {
|
||||
var glanceUserAgentString = "Glance/" + buildVersion + " +https://github.com/glanceapp/glance"
|
||||
var userAgentPersistentVersion atomic.Int32
|
||||
|
||||
func setBrowserUserAgentHeader(request *http.Request) {
|
||||
func getBrowserUserAgentHeader() string {
|
||||
if rand.IntN(2000) == 0 {
|
||||
userAgentPersistentVersion.Store(rand.Int32N(5))
|
||||
}
|
||||
|
||||
version := strconv.Itoa(130 + int(userAgentPersistentVersion.Load()))
|
||||
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:"+version+".0) Gecko/20100101 Firefox/"+version+".0")
|
||||
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", getBrowserUserAgentHeader())
|
||||
}
|
||||
|
||||
func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) {
|
||||
@@ -68,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,
|
||||
@@ -148,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
|
||||
@@ -182,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])
|
||||
|
||||
|
||||
@@ -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,6 +18,10 @@ 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 {
|
||||
@@ -104,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 {
|
||||
|
||||
Reference in New Issue
Block a user