mirror of
https://github.com/glanceapp/glance.git
synced 2025-06-25 12:21:41 +02:00
Update implementation
This commit is contained in:
parent
a2d8410fec
commit
c2286f9a22
@ -322,41 +322,39 @@ Example:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
theme:
|
theme:
|
||||||
background-color: 186 21 20
|
background-color: 100 20 10
|
||||||
contrast-multiplier: 1.2
|
primary-color: 40 90 40
|
||||||
primary-color: 97 13 80
|
contrast-multiplier: 1.1
|
||||||
|
|
||||||
presets:
|
presets:
|
||||||
my-custom-dark-theme:
|
gruvbox-dark:
|
||||||
background-color: 229 19 23
|
background-color: 0 0 16
|
||||||
contrast-multiplier: 1.2
|
primary-color: 43 59 81
|
||||||
primary-color: 222 74 74
|
positive-color: 61 66 44
|
||||||
positive-color: 96 44 68
|
negative-color: 6 96 59
|
||||||
negative-color: 359 68 71
|
|
||||||
my-custom-light-theme:
|
zebra:
|
||||||
light: true
|
light: true
|
||||||
background-color: 220 23 95
|
background-color: 0 0 95
|
||||||
contrast-multiplier: 1.0
|
primary-color: 0 0 10
|
||||||
primary-color: 220 91 54
|
negative-color: 0 90 50
|
||||||
positive-color: 109 58 40
|
|
||||||
negative-color: 347 87 44
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available themes
|
### Available themes
|
||||||
If you don't want to spend time configuring your own theme, there are [several available themes](themes.md) which you can simply copy the values for.
|
If you don't want to spend time configuring your own theme, there are [several available themes](themes.md) which you can simply copy the values for.
|
||||||
|
|
||||||
### Properties
|
### Properties
|
||||||
| Name | Type | Required | Default |
|
| Name | Type | Required | Default |
|
||||||
| ---- |-------|----------| ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| light | boolean | no | false |
|
| light | boolean | no | false |
|
||||||
| background-color | HSL | no | 240 8 9 |
|
| background-color | HSL | no | 240 8 9 |
|
||||||
| primary-color | HSL | no | 43 50 70 |
|
| primary-color | HSL | no | 43 50 70 |
|
||||||
| positive-color | HSL | no | same as `primary-color` |
|
| positive-color | HSL | no | same as `primary-color` |
|
||||||
| negative-color | HSL | no | 0 70 70 |
|
| negative-color | HSL | no | 0 70 70 |
|
||||||
| contrast-multiplier | number | no | 1 |
|
| contrast-multiplier | number | no | 1 |
|
||||||
| text-saturation-multiplier | number | no | 1 |
|
| text-saturation-multiplier | number | no | 1 |
|
||||||
| custom-css-file | string | no | |
|
| custom-css-file | string | no | |
|
||||||
| presets | array | no | |
|
| presets | object | no | |
|
||||||
|
|
||||||
#### `light`
|
#### `light`
|
||||||
Whether the scheme is light or dark. This does not change the background color, it inverts the text colors so that they look appropriately on a light background.
|
Whether the scheme is light or dark. This does not change the background color, it inverts the text colors so that they look appropriately on a light background.
|
||||||
@ -389,26 +387,6 @@ theme:
|
|||||||
custom-css-file: /assets/my-style.css
|
custom-css-file: /assets/my-style.css
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `presets`
|
|
||||||
Define theme presets that can be selected from a dropdown menu in the webpage. Example:
|
|
||||||
```yaml
|
|
||||||
theme:
|
|
||||||
presets:
|
|
||||||
my-custom-dark-theme: # This will be displayed in the dropdown menu to select this theme
|
|
||||||
background-color: 229 19 23
|
|
||||||
contrast-multiplier: 1.2
|
|
||||||
primary-color: 222 74 74
|
|
||||||
positive-color: 96 44 68
|
|
||||||
negative-color: 359 68 71
|
|
||||||
my-custom-light-theme: # This will be displayed in the dropdown menu to select this theme
|
|
||||||
light: true
|
|
||||||
background-color: 220 23 95
|
|
||||||
contrast-multiplier: 1.0
|
|
||||||
primary-color: 220 91 54
|
|
||||||
positive-color: 109 58 40
|
|
||||||
negative-color: 347 87 44
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
> Because Glance uses a lot of utility classes it might be difficult to target some elements. To make it easier to style specific widgets, each widget has a `widget-type-{name}` class, so for example if you wanted to make the links inside just the RSS widget bigger you could use the following selector:
|
> Because Glance uses a lot of utility classes it might be difficult to target some elements. To make it easier to style specific widgets, each widget has a `widget-type-{name}` class, so for example if you wanted to make the links inside just the RSS widget bigger you could use the following selector:
|
||||||
@ -421,6 +399,30 @@ theme:
|
|||||||
>
|
>
|
||||||
> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets.
|
> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets.
|
||||||
|
|
||||||
|
#### `presets`
|
||||||
|
Define additional theme presets that can be selected from the theme switcher on the page. For each preset, you can specify the same properties as for the default theme, such as `background-color`, `primary-color`, `positive-color`, `negative-color`, `contrast-multiplier`, etc., except for the `custom-css-file` property.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
presets:
|
||||||
|
my-custom-dark-theme:
|
||||||
|
background-color: 229 19 23
|
||||||
|
contrast-multiplier: 1.2
|
||||||
|
primary-color: 222 74 74
|
||||||
|
positive-color: 96 44 68
|
||||||
|
negative-color: 359 68 71
|
||||||
|
my-custom-light-theme:
|
||||||
|
light: true
|
||||||
|
background-color: 220 23 95
|
||||||
|
contrast-multiplier: 1.1
|
||||||
|
primary-color: 220 91 54
|
||||||
|
positive-color: 109 58 40
|
||||||
|
negative-color: 347 87 44
|
||||||
|
```
|
||||||
|
|
||||||
|
To override the default dark and light themes, use the key names `default-dark` and `default-light`.
|
||||||
|
|
||||||
## Pages & Columns
|
## Pages & Columns
|
||||||

|

|
||||||
@ -451,7 +453,6 @@ pages:
|
|||||||
| desktop-navigation-width | string | no | |
|
| desktop-navigation-width | string | no | |
|
||||||
| center-vertically | boolean | no | false |
|
| center-vertically | boolean | no | false |
|
||||||
| hide-desktop-navigation | boolean | no | false |
|
| hide-desktop-navigation | boolean | no | false |
|
||||||
| expand-mobile-page-navigation | boolean | no | false |
|
|
||||||
| show-mobile-header | boolean | no | false |
|
| show-mobile-header | boolean | no | false |
|
||||||
| columns | array | yes | |
|
| columns | array | yes | |
|
||||||
|
|
||||||
@ -483,9 +484,6 @@ When set to `true`, vertically centers the content on the page. Has no effect if
|
|||||||
#### `hide-desktop-navigation`
|
#### `hide-desktop-navigation`
|
||||||
Whether to show the navigation links at the top of the page on desktop.
|
Whether to show the navigation links at the top of the page on desktop.
|
||||||
|
|
||||||
#### `expand-mobile-page-navigation`
|
|
||||||
Whether the mobile page navigation should be expanded by default.
|
|
||||||
|
|
||||||
#### `show-mobile-header`
|
#### `show-mobile-header`
|
||||||
Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices.
|
Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices.
|
||||||
|
|
||||||
|
@ -23,17 +23,27 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type hslColorField struct {
|
type hslColorField struct {
|
||||||
Hue float64
|
H float64
|
||||||
Saturation float64
|
S float64
|
||||||
Lightness float64
|
L float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *hslColorField) String() string {
|
func (c *hslColorField) String() string {
|
||||||
return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", c.Hue, c.Saturation, c.Lightness)
|
return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", c.H, c.S, c.L)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *hslColorField) ToHex() string {
|
func (c *hslColorField) ToHex() string {
|
||||||
return hslToHex(c.Hue, c.Saturation, c.Lightness)
|
return hslToHex(c.H, c.S, c.L)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c1 *hslColorField) SameAs(c2 *hslColorField) bool {
|
||||||
|
if c1 == nil && c2 == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if c1 == nil || c2 == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return c1.H == c2.H && c1.S == c2.S && c1.L == c2.L
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||||
@ -76,9 +86,9 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
|
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Hue = hue
|
c.H = hue
|
||||||
c.Saturation = saturation
|
c.S = saturation
|
||||||
c.Lightness = lightness
|
c.L = lightness
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"iter"
|
||||||
"log"
|
"log"
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
@ -38,18 +39,9 @@ type config struct {
|
|||||||
} `yaml:"document"`
|
} `yaml:"document"`
|
||||||
|
|
||||||
Theme struct {
|
Theme struct {
|
||||||
// Todo : Find a way to use CssProperties struct to avoid duplicates
|
themeProperties `yaml:",inline"`
|
||||||
BackgroundColor *hslColorField `yaml:"background-color"`
|
CustomCSSFile string `yaml:"custom-css-file"`
|
||||||
BackgroundColorAsHex string `yaml:"-"`
|
Presets orderedYAMLMap[string, *themeProperties] `yaml:"presets"`
|
||||||
PrimaryColor *hslColorField `yaml:"primary-color"`
|
|
||||||
PositiveColor *hslColorField `yaml:"positive-color"`
|
|
||||||
NegativeColor *hslColorField `yaml:"negative-color"`
|
|
||||||
Light bool `yaml:"light"`
|
|
||||||
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
|
||||||
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
|
||||||
CustomCSSFile string `yaml:"custom-css-file"`
|
|
||||||
|
|
||||||
Presets map[string]CssProperties `yaml:"presets"`
|
|
||||||
} `yaml:"theme"`
|
} `yaml:"theme"`
|
||||||
|
|
||||||
Branding struct {
|
Branding struct {
|
||||||
@ -66,26 +58,15 @@ type config struct {
|
|||||||
Pages []page `yaml:"pages"`
|
Pages []page `yaml:"pages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CssProperties struct {
|
|
||||||
BackgroundColor *hslColorField `yaml:"background-color"`
|
|
||||||
PrimaryColor *hslColorField `yaml:"primary-color"`
|
|
||||||
PositiveColor *hslColorField `yaml:"positive-color"`
|
|
||||||
NegativeColor *hslColorField `yaml:"negative-color"`
|
|
||||||
Light bool `yaml:"light"`
|
|
||||||
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
|
||||||
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type page struct {
|
type page struct {
|
||||||
Title string `yaml:"name"`
|
Title string `yaml:"name"`
|
||||||
Slug string `yaml:"slug"`
|
Slug string `yaml:"slug"`
|
||||||
Width string `yaml:"width"`
|
Width string `yaml:"width"`
|
||||||
DesktopNavigationWidth string `yaml:"desktop-navigation-width"`
|
DesktopNavigationWidth string `yaml:"desktop-navigation-width"`
|
||||||
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
||||||
ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"`
|
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
||||||
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
CenterVertically bool `yaml:"center-vertically"`
|
||||||
CenterVertically bool `yaml:"center-vertically"`
|
Columns []struct {
|
||||||
Columns []struct {
|
|
||||||
Size string `yaml:"size"`
|
Size string `yaml:"size"`
|
||||||
Widgets widgets `yaml:"widgets"`
|
Widgets widgets `yaml:"widgets"`
|
||||||
} `yaml:"columns"`
|
} `yaml:"columns"`
|
||||||
@ -503,3 +484,103 @@ func isConfigStateValid(config *config) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read-only way to store ordered maps from a YAML structure
|
||||||
|
type orderedYAMLMap[K comparable, V any] struct {
|
||||||
|
keys []K
|
||||||
|
data map[K]V
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOrderedYAMLMap[K comparable, V any](keys []K, values []V) (*orderedYAMLMap[K, V], error) {
|
||||||
|
if len(keys) != len(values) {
|
||||||
|
return nil, fmt.Errorf("keys and values must have the same length")
|
||||||
|
}
|
||||||
|
|
||||||
|
om := &orderedYAMLMap[K, V]{
|
||||||
|
keys: make([]K, len(keys)),
|
||||||
|
data: make(map[K]V, len(keys)),
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(om.keys, keys)
|
||||||
|
|
||||||
|
for i := range keys {
|
||||||
|
om.data[keys[i]] = values[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return om, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (om *orderedYAMLMap[K, V]) Items() iter.Seq2[K, V] {
|
||||||
|
return func(yield func(K, V) bool) {
|
||||||
|
for _, key := range om.keys {
|
||||||
|
value, ok := om.data[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !yield(key, value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (om *orderedYAMLMap[K, V]) Get(key K) (V, bool) {
|
||||||
|
value, ok := om.data[key]
|
||||||
|
return value, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *orderedYAMLMap[K, V]) Merge(other *orderedYAMLMap[K, V]) *orderedYAMLMap[K, V] {
|
||||||
|
merged := &orderedYAMLMap[K, V]{
|
||||||
|
keys: make([]K, 0, len(self.keys)+len(other.keys)),
|
||||||
|
data: make(map[K]V, len(self.data)+len(other.data)),
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.keys = append(merged.keys, self.keys...)
|
||||||
|
maps.Copy(merged.data, self.data)
|
||||||
|
|
||||||
|
for _, key := range other.keys {
|
||||||
|
if _, exists := self.data[key]; !exists {
|
||||||
|
merged.keys = append(merged.keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maps.Copy(merged.data, other.data)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func (om *orderedYAMLMap[K, V]) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
if node.Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("orderedMap: expected mapping node, got %d", node.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(node.Content)%2 != 0 {
|
||||||
|
return fmt.Errorf("orderedMap: expected even number of content items, got %d", len(node.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
om.keys = make([]K, len(node.Content)/2)
|
||||||
|
om.data = make(map[K]V, len(node.Content)/2)
|
||||||
|
|
||||||
|
for i := 0; i < len(node.Content); i += 2 {
|
||||||
|
keyNode := node.Content[i]
|
||||||
|
valueNode := node.Content[i+1]
|
||||||
|
|
||||||
|
var key K
|
||||||
|
if err := keyNode.Decode(&key); err != nil {
|
||||||
|
return fmt.Errorf("orderedMap: decoding key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := om.data[key]; ok {
|
||||||
|
return fmt.Errorf("orderedMap: duplicate key %v", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
var value V
|
||||||
|
if err := valueNode.Decode(&value); err != nil {
|
||||||
|
return fmt.Errorf("orderedMap: decoding value: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
(*om).keys[i/2] = key
|
||||||
|
(*om).data[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -82,7 +82,6 @@ func computeFSHash(files fs.FS) (string, error) {
|
|||||||
|
|
||||||
var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`)
|
var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`)
|
||||||
var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`)
|
var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`)
|
||||||
var whitespaceAtBeginningOfLinePattern = regexp.MustCompile(`(?m)^\s+`)
|
|
||||||
|
|
||||||
// Yes, we bundle at runtime, give comptime pls
|
// Yes, we bundle at runtime, give comptime pls
|
||||||
var bundledCSSContents = func() []byte {
|
var bundledCSSContents = func() []byte {
|
||||||
|
@ -3,9 +3,7 @@ package glance
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -16,19 +14,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
pageTemplate = mustParseTemplate("page.html", "document.html")
|
pageTemplate = mustParseTemplate("page.html", "document.html")
|
||||||
pageContentTemplate = mustParseTemplate("page-content.html")
|
pageContentTemplate = mustParseTemplate("page-content.html")
|
||||||
pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
|
manifestTemplate = mustParseTemplate("manifest.json")
|
||||||
manifestTemplate = mustParseTemplate("manifest.json")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour
|
const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour
|
||||||
|
|
||||||
type application struct {
|
type application struct {
|
||||||
Version string
|
Version string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Config config
|
Config config
|
||||||
ParsedThemeStyle template.HTML
|
|
||||||
|
|
||||||
parsedManifest []byte
|
parsedManifest []byte
|
||||||
|
|
||||||
@ -36,14 +32,15 @@ type application struct {
|
|||||||
widgetByID map[uint64]widget
|
widgetByID map[uint64]widget
|
||||||
}
|
}
|
||||||
|
|
||||||
func newApplication(config *config) (*application, error) {
|
func newApplication(c *config) (*application, error) {
|
||||||
app := &application{
|
app := &application{
|
||||||
Version: buildVersion,
|
Version: buildVersion,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
Config: *config,
|
Config: *c,
|
||||||
slugToPage: make(map[string]*page),
|
slugToPage: make(map[string]*page),
|
||||||
widgetByID: make(map[uint64]widget),
|
widgetByID: make(map[uint64]widget),
|
||||||
}
|
}
|
||||||
|
config := &app.Config
|
||||||
|
|
||||||
app.slugToPage[""] = &config.Pages[0]
|
app.slugToPage[""] = &config.Pages[0]
|
||||||
|
|
||||||
@ -51,10 +48,43 @@ func newApplication(config *config) (*application, error) {
|
|||||||
assetResolver: app.StaticAssetPath,
|
assetResolver: app.StaticAssetPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
//
|
||||||
app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme)
|
// Init themes
|
||||||
|
//
|
||||||
|
|
||||||
|
themeKeys := make([]string, 0, 2)
|
||||||
|
themeProps := make([]*themeProperties, 0, 2)
|
||||||
|
|
||||||
|
defaultDarkTheme, ok := config.Theme.Presets.Get("default-dark")
|
||||||
|
if ok && !config.Theme.SameAs(defaultDarkTheme) || !config.Theme.SameAs(&themeProperties{}) {
|
||||||
|
themeKeys = append(themeKeys, "default-dark")
|
||||||
|
themeProps = append(themeProps, &themeProperties{})
|
||||||
|
}
|
||||||
|
|
||||||
|
themeKeys = append(themeKeys, "default-light")
|
||||||
|
themeProps = append(themeProps, &themeProperties{
|
||||||
|
Light: true,
|
||||||
|
BackgroundColor: &hslColorField{240, 13, 86},
|
||||||
|
PrimaryColor: &hslColorField{45, 100, 26},
|
||||||
|
NegativeColor: &hslColorField{0, 50, 50},
|
||||||
|
})
|
||||||
|
|
||||||
|
themePresets, err := newOrderedYAMLMap(themeKeys, themeProps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parsing theme style: %v", err)
|
return nil, fmt.Errorf("creating theme presets: %v", err)
|
||||||
|
}
|
||||||
|
config.Theme.Presets = *themePresets.Merge(&config.Theme.Presets)
|
||||||
|
|
||||||
|
for key, properties := range config.Theme.Presets.Items() {
|
||||||
|
properties.Key = key
|
||||||
|
if err := properties.init(); err != nil {
|
||||||
|
return nil, fmt.Errorf("initializing preset theme %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Theme.Key = "default"
|
||||||
|
if err := config.Theme.init(); err != nil {
|
||||||
|
return nil, fmt.Errorf("initializing default theme: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for p := range config.Pages {
|
for p := range config.Pages {
|
||||||
@ -91,18 +121,10 @@ func newApplication(config *config) (*application, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config = &app.Config
|
|
||||||
|
|
||||||
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
||||||
config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
||||||
config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL)
|
config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL)
|
||||||
|
|
||||||
if config.Theme.BackgroundColor != nil {
|
|
||||||
config.Theme.BackgroundColorAsHex = config.Theme.BackgroundColor.ToHex()
|
|
||||||
} else {
|
|
||||||
config.Theme.BackgroundColorAsHex = "#151519"
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.Branding.FaviconURL == "" {
|
if config.Branding.FaviconURL == "" {
|
||||||
config.Branding.FaviconURL = app.StaticAssetPath("favicon.png")
|
config.Branding.FaviconURL = app.StaticAssetPath("favicon.png")
|
||||||
} else {
|
} else {
|
||||||
@ -121,11 +143,11 @@ func newApplication(config *config) (*application, error) {
|
|||||||
config.Branding.AppBackgroundColor = config.Theme.BackgroundColorAsHex
|
config.Branding.AppBackgroundColor = config.Theme.BackgroundColorAsHex
|
||||||
}
|
}
|
||||||
|
|
||||||
var manifestWriter bytes.Buffer
|
manifest, err := executeTemplateToString(manifestTemplate, pageTemplateData{App: app})
|
||||||
if err := manifestTemplate.Execute(&manifestWriter, pageTemplateData{App: app}); err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parsing manifest.json: %v", err)
|
return nil, fmt.Errorf("parsing manifest.json: %v", err)
|
||||||
}
|
}
|
||||||
app.parsedManifest = manifestWriter.Bytes()
|
app.parsedManifest = []byte(manifest)
|
||||||
|
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
@ -163,10 +185,28 @@ func (a *application) resolveUserDefinedAssetPath(path string) string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type pageTemplateRequestData struct {
|
||||||
|
Theme *themeProperties
|
||||||
|
}
|
||||||
|
|
||||||
type pageTemplateData struct {
|
type pageTemplateData struct {
|
||||||
App *application
|
App *application
|
||||||
Page *page
|
Page *page
|
||||||
Presets string
|
Request pageTemplateRequestData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) populateTemplateRequestData(data *pageTemplateRequestData, r *http.Request) {
|
||||||
|
theme := &a.Config.Theme.themeProperties
|
||||||
|
|
||||||
|
selectedTheme, err := r.Cookie("theme")
|
||||||
|
if err == nil {
|
||||||
|
preset, exists := a.Config.Theme.Presets.Get(selectedTheme.Value)
|
||||||
|
if exists {
|
||||||
|
theme = preset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Theme = theme
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
|
func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -177,24 +217,14 @@ func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
presets := a.Config.Theme.Presets
|
data := pageTemplateData{
|
||||||
keys := make([]string, 0, len(presets))
|
Page: page,
|
||||||
for key := range presets {
|
App: a,
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
presetsAsJSON, jsonErr := json.Marshal(presets)
|
|
||||||
if jsonErr != nil {
|
|
||||||
log.Fatalf("Erreur lors de la conversion en JSON : %v", jsonErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
pageData := pageTemplateData{
|
|
||||||
App: a,
|
|
||||||
Page: page,
|
|
||||||
Presets: string(presetsAsJSON),
|
|
||||||
}
|
}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
|
||||||
var responseBytes bytes.Buffer
|
var responseBytes bytes.Buffer
|
||||||
err := pageTemplate.Execute(&responseBytes, pageData)
|
err := pageTemplate.Execute(&responseBytes, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte(err.Error()))
|
w.Write([]byte(err.Error()))
|
||||||
@ -279,6 +309,7 @@ func (a *application) server() (func() error, func() error) {
|
|||||||
mux.HandleFunc("GET /{page}", a.handlePageRequest)
|
mux.HandleFunc("GET /{page}", a.handlePageRequest)
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
|
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
|
||||||
|
mux.HandleFunc("POST /api/set-theme/{key}", a.handleThemeChangeRequest)
|
||||||
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
|
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
|
||||||
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
@ -48,6 +48,17 @@
|
|||||||
transition: transform .3s;
|
transition: transform .3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-actions > * {
|
||||||
|
padding-block: .9rem;
|
||||||
|
padding-inline: var(--content-bounds-padding);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-actions > *:hover, .mobile-navigation-actions > *:active {
|
||||||
|
background-color: var(--color-widget-background-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) .hamburger-icon {
|
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) .hamburger-icon {
|
||||||
--spacing: 7px;
|
--spacing: 7px;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
@ -60,6 +71,7 @@
|
|||||||
|
|
||||||
.mobile-navigation-page-links {
|
.mobile-navigation-page-links {
|
||||||
border-top: 1px solid var(--color-widget-content-border);
|
border-top: 1px solid var(--color-widget-content-border);
|
||||||
|
border-bottom: 1px solid var(--color-widget-content-border);
|
||||||
padding: 20px var(--content-bounds-padding);
|
padding: 20px var(--content-bounds-padding);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -1,25 +1,4 @@
|
|||||||
.dark {
|
:root[data-scheme=light] {
|
||||||
--scheme: ;
|
|
||||||
--bgh: 240;
|
|
||||||
--bgs: 8%;
|
|
||||||
--bgl: 9%;
|
|
||||||
--bghs: var(--bgh), var(--bgs);
|
|
||||||
--cm: 1;
|
|
||||||
--tsm: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.light {
|
|
||||||
--scheme: 100% -;
|
|
||||||
--bgh: 240;
|
|
||||||
--bgs: 50%;
|
|
||||||
--bgl: 98%;
|
|
||||||
--bghs: var(--bgh), var(--bgs);
|
|
||||||
--cm: 1;
|
|
||||||
--tsm: 1;
|
|
||||||
--color-primary: hsl(43, 50%, 70%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.light-scheme {
|
|
||||||
--scheme: 100% -;
|
--scheme: 100% -;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,7 +219,7 @@ kbd:active {
|
|||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-center-vertically .page {
|
.page.center-vertically {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -277,6 +256,8 @@ kbd:active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
|
overflow-x: auto;
|
||||||
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
gap: var(--header-items-gap);
|
gap: var(--header-items-gap);
|
||||||
}
|
}
|
||||||
@ -315,82 +296,72 @@ kbd:active {
|
|||||||
color: var(--color-text-highlight);
|
color: var(--color-text-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
.theme-choices {
|
||||||
### Theme Dropdown ###
|
--presets-per-row: 2;
|
||||||
*/
|
display: grid;
|
||||||
.theme-dropdown {
|
grid-template-columns: repeat(var(--presets-per-row), 1fr);
|
||||||
position: relative;
|
align-items: center;
|
||||||
display: inline-block;
|
gap: 1.35rem;
|
||||||
right: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-button {
|
.theme-choices:has(> :nth-child(3)) {
|
||||||
padding: 10px 15px;
|
--presets-per-row: 3;
|
||||||
background: var(--color-widget-background);
|
}
|
||||||
border: 1px solid var(--color-widget-content-border);
|
|
||||||
border-radius: 4px;
|
.theme-preset {
|
||||||
cursor: pointer;
|
background-color: var(--color);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: center;
|
||||||
min-width: 150px;
|
gap: 0.5rem;
|
||||||
transition: border-color .2s;
|
height: 2rem;
|
||||||
color: var(--color-text-highlight);
|
padding-inline: 0.5rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-button:hover {
|
.theme-choices .theme-preset::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -.4rem;
|
||||||
|
border-radius: .7rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-choices .theme-preset:hover::before {
|
||||||
border-color: var(--color-text-subdue);
|
border-color: var(--color-text-subdue);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-content {
|
.theme-choices .theme-preset.current::before {
|
||||||
display: none;
|
border-color: var(--color-text-base);
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
background: var(--color-widget-content-border);
|
|
||||||
min-width: 150px;
|
|
||||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
|
||||||
border-radius: 4px;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-navigation-page-links .dropdown-content {
|
.theme-preset-light {
|
||||||
top: unset;
|
gap: 0.3rem;
|
||||||
bottom: 38px;
|
height: 1.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-content.show {
|
.theme-color {
|
||||||
display: block;
|
background-color: var(--color);
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-option {
|
.theme-preset-light .theme-color {
|
||||||
padding: 10px 15px;
|
width: 1rem;
|
||||||
cursor: pointer;
|
height: 1rem;
|
||||||
transition: background-color 0.2s;
|
border-radius: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-option:hover {
|
.current-theme-preview {
|
||||||
background-color: #f8f9fa;
|
opacity: 0.4;
|
||||||
|
transition: opacity .3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.theme-picker.popover-active .current-theme-preview, .theme-picker:hover {
|
||||||
height: 1px;
|
opacity: 1;
|
||||||
background-color: #dee2e6;
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow {
|
|
||||||
border: solid #666;
|
|
||||||
border-width: 0 2px 2px 0;
|
|
||||||
display: inline-block;
|
|
||||||
padding: 3px;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
margin-left: auto;
|
|
||||||
position: relative;
|
|
||||||
top: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-button.active .arrow {
|
|
||||||
transform: rotate(-135deg);
|
|
||||||
}
|
}
|
||||||
|
@ -376,7 +376,7 @@ details[open] .summary::after {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root:not(.light-scheme) .flat-icon {
|
:root:not([data-scheme=light]) .flat-icon {
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,6 +459,23 @@ details[open] .summary::after {
|
|||||||
filter: none;
|
filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.hide-scrollbars {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide on Safari and Chrome */
|
||||||
|
.hide-scrollbars::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-icon {
|
||||||
|
width: 2.3rem;
|
||||||
|
height: 2.3rem;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.size-h1 { font-size: var(--font-size-h1); }
|
.size-h1 { font-size: var(--font-size-h1); }
|
||||||
.size-h2 { font-size: var(--font-size-h2); }
|
.size-h2 { font-size: var(--font-size-h2); }
|
||||||
.size-h3 { font-size: var(--font-size-h3); }
|
.size-h3 { font-size: var(--font-size-h3); }
|
||||||
@ -510,6 +527,7 @@ details[open] .summary::after {
|
|||||||
.grow { flex-grow: 1; }
|
.grow { flex-grow: 1; }
|
||||||
.flex-column { flex-direction: column; }
|
.flex-column { flex-direction: column; }
|
||||||
.items-center { align-items: center; }
|
.items-center { align-items: center; }
|
||||||
|
.self-center { align-self: center; }
|
||||||
.items-start { align-items: start; }
|
.items-start { align-items: start; }
|
||||||
.items-end { align-items: end; }
|
.items-end { align-items: end; }
|
||||||
.gap-5 { gap: 0.5rem; }
|
.gap-5 { gap: 0.5rem; }
|
||||||
@ -549,6 +567,7 @@ details[open] .summary::after {
|
|||||||
.padding-widget { padding: var(--widget-content-padding); }
|
.padding-widget { padding: var(--widget-content-padding); }
|
||||||
.padding-block-widget { padding-block: var(--widget-content-vertical-padding); }
|
.padding-block-widget { padding-block: var(--widget-content-vertical-padding); }
|
||||||
.padding-inline-widget { padding-inline: var(--widget-content-horizontal-padding); }
|
.padding-inline-widget { padding-inline: var(--widget-content-horizontal-padding); }
|
||||||
|
.pointer-events-none { pointer-events: none; }
|
||||||
.padding-block-5 { padding-block: 0.5rem; }
|
.padding-block-5 { padding-block: 0.5rem; }
|
||||||
.scale-half { transform: scale(0.5); }
|
.scale-half { transform: scale(0.5); }
|
||||||
.list { --list-half-gap: 0rem; }
|
.list { --list-half-gap: 0rem; }
|
||||||
|
@ -1,30 +1,7 @@
|
|||||||
import { setupPopovers } from './popover.js';
|
import { setupPopovers } from './popover.js';
|
||||||
import { setupMasonries } from './masonry.js';
|
import { setupMasonries } from './masonry.js';
|
||||||
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
|
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
|
||||||
|
import { elem, find, findAll } from './templating.js';
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const theme = localStorage.getItem('theme');
|
|
||||||
|
|
||||||
if (!theme) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = document.querySelector('html');
|
|
||||||
const jsonTheme = JSON.parse(theme);
|
|
||||||
if (jsonTheme.themeScheme === 'light') {
|
|
||||||
html.classList.remove('dark-scheme');
|
|
||||||
html.classList.add('light-scheme');
|
|
||||||
} else if (jsonTheme.themeScheme === 'dark') {
|
|
||||||
html.classList.add('dark-scheme');
|
|
||||||
html.classList.remove('light-scheme');
|
|
||||||
}
|
|
||||||
|
|
||||||
html.classList.add(jsonTheme.theme);
|
|
||||||
document.querySelector('[name=color-scheme]').setAttribute('content', jsonTheme.themeScheme);
|
|
||||||
Array.from(document.querySelectorAll('.dropdown-button span')).forEach((button) => {
|
|
||||||
button.textContent = jsonTheme.theme;
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function fetchPageContent(pageData) {
|
async function fetchPageContent(pageData) {
|
||||||
// TODO: handle non 200 status codes/time outs
|
// TODO: handle non 200 status codes/time outs
|
||||||
@ -678,102 +655,77 @@ function setupTruncatedElementTitles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function changeTheme(key, onChanged) {
|
||||||
* @typedef {Object} HslColorField
|
const themeStyleElem = find("#theme-style");
|
||||||
* @property {number} Hue
|
|
||||||
* @property {number} Saturation
|
|
||||||
* @property {number} Lightness
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
const response = await fetch(`${pageData.baseURL}/api/set-theme/${key}`, {
|
||||||
* @typedef {Object} Theme
|
method: "POST",
|
||||||
* @property {HslColorField} BackgroundColor
|
|
||||||
* @property {HslColorField} PrimaryColor
|
|
||||||
* @property {HslColorField} PositiveColor
|
|
||||||
* @property {HslColorField} NegativeColor
|
|
||||||
* @property {boolean} Light
|
|
||||||
* @property {number} ContrastMultiplier
|
|
||||||
* @property {number} TextSaturationMultiplier
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Record<string, Theme>} ThemeCollection
|
|
||||||
*/
|
|
||||||
function setupThemeSwitcher() {
|
|
||||||
const presetsContainers = Array.from(document.querySelectorAll('.custom-presets'));
|
|
||||||
const userThemesKeys = Object.keys(userThemes);
|
|
||||||
|
|
||||||
presetsContainers.forEach((presetsContainer) => {
|
|
||||||
userThemesKeys.forEach(preset => {
|
|
||||||
const presetElement = document.createElement('div');
|
|
||||||
presetElement.className = 'theme-option';
|
|
||||||
presetElement.setAttribute('data-theme', preset);
|
|
||||||
presetElement.setAttribute('data-scheme', userThemes[preset].Light ? 'light' : 'dark');
|
|
||||||
presetElement.textContent = preset;
|
|
||||||
presetsContainer.appendChild(presetElement);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const dropdownButtons = Array.from(document.querySelectorAll('.dropdown-button'));
|
if (response.status != 200) {
|
||||||
const dropdownContents = Array.from(document.querySelectorAll('.dropdown-content'));
|
alert("Failed to set theme: " + response.statusText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newThemeStyle = await response.text();
|
||||||
|
|
||||||
dropdownButtons.forEach((dropdownButton) => {
|
const tempStyle = elem("style")
|
||||||
dropdownButton.addEventListener('click', (e) => {
|
.html("* { transition: none !important; }")
|
||||||
e.stopPropagation();
|
.appendTo(document.head);
|
||||||
dropdownContents.forEach((dropdownContent) => {
|
|
||||||
dropdownContent.classList.toggle('show');
|
|
||||||
});
|
|
||||||
dropdownButton.classList.toggle('active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', (e) => {
|
themeStyleElem.html(newThemeStyle);
|
||||||
if (!e.target.closest('.theme-dropdown')) {
|
document.documentElement.setAttribute("data-scheme", response.headers.get("X-Scheme"));
|
||||||
dropdownContents.forEach((dropdownContent) => {
|
typeof onChanged == "function" && onChanged();
|
||||||
dropdownContent.classList.remove('show');
|
setTimeout(() => { tempStyle.remove(); }, 10);
|
||||||
});
|
}
|
||||||
dropdownButtons.forEach((dropdownButton) => {
|
|
||||||
dropdownButton.classList.remove('active');
|
function initThemeSwitcher() {
|
||||||
});
|
find(".mobile-navigation .theme-choices").replaceWith(
|
||||||
|
find(".header-container .theme-choices").cloneNode(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
const presetElems = findAll(".theme-choices .theme-preset");
|
||||||
|
let themePreviewElems = document.getElementsByClassName("current-theme-preview");
|
||||||
|
let isLoading = false;
|
||||||
|
|
||||||
|
presetElems.forEach((presetElement) => {
|
||||||
|
const themeKey = presetElement.dataset.key;
|
||||||
|
|
||||||
|
if (themeKey === undefined) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.theme-option').forEach(option => {
|
if (themeKey == pageData.theme) {
|
||||||
option.addEventListener('click', () => {
|
presetElement.classList.add("current");
|
||||||
const selectedTheme = option.getAttribute('data-theme');
|
}
|
||||||
const selectedThemeScheme = option.getAttribute('data-scheme');
|
|
||||||
const previousTheme = localStorage.getItem('theme');
|
|
||||||
dropdownContents.forEach((dropdownContent) => {
|
|
||||||
dropdownContent.classList.remove('show');
|
|
||||||
});
|
|
||||||
dropdownButtons.forEach((dropdownButton) => {
|
|
||||||
const html = document.querySelector('html');
|
|
||||||
if (previousTheme) {
|
|
||||||
html.classList.remove(JSON.parse(previousTheme).theme);
|
|
||||||
}
|
|
||||||
dropdownButton.classList.remove('active');
|
|
||||||
dropdownButton.querySelector('span').textContent = option.textContent;
|
|
||||||
html.classList.add(selectedTheme);
|
|
||||||
|
|
||||||
if (selectedThemeScheme === 'light') {
|
presetElement.addEventListener("click", () => {
|
||||||
html.classList.remove('dark-scheme');
|
if (themeKey == pageData.theme) return;
|
||||||
html.classList.add('light-scheme');
|
if (isLoading) return;
|
||||||
} else if (selectedThemeScheme === 'dark') {
|
|
||||||
html.classList.add('dark-scheme');
|
|
||||||
html.classList.remove('light-scheme');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelector('[name=color-scheme]').setAttribute('content', selectedThemeScheme);
|
isLoading = true;
|
||||||
localStorage.setItem('theme', JSON.stringify({
|
changeTheme(themeKey, function() {
|
||||||
theme: selectedTheme,
|
isLoading = false;
|
||||||
themeScheme: selectedThemeScheme
|
pageData.theme = themeKey;
|
||||||
}));
|
presetElems.forEach((e) => { e.classList.remove("current"); });
|
||||||
|
|
||||||
|
Array.from(themePreviewElems).forEach((preview) => {
|
||||||
|
preview.querySelector(".theme-preset").replaceWith(
|
||||||
|
presetElement.cloneNode(true)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
presetElems.forEach((e) => {
|
||||||
|
if (e.dataset.key != themeKey) return;
|
||||||
|
e.classList.add("current");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupPage() {
|
async function setupPage() {
|
||||||
|
initThemeSwitcher();
|
||||||
|
|
||||||
const pageElement = document.getElementById("page");
|
const pageElement = document.getElementById("page");
|
||||||
const pageContentElement = document.getElementById("page-content");
|
const pageContentElement = document.getElementById("page-content");
|
||||||
const pageContent = await fetchPageContent(pageData);
|
const pageContent = await fetchPageContent(pageData);
|
||||||
@ -781,7 +733,6 @@ async function setupPage() {
|
|||||||
pageContentElement.innerHTML = pageContent;
|
pageContentElement.innerHTML = pageContent;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setupThemeSwitcher();
|
|
||||||
setupPopovers();
|
setupPopovers();
|
||||||
setupClocks()
|
setupClocks()
|
||||||
await setupCalendars();
|
await setupCalendars();
|
||||||
|
@ -37,9 +37,6 @@ export function setupMasonries() {
|
|||||||
columnsFragment.append(column);
|
columnsFragment.append(column);
|
||||||
}
|
}
|
||||||
|
|
||||||
// poor man's masonry
|
|
||||||
// TODO: add an option that allows placing items in the
|
|
||||||
// shortest column instead of iterating the columns in order
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
columnsFragment.children[i % columnsCount].appendChild(items[i]);
|
columnsFragment.children[i % columnsCount].appendChild(items[i]);
|
||||||
}
|
}
|
||||||
|
@ -157,6 +157,9 @@ function hidePopover() {
|
|||||||
|
|
||||||
activeTarget.classList.remove("popover-active");
|
activeTarget.classList.remove("popover-active");
|
||||||
containerElement.style.display = "none";
|
containerElement.style.display = "none";
|
||||||
|
containerElement.style.removeProperty("top");
|
||||||
|
containerElement.style.removeProperty("left");
|
||||||
|
containerElement.style.removeProperty("right");
|
||||||
document.removeEventListener("keydown", handleHidePopoverOnEscape);
|
document.removeEventListener("keydown", handleHidePopoverOnEscape);
|
||||||
window.removeEventListener("resize", queueRepositionContainer);
|
window.removeEventListener("resize", queueRepositionContainer);
|
||||||
observer.unobserve(containerElement);
|
observer.unobserve(containerElement);
|
||||||
@ -181,7 +184,12 @@ export function setupPopovers() {
|
|||||||
for (let i = 0; i < targets.length; i++) {
|
for (let i = 0; i < targets.length; i++) {
|
||||||
const target = targets[i];
|
const target = targets[i];
|
||||||
|
|
||||||
target.addEventListener("mouseenter", handleMouseEnter);
|
if (target.dataset.popoverTrigger === "click") {
|
||||||
|
target.addEventListener("click", handleMouseEnter);
|
||||||
|
} else {
|
||||||
|
target.addEventListener("mouseenter", handleMouseEnter);
|
||||||
|
}
|
||||||
|
|
||||||
target.addEventListener("mouseleave", handleMouseLeave);
|
target.addEventListener("mouseleave", handleMouseLeave);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,22 +1,32 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html {{ block "document-root-attrs" . }}{{ end }} lang="en" id="top">
|
<html {{ block "document-root-attrs" . }}{{ end }} lang="en" id="top" data-scheme="{{ if .Request.Theme.Light }}light{{ else }}dark{{ end }}">
|
||||||
<head>
|
<head>
|
||||||
{{ block "document-head-before" . }}{{ end }}
|
{{ block "document-head-before" . }}{{ end }}
|
||||||
|
<script>
|
||||||
|
if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');
|
||||||
|
const pageData = {
|
||||||
|
slug: "{{ .Page.Slug }}",
|
||||||
|
baseURL: "{{ .App.Config.Server.BaseURL }}",
|
||||||
|
theme: "{{ .Request.Theme.Key }}",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<title>{{ block "document-title" . }}{{ end }}</title>
|
<title>{{ block "document-title" . }}{{ end }}</title>
|
||||||
<script>if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');</script>
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="dark">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="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-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="{{ .App.Config.Branding.AppName }}">
|
<meta name="apple-mobile-web-app-title" content="{{ .App.Config.Branding.AppName }}">
|
||||||
<meta name="theme-color" content="{{ .App.Config.Theme.BackgroundColorAsHex }}">
|
<meta name="theme-color" content="{{ .Request.Theme.BackgroundColorAsHex }}">
|
||||||
<link rel="apple-touch-icon" sizes="512x512" href='{{ .App.Config.Branding.AppIconURL }}'>
|
<link rel="apple-touch-icon" sizes="512x512" href='{{ .App.Config.Branding.AppIconURL }}'>
|
||||||
<link rel="manifest" href='{{ .App.VersionedAssetPath "manifest.json" }}'>
|
<link rel="manifest" href='{{ .App.VersionedAssetPath "manifest.json" }}'>
|
||||||
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
|
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
|
||||||
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
|
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
|
||||||
<script type="module" src='{{ .App.StaticAssetPath "js/main.js" }}'></script>
|
<script type="module" src='{{ .App.StaticAssetPath "js/main.js" }}'></script>
|
||||||
|
<style id="theme-style">
|
||||||
|
{{ .Request.Theme.CSS }}
|
||||||
|
</style>
|
||||||
{{ block "document-head-after" . }}{{ end }}
|
{{ block "document-head-after" . }}{{ end }}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
@ -2,25 +2,7 @@
|
|||||||
|
|
||||||
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
|
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
|
||||||
|
|
||||||
{{ define "document-head-before" }}
|
|
||||||
<script>
|
|
||||||
const pageData = {
|
|
||||||
slug: "{{ .Page.Slug }}",
|
|
||||||
baseURL: "{{ .App.Config.Server.BaseURL }}",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type ThemeCollection
|
|
||||||
*/
|
|
||||||
const userThemes = JSON.parse("{{ .Presets }}");
|
|
||||||
</script>
|
|
||||||
{{ 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" }}
|
{{ define "document-head-after" }}
|
||||||
{{ .App.ParsedThemeStyle }}
|
|
||||||
|
|
||||||
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
||||||
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.CreatedAt.Unix }}">
|
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.CreatedAt.Unix }}">
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@ -34,23 +16,6 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "theme-switcher" }}
|
|
||||||
<div class="theme-dropdown">
|
|
||||||
<button class="dropdown-button">
|
|
||||||
<span>Theme</span>
|
|
||||||
<i class="arrow"></i>
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-content">
|
|
||||||
<div class="theme-option" data-theme="default" data-scheme="dark">default</div>
|
|
||||||
<div class="theme-option" data-theme="light" data-scheme="light">light</div>
|
|
||||||
<div class="theme-option" data-theme="dark" data-scheme="dark">dark</div>
|
|
||||||
<div class="separator"></div>
|
|
||||||
<div class="custom-presets">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ define "document-body" }}
|
{{ define "document-body" }}
|
||||||
<div class="flex flex-column body-content">
|
<div class="flex flex-column body-content">
|
||||||
{{ if not .Page.HideDesktopNavigation }}
|
{{ if not .Page.HideDesktopNavigation }}
|
||||||
@ -58,11 +23,21 @@
|
|||||||
<div class="header flex padding-inline-widget widget-content-frame">
|
<div class="header flex padding-inline-widget widget-content-frame">
|
||||||
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
<!-- 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>
|
<div class="logo" aria-hidden="true">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
|
||||||
<nav class="nav flex grow">
|
<nav class="nav flex grow hide-scrollbars">
|
||||||
{{ template "navigation-links" . }}
|
{{ template "navigation-links" . }}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="flex items-center">
|
<div class="theme-picker self-center" data-popover-type="html" data-popover-position="below" data-popover-show-delay="0" data-popover-offset="0.7">
|
||||||
{{ template "theme-switcher" . }}
|
<div class="current-theme-preview">
|
||||||
|
{{ .Request.Theme.PreviewHTML }}
|
||||||
|
</div>
|
||||||
|
<div data-popover-html>
|
||||||
|
<div class="theme-choices">
|
||||||
|
{{ .App.Config.Theme.PreviewHTML }}
|
||||||
|
{{ range $_, $preset := .App.Config.Theme.Presets.Items }}
|
||||||
|
{{ $preset.PreviewHTML }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -74,20 +49,35 @@
|
|||||||
{{ range $i, $column := .Page.Columns }}
|
{{ range $i, $column := .Page.Columns }}
|
||||||
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"{{ if .Page.ExpandMobilePageNavigation }} checked{{ end }}><div class="hamburger-icon"></div></label>
|
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-navigation-page-links">
|
|
||||||
<div class="flex grow">
|
<div class="mobile-navigation-page-links hide-scrollbars">
|
||||||
{{ template "navigation-links" . }}
|
{{ template "navigation-links" . }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
|
||||||
{{ template "theme-switcher" . }}
|
<div class="mobile-navigation-actions flex flex-column margin-block-10">
|
||||||
|
<div class="theme-picker flex justify-between items-center" data-popover-type="html" data-popover-position="above" data-popover-show-delay="0" data-popover-hide-delay="100" data-popover-anchor=".current-theme-preview" data-popover-trigger="click">
|
||||||
|
<div data-popover-html>
|
||||||
|
<div class="theme-choices"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="size-h3 pointer-events-none">Change theme</div>
|
||||||
|
|
||||||
|
<div class="flex gap-15 items-center pointer-events-none">
|
||||||
|
<div class="current-theme-preview">
|
||||||
|
{{ .Request.Theme.PreviewHTML }}
|
||||||
|
</div>
|
||||||
|
<svg class="ui-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-bounds grow{{ if ne "" .Page.Width }} content-bounds-{{ .Page.Width }} {{ end }}">
|
<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">
|
<main class="page{{ if .Page.CenterVertically }} page-center-vertically{{ end }}" id="page" aria-live="polite" aria-busy="true">
|
||||||
<h1 class="visually-hidden">{{ .Page.Title }}</h1>
|
<h1 class="visually-hidden">{{ .Page.Title }}</h1>
|
||||||
<div class="page-content" id="page-content"></div>
|
<div class="page-content" id="page-content"></div>
|
||||||
<div class="page-loading-container">
|
<div class="page-loading-container">
|
||||||
|
19
internal/glance/templates/theme-preset-preview.html
Normal file
19
internal/glance/templates/theme-preset-preview.html
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{{- $background := "hsl(240, 8%, 9%)" | safeCSS }}
|
||||||
|
{{- $primary := "hsl(43, 50%, 70%)" | safeCSS }}
|
||||||
|
{{- $positive := "hsl(43, 50%, 70%)" | safeCSS }}
|
||||||
|
{{- $negative := "hsl(0, 70%, 70%)" | safeCSS }}
|
||||||
|
{{- if .BackgroundColor }}{{ $background = .BackgroundColor.String | safeCSS }}{{ end }}
|
||||||
|
{{- if .PrimaryColor }}
|
||||||
|
{{- $primary = .PrimaryColor.String | safeCSS }}
|
||||||
|
{{- if not .PositiveColor }}
|
||||||
|
{{- $positive = $primary }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $positive = .PositiveColor.String | safeCSS }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .NegativeColor }}{{ $negative = .NegativeColor.String | safeCSS }}{{ end }}
|
||||||
|
<button class="theme-preset{{ if .Light }} theme-preset-light{{ end }}" style="--color: {{ $background }}" data-key="{{ .Key }}">
|
||||||
|
<div class="theme-color" style="--color: {{ $primary }}"></div>
|
||||||
|
<div class="theme-color" style="--color: {{ $positive }}"></div>
|
||||||
|
<div class="theme-color" style="--color: {{ $negative }}"></div>
|
||||||
|
</button>
|
@ -1,31 +1,12 @@
|
|||||||
<style>
|
:root {
|
||||||
.default {
|
|
||||||
{{ if .BackgroundColor }}
|
{{ if .BackgroundColor }}
|
||||||
--bgh: {{ .BackgroundColor.Hue }};
|
--bgh: {{ .BackgroundColor.H }};
|
||||||
--bgs: {{ .BackgroundColor.Saturation }}%;
|
--bgs: {{ .BackgroundColor.S }}%;
|
||||||
--bgl: {{ .BackgroundColor.Lightness }}%;
|
--bgl: {{ .BackgroundColor.L }}%;
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
|
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
|
||||||
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
|
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
|
||||||
{{ if .PrimaryColor }}--color-primary: {{ .PrimaryColor.String | safeCSS }};{{ end }}
|
{{ if .PrimaryColor }}--color-primary: {{ .PrimaryColor.String | safeCSS }};{{ end }}
|
||||||
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.String | safeCSS }};{{ end }}
|
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.String | safeCSS }};{{ end }}
|
||||||
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.String | safeCSS }};{{ end }}
|
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.String | safeCSS }};{{ end }}
|
||||||
}
|
}
|
||||||
|
|
||||||
{{ range $name,$theme := .Presets }}
|
|
||||||
.{{ $name }} {
|
|
||||||
{{ if .BackgroundColor }}
|
|
||||||
--bgh: {{ $theme.BackgroundColor.Hue }};
|
|
||||||
--bgs: {{ $theme.BackgroundColor.Saturation }}%;
|
|
||||||
--bgl: {{ $theme.BackgroundColor.Lightness }}%;
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ if ne 0.0 $theme.ContrastMultiplier }}--cm: {{ $theme.ContrastMultiplier }};{{ end }}
|
|
||||||
{{ if ne 0.0 $theme.TextSaturationMultiplier }}--tsm: {{ $theme.TextSaturationMultiplier }};{{ end }}
|
|
||||||
{{ if $theme.PrimaryColor }}--color-primary: {{ $theme.PrimaryColor.String | safeCSS }};{{ end }}
|
|
||||||
{{ if $theme.PositiveColor }}--color-positive: {{ $theme.PositiveColor.String | safeCSS }};{{ end }}
|
|
||||||
{{ if $theme.NegativeColor }}--color-negative: {{ $theme.NegativeColor.String | safeCSS }};{{ end }}
|
|
||||||
}
|
|
||||||
{{ end }}
|
|
||||||
</style>
|
|
104
internal/glance/theme.go
Normal file
104
internal/glance/theme.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
themeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
|
||||||
|
themePresetPreviewTemplate = mustParseTemplate("theme-preset-preview.html")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *application) handleThemeChangeRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
themeKey := r.PathValue("key")
|
||||||
|
|
||||||
|
properties, exists := a.Config.Theme.Presets.Get(themeKey)
|
||||||
|
if !exists && themeKey != "default" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if themeKey == "default" {
|
||||||
|
properties = &a.Config.Theme.themeProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "theme",
|
||||||
|
Value: themeKey,
|
||||||
|
Path: a.Config.Server.BaseURL + "/",
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
w.Header().Set("X-Scheme", ternary(properties.Light, "light", "dark"))
|
||||||
|
w.Write([]byte(properties.CSS))
|
||||||
|
}
|
||||||
|
|
||||||
|
type themeProperties struct {
|
||||||
|
BackgroundColor *hslColorField `yaml:"background-color"`
|
||||||
|
PrimaryColor *hslColorField `yaml:"primary-color"`
|
||||||
|
PositiveColor *hslColorField `yaml:"positive-color"`
|
||||||
|
NegativeColor *hslColorField `yaml:"negative-color"`
|
||||||
|
Light bool `yaml:"light"`
|
||||||
|
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
||||||
|
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
||||||
|
|
||||||
|
Key string `yaml:"-"`
|
||||||
|
CSS template.CSS `yaml:"-"`
|
||||||
|
PreviewHTML template.HTML `yaml:"-"`
|
||||||
|
BackgroundColorAsHex string `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *themeProperties) init() error {
|
||||||
|
css, err := executeTemplateToString(themeStyleTemplate, t)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compiling theme style: %v", err)
|
||||||
|
}
|
||||||
|
t.CSS = template.CSS(whitespaceAtBeginningOfLinePattern.ReplaceAllString(css, ""))
|
||||||
|
|
||||||
|
previewHTML, err := executeTemplateToString(themePresetPreviewTemplate, t)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compiling theme preview: %v", err)
|
||||||
|
}
|
||||||
|
t.PreviewHTML = template.HTML(previewHTML)
|
||||||
|
|
||||||
|
if t.BackgroundColor != nil {
|
||||||
|
t.BackgroundColorAsHex = t.BackgroundColor.ToHex()
|
||||||
|
} else {
|
||||||
|
t.BackgroundColorAsHex = "#151519"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t1 *themeProperties) SameAs(t2 *themeProperties) bool {
|
||||||
|
if t1 == nil && t2 == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if t1 == nil || t2 == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t1.Light != t2.Light {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t1.ContrastMultiplier != t2.ContrastMultiplier {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t1.TextSaturationMultiplier != t2.TextSaturationMultiplier {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !t1.BackgroundColor.SameAs(t2.BackgroundColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !t1.PrimaryColor.SameAs(t2.PrimaryColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !t1.PositiveColor.SameAs(t2.PositiveColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !t1.NegativeColor.SameAs(t2.NegativeColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
@ -15,6 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||||
|
var whitespaceAtBeginningOfLinePattern = regexp.MustCompile(`(?m)^\s+`)
|
||||||
|
|
||||||
func percentChange(current, previous float64) float64 {
|
func percentChange(current, previous float64) float64 {
|
||||||
return (current/previous - 1) * 100
|
return (current/previous - 1) * 100
|
||||||
@ -149,15 +150,14 @@ func fileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.H
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeTemplateToHTML(t *template.Template, data interface{}) (template.HTML, error) {
|
func executeTemplateToString(t *template.Template, data any) (string, error) {
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
|
|
||||||
err := t.Execute(&b, data)
|
err := t.Execute(&b, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("executing template: %w", err)
|
return "", fmt.Errorf("executing template: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return template.HTML(b.String()), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringToBool(s string) bool {
|
func stringToBool(s string) bool {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user