This commit is contained in:
Axel Hecht 2025-02-09 13:13:38 -08:00 committed by GitHub
commit 07f7f88a7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 352 additions and 22 deletions

View file

@ -269,25 +269,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.
@ -320,6 +336,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:

View file

@ -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 {

View file

@ -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

View file

@ -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
@ -649,6 +673,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<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'));
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");
@ -657,6 +776,7 @@ async function setupPage() {
pageContentElement.innerHTML = pageContent;
try {
setupThemeSwitcher();
setupPopovers();
setupClocks()
await setupCalendars();

View file

@ -56,6 +56,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% -;
}
@ -1844,7 +1866,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;
}
@ -2095,3 +2116,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);
}

View file

@ -5,7 +5,7 @@
<title>{{ block "document-title" . }}{{ end }}</title>
<script>if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');</script>
<meta charset="UTF-8">
<meta name="color-scheme" content="dark">
<meta name="color-scheme" content="light">
<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="mobile-web-app-capable" content="yes">

View file

@ -8,6 +8,11 @@
slug: "{{ .Page.Slug }}",
baseURL: "{{ .App.Config.Server.BaseURL }}",
};
/**
* @type ThemeCollection
*/
const userThemes = JSON.parse("{{ .Presets }}");
</script>
{{ end }}
@ -29,6 +34,23 @@
{{ 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" }}
<div class="flex flex-column body-content">
{{ if not .Page.HideDesktopNavigation }}
@ -39,6 +61,9 @@
<div class="nav flex grow">
{{ template "navigation-links" . }}
</div>
<div class="flex items-center">
{{ template "theme-switcher" . }}
</div>
</div>
</div>
{{ end }}
@ -52,7 +77,12 @@
<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>
</div>
<div class="mobile-navigation-page-links">
{{ template "navigation-links" . }}
<div class="flex grow">
{{ template "navigation-links" . }}
</div>
<div class="flex items-center">
{{ template "theme-switcher" . }}
</div>
</div>
</div>

View file

@ -1,14 +1,31 @@
<style>
:root {
.default {
{{ if .BackgroundColor }}
--bgh: {{ .BackgroundColor.Hue }};
--bgs: {{ .BackgroundColor.Saturation }}%;
--bgl: {{ .BackgroundColor.Lightness }}%;
{{ end }}
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
{{ if .PrimaryColor }}--color-primary: {{ .PrimaryColor.String | safeCSS }};{{ end }}
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.String | safeCSS }};{{ end }}
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.String | safeCSS }};{{ end }}
}
</style>
{{ 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>