diff --git a/docs/configuration.md b/docs/configuration.md index cc911c3..73083d9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -325,6 +325,19 @@ theme: background-color: 100 20 10 primary-color: 40 90 40 contrast-multiplier: 1.1 + + presets: + gruvbox-dark: + background-color: 0 0 16 + primary-color: 43 59 81 + positive-color: 61 66 44 + negative-color: 6 96 59 + + zebra: + light: true + background-color: 0 0 95 + primary-color: 0 0 10 + negative-color: 0 90 50 ``` ### Available themes @@ -341,6 +354,7 @@ If you don't want to spend time configuring your own theme, there are [several a | contrast-multiplier | number | no | 1 | | text-saturation-multiplier | number | no | 1 | | custom-css-file | string | no | | +| presets | object | no | | #### `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. @@ -385,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. +#### `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 ![illustration of pages and columns](images/pages-and-columns-illustration.png) @@ -415,7 +453,6 @@ pages: | desktop-navigation-width | string | no | | | center-vertically | boolean | no | false | | hide-desktop-navigation | boolean | no | false | -| expand-mobile-page-navigation | boolean | no | false | | show-mobile-header | boolean | no | false | | columns | array | yes | | @@ -447,9 +484,6 @@ When set to `true`, vertically centers the content on the page. Has no effect if #### `hide-desktop-navigation` 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` 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. diff --git a/internal/glance/config-fields.go b/internal/glance/config-fields.go index 1a0a701..d368140 100644 --- a/internal/glance/config-fields.go +++ b/internal/glance/config-fields.go @@ -23,17 +23,27 @@ const ( ) type hslColorField struct { - Hue float64 - Saturation float64 - Lightness float64 + H float64 + S float64 + L float64 } 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 { - 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 { @@ -76,9 +86,9 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax) } - c.Hue = hue - c.Saturation = saturation - c.Lightness = lightness + c.H = hue + c.S = saturation + c.L = lightness return nil } diff --git a/internal/glance/config.go b/internal/glance/config.go index ea67f5f..318367c 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "html/template" + "iter" "log" "maps" "os" @@ -38,15 +39,9 @@ type config struct { } `yaml:"document"` 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"` - Light bool `yaml:"light"` - ContrastMultiplier float32 `yaml:"contrast-multiplier"` - TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` - CustomCSSFile string `yaml:"custom-css-file"` + themeProperties `yaml:",inline"` + CustomCSSFile string `yaml:"custom-css-file"` + Presets orderedYAMLMap[string, *themeProperties] `yaml:"presets"` } `yaml:"theme"` Branding struct { @@ -64,15 +59,14 @@ type config struct { } 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"` - CenterVertically bool `yaml:"center-vertically"` - Columns []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"` + HideDesktopNavigation bool `yaml:"hide-desktop-navigation"` + CenterVertically bool `yaml:"center-vertically"` + Columns []struct { Size string `yaml:"size"` Widgets widgets `yaml:"widgets"` } `yaml:"columns"` @@ -490,3 +484,103 @@ func isConfigStateValid(config *config) error { 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 +} diff --git a/internal/glance/embed.go b/internal/glance/embed.go index 3d0db8c..e09caa8 100644 --- a/internal/glance/embed.go +++ b/internal/glance/embed.go @@ -82,7 +82,6 @@ func computeFSHash(files fs.FS) (string, error) { 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 { diff --git a/internal/glance/glance.go b/internal/glance/glance.go index d2fad40..461d00b 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "html/template" "log" "net/http" "path/filepath" @@ -15,19 +14,17 @@ import ( ) var ( - pageTemplate = mustParseTemplate("page.html", "document.html") - pageContentTemplate = mustParseTemplate("page-content.html") - pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl") - manifestTemplate = mustParseTemplate("manifest.json") + pageTemplate = mustParseTemplate("page.html", "document.html") + pageContentTemplate = mustParseTemplate("page-content.html") + 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 + Version string + CreatedAt time.Time + Config config parsedManifest []byte @@ -35,14 +32,15 @@ type application struct { widgetByID map[uint64]widget } -func newApplication(config *config) (*application, error) { +func newApplication(c *config) (*application, error) { app := &application{ Version: buildVersion, CreatedAt: time.Now(), - Config: *config, + Config: *c, slugToPage: make(map[string]*page), widgetByID: make(map[uint64]widget), } + config := &app.Config app.slugToPage[""] = &config.Pages[0] @@ -50,10 +48,43 @@ func newApplication(config *config) (*application, error) { 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 { - 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 { @@ -90,18 +121,10 @@ func newApplication(config *config) (*application, error) { } } - config = &app.Config - config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/") config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile) 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 == "" { config.Branding.FaviconURL = app.StaticAssetPath("favicon.png") } else { @@ -120,11 +143,11 @@ func newApplication(config *config) (*application, error) { config.Branding.AppBackgroundColor = config.Theme.BackgroundColorAsHex } - var manifestWriter bytes.Buffer - if err := manifestTemplate.Execute(&manifestWriter, pageTemplateData{App: app}); err != nil { + manifest, err := executeTemplateToString(manifestTemplate, pageTemplateData{App: app}) + if err != nil { return nil, fmt.Errorf("parsing manifest.json: %v", err) } - app.parsedManifest = manifestWriter.Bytes() + app.parsedManifest = []byte(manifest) return app, nil } @@ -162,9 +185,28 @@ func (a *application) resolveUserDefinedAssetPath(path string) string { return path } +type pageTemplateRequestData struct { + Theme *themeProperties +} + type pageTemplateData struct { - App *application - Page *page + App *application + Page *page + 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) { @@ -175,13 +217,14 @@ func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) return } - pageData := pageTemplateData{ + data := pageTemplateData{ Page: page, App: a, } + a.populateTemplateRequestData(&data.Request, r) var responseBytes bytes.Buffer - err := pageTemplate.Execute(&responseBytes, pageData) + err := pageTemplate.Execute(&responseBytes, data) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) @@ -266,6 +309,7 @@ func (a *application) server() (func() error, func() error) { mux.HandleFunc("GET /{page}", a.handlePageRequest) 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("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/internal/glance/static/css/mobile.css b/internal/glance/static/css/mobile.css index 73b0d39..3ad65f6 100644 --- a/internal/glance/static/css/mobile.css +++ b/internal/glance/static/css/mobile.css @@ -48,6 +48,17 @@ 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 { --spacing: 7px; color: var(--color-primary); @@ -60,6 +71,7 @@ .mobile-navigation-page-links { border-top: 1px solid var(--color-widget-content-border); + border-bottom: 1px solid var(--color-widget-content-border); padding: 20px var(--content-bounds-padding); display: flex; align-items: center; diff --git a/internal/glance/static/css/site.css b/internal/glance/static/css/site.css index bf87dd3..6dd986f 100644 --- a/internal/glance/static/css/site.css +++ b/internal/glance/static/css/site.css @@ -1,4 +1,4 @@ -.light-scheme { +:root[data-scheme=light] { --scheme: 100% -; } @@ -219,7 +219,7 @@ kbd:active { max-width: 1100px; } -.page-center-vertically .page { +.page.center-vertically { display: flex; justify-content: center; flex-direction: column; @@ -256,6 +256,8 @@ kbd:active { } .nav { + overflow-x: auto; + min-width: 0; height: 100%; gap: var(--header-items-gap); } @@ -293,3 +295,73 @@ kbd:active { border-bottom-color: var(--color-primary); color: var(--color-text-highlight); } + +.theme-choices { + --presets-per-row: 2; + display: grid; + grid-template-columns: repeat(var(--presets-per-row), 1fr); + align-items: center; + gap: 1.35rem; +} + +.theme-choices:has(> :nth-child(3)) { + --presets-per-row: 3; +} + +.theme-preset { + background-color: var(--color); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + height: 2rem; + padding-inline: 0.5rem; + border-radius: 0.3rem; + border: none; + cursor: pointer; + position: relative; +} + +.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); +} + +.theme-choices .theme-preset.current::before { + border-color: var(--color-text-base); +} + +.theme-preset-light { + gap: 0.3rem; + height: 1.8rem; +} + +.theme-color { + background-color: var(--color); + width: 0.9rem; + height: 0.9rem; + border-radius: 0.2rem; +} + +.theme-preset-light .theme-color { + width: 1rem; + height: 1rem; + border-radius: 0.3rem; +} + +.current-theme-preview { + opacity: 0.4; + transition: opacity .3s; +} + +.theme-picker.popover-active .current-theme-preview, .theme-picker:hover { + opacity: 1; +} diff --git a/internal/glance/static/css/utils.css b/internal/glance/static/css/utils.css index fbc3bad..f3cf987 100644 --- a/internal/glance/static/css/utils.css +++ b/internal/glance/static/css/utils.css @@ -376,7 +376,7 @@ details[open] .summary::after { gap: 0.5rem; } -:root:not(.light-scheme) .flat-icon { +:root:not([data-scheme=light]) .flat-icon { filter: invert(1); } @@ -459,6 +459,23 @@ details[open] .summary::after { 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-h2 { font-size: var(--font-size-h2); } .size-h3 { font-size: var(--font-size-h3); } @@ -510,6 +527,7 @@ details[open] .summary::after { .grow { flex-grow: 1; } .flex-column { flex-direction: column; } .items-center { align-items: center; } +.self-center { align-self: center; } .items-start { align-items: start; } .items-end { align-items: end; } .gap-5 { gap: 0.5rem; } @@ -549,6 +567,7 @@ details[open] .summary::after { .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); } +.pointer-events-none { pointer-events: none; } .padding-block-5 { padding-block: 0.5rem; } .scale-half { transform: scale(0.5); } .list { --list-half-gap: 0rem; } diff --git a/internal/glance/static/js/main.js b/internal/glance/static/js/main.js index dca713b..0f8768a 100644 --- a/internal/glance/static/js/main.js +++ b/internal/glance/static/js/main.js @@ -1,6 +1,7 @@ import { setupPopovers } from './popover.js'; import { setupMasonries } from './masonry.js'; import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js'; +import { elem, find, findAll } from './templating.js'; async function fetchPageContent(pageData) { // TODO: handle non 200 status codes/time outs @@ -654,7 +655,77 @@ function setupTruncatedElementTitles() { } } +async function changeTheme(key, onChanged) { + const themeStyleElem = find("#theme-style"); + + const response = await fetch(`${pageData.baseURL}/api/set-theme/${key}`, { + method: "POST", + }); + + if (response.status != 200) { + alert("Failed to set theme: " + response.statusText); + return; + } + const newThemeStyle = await response.text(); + + const tempStyle = elem("style") + .html("* { transition: none !important; }") + .appendTo(document.head); + + themeStyleElem.html(newThemeStyle); + document.documentElement.setAttribute("data-scheme", response.headers.get("X-Scheme")); + typeof onChanged == "function" && onChanged(); + setTimeout(() => { tempStyle.remove(); }, 10); +} + +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; + } + + if (themeKey == pageData.theme) { + presetElement.classList.add("current"); + } + + presetElement.addEventListener("click", () => { + if (themeKey == pageData.theme) return; + if (isLoading) return; + + isLoading = true; + changeTheme(themeKey, function() { + isLoading = false; + 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() { + initThemeSwitcher(); + const pageElement = document.getElementById("page"); const pageContentElement = document.getElementById("page-content"); const pageContent = await fetchPageContent(pageData); diff --git a/internal/glance/static/js/masonry.js b/internal/glance/static/js/masonry.js index 45680f4..83e6206 100644 --- a/internal/glance/static/js/masonry.js +++ b/internal/glance/static/js/masonry.js @@ -37,9 +37,6 @@ export function setupMasonries() { 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++) { columnsFragment.children[i % columnsCount].appendChild(items[i]); } diff --git a/internal/glance/static/js/popover.js b/internal/glance/static/js/popover.js index 331ee26..5e38f9c 100644 --- a/internal/glance/static/js/popover.js +++ b/internal/glance/static/js/popover.js @@ -157,6 +157,9 @@ function hidePopover() { activeTarget.classList.remove("popover-active"); containerElement.style.display = "none"; + containerElement.style.removeProperty("top"); + containerElement.style.removeProperty("left"); + containerElement.style.removeProperty("right"); document.removeEventListener("keydown", handleHidePopoverOnEscape); window.removeEventListener("resize", queueRepositionContainer); observer.unobserve(containerElement); @@ -181,7 +184,12 @@ export function setupPopovers() { for (let i = 0; i < targets.length; 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); } } diff --git a/internal/glance/templates/document.html b/internal/glance/templates/document.html index 4530827..ce2e9a6 100644 --- a/internal/glance/templates/document.html +++ b/internal/glance/templates/document.html @@ -1,9 +1,16 @@ - + {{ block "document-head-before" . }}{{ end }} + {{ block "document-title" . }}{{ end }} - @@ -11,12 +18,15 @@ - + + {{ block "document-head-after" . }}{{ end }} diff --git a/internal/glance/templates/page.html b/internal/glance/templates/page.html index 24baf78..2f0cb71 100644 --- a/internal/glance/templates/page.html +++ b/internal/glance/templates/page.html @@ -2,20 +2,7 @@ {{ define "document-title" }}{{ .Page.Title }}{{ end }} -{{ define "document-head-before" }} - -{{ 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 }} {{ end }} @@ -36,9 +23,22 @@
-
{{ end }} @@ -49,15 +49,35 @@ {{ range $i, $column := .Page.Columns }} {{ end }} - + -
-
+

{{ .Page.Title }}

diff --git a/internal/glance/templates/theme-preset-preview.html b/internal/glance/templates/theme-preset-preview.html new file mode 100644 index 0000000..ac17f98 --- /dev/null +++ b/internal/glance/templates/theme-preset-preview.html @@ -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 }} + diff --git a/internal/glance/templates/theme-style.gotmpl b/internal/glance/templates/theme-style.gotmpl index 878ca0b..ffa9eb5 100644 --- a/internal/glance/templates/theme-style.gotmpl +++ b/internal/glance/templates/theme-style.gotmpl @@ -1,9 +1,8 @@ - diff --git a/internal/glance/theme.go b/internal/glance/theme.go new file mode 100644 index 0000000..f99efd4 --- /dev/null +++ b/internal/glance/theme.go @@ -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 +} diff --git a/internal/glance/utils.go b/internal/glance/utils.go index 2f76965..3411f83 100644 --- a/internal/glance/utils.go +++ b/internal/glance/utils.go @@ -15,6 +15,7 @@ import ( ) var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) +var whitespaceAtBeginningOfLinePattern = regexp.MustCompile(`(?m)^\s+`) func percentChange(current, previous float64) float64 { 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 - err := t.Execute(&b, data) if err != nil { return "", fmt.Errorf("executing template: %w", err) } - return template.HTML(b.String()), nil + return b.String(), nil } func stringToBool(s string) bool {