diff --git a/docs/configuration.md b/docs/configuration.md index 6bd1bc6..cda2fd5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -228,25 +228,41 @@ Example: ```yaml theme: - background-color: 100 20 10 - primary-color: 40 90 40 - contrast-multiplier: 1.1 + background-color: 186 21 20 + contrast-multiplier: 1.2 + primary-color: 97 13 80 + + 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.0 + primary-color: 220 91 54 + positive-color: 109 58 40 + negative-color: 347 87 44 ``` ### 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. ### Properties -| Name | Type | Required | Default | -| ---- | ---- | -------- | ------- | -| light | boolean | no | false | -| background-color | HSL | no | 240 8 9 | -| primary-color | HSL | no | 43 50 70 | -| positive-color | HSL | no | same as `primary-color` | -| negative-color | HSL | no | 0 70 70 | -| contrast-multiplier | number | no | 1 | -| text-saturation-multiplier | number | no | 1 | -| custom-css-file | string | no | | +| Name | Type | Required | Default | +| ---- |-------|----------| ------- | +| light | boolean | no | false | +| background-color | HSL | no | 240 8 9 | +| primary-color | HSL | no | 43 50 70 | +| positive-color | HSL | no | same as `primary-color` | +| negative-color | HSL | no | 0 70 70 | +| contrast-multiplier | number | no | 1 | +| text-saturation-multiplier | number | no | 1 | +| custom-css-file | string | no | | +| presets | array | 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. @@ -279,6 +295,26 @@ theme: 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] > > 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: diff --git a/internal/glance/config.go b/internal/glance/config.go index 0ab79af..f110470 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -17,6 +17,16 @@ import ( "gopkg.in/yaml.v3" ) +type CssProperties struct { + BackgroundColor *hslColorField `yaml:"background-color"` + PrimaryColor *hslColorField `yaml:"primary-color"` + PositiveColor *hslColorField `yaml:"positive-color"` + NegativeColor *hslColorField `yaml:"negative-color"` + Light bool `yaml:"light"` + ContrastMultiplier float32 `yaml:"contrast-multiplier"` + TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` +} + type config struct { Server struct { Host string `yaml:"host"` @@ -31,6 +41,7 @@ type config struct { } `yaml:"document"` Theme struct { + // Todo : Find a way to use CssProperties struct to avoid duplicates BackgroundColor *hslColorField `yaml:"background-color"` PrimaryColor *hslColorField `yaml:"primary-color"` PositiveColor *hslColorField `yaml:"positive-color"` @@ -39,6 +50,8 @@ type config struct { 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"` Branding struct { diff --git a/internal/glance/glance.go b/internal/glance/glance.go index b1fcc37..d5c7400 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -3,6 +3,7 @@ package glance import ( "bytes" "context" + "encoding/json" "fmt" "html/template" "log" @@ -125,8 +126,9 @@ func (a *application) transformUserDefinedAssetPath(path string) string { } type pageTemplateData struct { - App *application - Page *page + App *application + Page *page + Presets string } func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) { @@ -137,9 +139,20 @@ func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) return } + presets := a.Config.Theme.Presets + keys := make([]string, 0, len(presets)) + for key := range presets { + 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{ - Page: page, - App: a, + App: a, + Page: page, + Presets: string(presetsAsJSON), } var responseBytes bytes.Buffer diff --git a/internal/glance/static/js/main.js b/internal/glance/static/js/main.js index 58a8c2d..b644676 100644 --- a/internal/glance/static/js/main.js +++ b/internal/glance/static/js/main.js @@ -2,6 +2,30 @@ import { setupPopovers } from './popover.js'; import { setupMasonries } from './masonry.js'; import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.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) { // TODO: handle non 200 status codes/time outs // TODO: add retries @@ -638,6 +662,101 @@ function setupTruncatedElementTitles() { } } +/** + * @typedef {Object} HslColorField + * @property {number} Hue + * @property {number} Saturation + * @property {number} Lightness + */ + +/** + * @typedef {Object} Theme + * @property {HslColorField} BackgroundColor + * @property {HslColorField} PrimaryColor + * @property {HslColorField} PositiveColor + * @property {HslColorField} NegativeColor + * @property {boolean} Light + * @property {number} ContrastMultiplier + * @property {number} TextSaturationMultiplier + */ + +/** + * @typedef {Record} 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')); + const dropdownContents = Array.from(document.querySelectorAll('.dropdown-content')); + + dropdownButtons.forEach((dropdownButton) => { + dropdownButton.addEventListener('click', (e) => { + e.stopPropagation(); + dropdownContents.forEach((dropdownContent) => { + dropdownContent.classList.toggle('show'); + }); + dropdownButton.classList.toggle('active'); + }); + }); + + document.addEventListener('click', (e) => { + if (!e.target.closest('.theme-dropdown')) { + dropdownContents.forEach((dropdownContent) => { + dropdownContent.classList.remove('show'); + }); + dropdownButtons.forEach((dropdownButton) => { + dropdownButton.classList.remove('active'); + }); + } + }); + + document.querySelectorAll('.theme-option').forEach(option => { + option.addEventListener('click', () => { + 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') { + html.classList.remove('dark-scheme'); + html.classList.add('light-scheme'); + } else if (selectedThemeScheme === 'dark') { + html.classList.add('dark-scheme'); + html.classList.remove('light-scheme'); + } + + document.querySelector('[name=color-scheme]').setAttribute('content', selectedThemeScheme); + localStorage.setItem('theme', JSON.stringify({ + theme: selectedTheme, + themeScheme: selectedThemeScheme + })); + }); + }); + }); +} + async function setupPage() { const pageElement = document.getElementById("page"); const pageContentElement = document.getElementById("page-content"); @@ -646,6 +765,7 @@ async function setupPage() { pageContentElement.innerHTML = pageContent; try { + setupThemeSwitcher(); setupPopovers(); setupClocks() setupCarousels(); diff --git a/internal/glance/static/main.css b/internal/glance/static/main.css index 1d5c19a..1128963 100644 --- a/internal/glance/static/main.css +++ b/internal/glance/static/main.css @@ -55,6 +55,28 @@ --font-size-h6: 1.1rem; } +.dark { + --scheme: ; + --bgh: 240; + --bgs: 8%; + --bgl: 9%; + --bghs: var(--bgh), var(--bgs); + --cm: 1; + --tsm: 1; +} + +.light { + --scheme: 100% -; + --bgh: 240; + --bgs: 50%; + --bgl: 98%; + --bghs: var(--bgh), var(--bgs); + --cm: 1; + --tsm: 1; + --color-primary: hsl(43, 50%, 70%); +} + + .light-scheme { --scheme: 100% -; } @@ -1625,7 +1647,6 @@ details[open] .summary::after { padding: 15px var(--content-bounds-padding); display: flex; align-items: center; - overflow-x: auto; scrollbar-width: thin; gap: 2.5rem; } @@ -1872,3 +1893,83 @@ details[open] .summary::after { .list-gap-20 { --list-half-gap: 1rem; } .list-gap-24 { --list-half-gap: 1.2rem; } .list-gap-34 { --list-half-gap: 1.7rem; } + +/* +### Theme Dropdown ### +*/ +.theme-dropdown { + position: relative; + display: inline-block; + right: 0; +} + +.dropdown-button { + padding: 10px 15px; + background: var(--color-widget-background); + border: 1px solid var(--color-widget-content-border); + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + min-width: 150px; + transition: border-color .2s; + color: var(--color-text-highlight); +} + +.dropdown-button:hover { + border-color: var(--color-text-subdue); +} + +.dropdown-content { + display: none; + position: absolute; + top: 100%; + left: 0; + background: var(--color-widget-content-border); + min-width: 150px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + border-radius: 4px; + z-index: 1000; +} + +.mobile-navigation-page-links .dropdown-content { + top: unset; + bottom: 38px; +} + +.dropdown-content.show { + display: block; +} + +.theme-option { + padding: 10px 15px; + cursor: pointer; + transition: background-color 0.2s; +} + +.theme-option:hover { + background-color: #f8f9fa; +} + +.separator { + height: 1px; + background-color: #dee2e6; + margin: 5px 0; +} + +.arrow { + border: solid #666; + border-width: 0 2px 2px 0; + display: inline-block; + padding: 3px; + transform: rotate(45deg); + transition: transform 0.2s ease; + margin-left: auto; + position: relative; + top: -1px; +} + +.dropdown-button.active .arrow { + transform: rotate(-135deg); +} \ No newline at end of file diff --git a/internal/glance/templates/document.html b/internal/glance/templates/document.html index a26f854..e28c85e 100644 --- a/internal/glance/templates/document.html +++ b/internal/glance/templates/document.html @@ -5,7 +5,7 @@ {{ block "document-title" . }}{{ end }} - + diff --git a/internal/glance/templates/page.html b/internal/glance/templates/page.html index e740d03..5b48520 100644 --- a/internal/glance/templates/page.html +++ b/internal/glance/templates/page.html @@ -8,6 +8,11 @@ slug: "{{ .Page.Slug }}", baseURL: "{{ .App.Config.Server.BaseURL }}", }; + + /** + * @type ThemeCollection + */ + const userThemes = JSON.parse("{{ .Presets }}"); {{ end }} @@ -29,6 +34,23 @@ {{ end }} {{ end }} +{{ define "theme-switcher" }} +
+ + +
+{{ end }} + {{ define "document-body" }}
{{ if not .Page.HideDesktopNavigation }} @@ -39,6 +61,9 @@ +
+ {{ template "theme-switcher" . }} +
{{ end }} @@ -52,7 +77,12 @@ diff --git a/internal/glance/templates/theme-style.gotmpl b/internal/glance/templates/theme-style.gotmpl index 878ca0b..2492568 100644 --- a/internal/glance/templates/theme-style.gotmpl +++ b/internal/glance/templates/theme-style.gotmpl @@ -1,14 +1,31 @@ + +{{ 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 }} + \ No newline at end of file