浏览代码

Merge 62e9c32082f113ebaad14e1c12bccfbb4b5b1640 into 0d6966726e2373d901a859e5cf31ca06039d0195

Axel Hecht 4 月之前
父节点
当前提交
07f7f88a7b

+ 49 - 13
docs/configuration.md

@@ -269,25 +269,41 @@ Example:
 
 
 ```yaml
 ```yaml
 theme:
 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
 ### 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 |
-| ---- | ---- | -------- | ------- |
-| 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`
 #### `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.
@@ -320,6 +336,26 @@ 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:

+ 13 - 0
internal/glance/config.go

@@ -17,6 +17,16 @@ import (
 	"gopkg.in/yaml.v3"
 	"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 {
 type config struct {
 	Server struct {
 	Server struct {
 		Host       string    `yaml:"host"`
 		Host       string    `yaml:"host"`
@@ -31,6 +41,7 @@ type config struct {
 	} `yaml:"document"`
 	} `yaml:"document"`
 
 
 	Theme struct {
 	Theme struct {
+		// Todo : Find a way to use CssProperties struct to avoid duplicates
 		BackgroundColor          *hslColorField `yaml:"background-color"`
 		BackgroundColor          *hslColorField `yaml:"background-color"`
 		PrimaryColor             *hslColorField `yaml:"primary-color"`
 		PrimaryColor             *hslColorField `yaml:"primary-color"`
 		PositiveColor            *hslColorField `yaml:"positive-color"`
 		PositiveColor            *hslColorField `yaml:"positive-color"`
@@ -39,6 +50,8 @@ type config struct {
 		ContrastMultiplier       float32        `yaml:"contrast-multiplier"`
 		ContrastMultiplier       float32        `yaml:"contrast-multiplier"`
 		TextSaturationMultiplier float32        `yaml:"text-saturation-multiplier"`
 		TextSaturationMultiplier float32        `yaml:"text-saturation-multiplier"`
 		CustomCSSFile            string         `yaml:"custom-css-file"`
 		CustomCSSFile            string         `yaml:"custom-css-file"`
+
+		Presets map[string]CssProperties `yaml:"presets"`
 	} `yaml:"theme"`
 	} `yaml:"theme"`
 
 
 	Branding struct {
 	Branding struct {

+ 17 - 4
internal/glance/glance.go

@@ -3,6 +3,7 @@ package glance
 import (
 import (
 	"bytes"
 	"bytes"
 	"context"
 	"context"
+	"encoding/json"
 	"fmt"
 	"fmt"
 	"html/template"
 	"html/template"
 	"log"
 	"log"
@@ -125,8 +126,9 @@ func (a *application) transformUserDefinedAssetPath(path string) string {
 }
 }
 
 
 type pageTemplateData struct {
 type pageTemplateData struct {
-	App  *application
-	Page *page
+	App     *application
+	Page    *page
+	Presets string
 }
 }
 
 
 func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
 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
 		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{
 	pageData := pageTemplateData{
-		Page: page,
-		App:  a,
+		App:     a,
+		Page:    page,
+		Presets: string(presetsAsJSON),
 	}
 	}
 
 
 	var responseBytes bytes.Buffer
 	var responseBytes bytes.Buffer

+ 120 - 0
internal/glance/static/js/main.js

@@ -2,6 +2,30 @@ 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';
 
 
+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
     // TODO: add retries
     // 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() {
 async function setupPage() {
     const pageElement = document.getElementById("page");
     const pageElement = document.getElementById("page");
     const pageContentElement = document.getElementById("page-content");
     const pageContentElement = document.getElementById("page-content");
@@ -657,6 +776,7 @@ async function setupPage() {
     pageContentElement.innerHTML = pageContent;
     pageContentElement.innerHTML = pageContent;
 
 
     try {
     try {
+        setupThemeSwitcher();
         setupPopovers();
         setupPopovers();
         setupClocks()
         setupClocks()
         await setupCalendars();
         await setupCalendars();

+ 102 - 1
internal/glance/static/main.css

@@ -56,6 +56,28 @@
     --font-size-h6: 1.1rem;
     --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 {
 .light-scheme {
     --scheme: 100% -;
     --scheme: 100% -;
 }
 }
@@ -1844,7 +1866,6 @@ details[open] .summary::after {
         padding: 15px var(--content-bounds-padding);
         padding: 15px var(--content-bounds-padding);
         display: flex;
         display: flex;
         align-items: center;
         align-items: center;
-        overflow-x: auto;
         scrollbar-width: thin;
         scrollbar-width: thin;
         gap: 2.5rem;
         gap: 2.5rem;
     }
     }
@@ -2095,3 +2116,83 @@ details[open] .summary::after {
 .list-gap-20        { --list-half-gap: 1rem; }
 .list-gap-20        { --list-half-gap: 1rem; }
 .list-gap-24        { --list-half-gap: 1.2rem; }
 .list-gap-24        { --list-half-gap: 1.2rem; }
 .list-gap-34        { --list-half-gap: 1.7rem; }
 .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);
+}

+ 1 - 1
internal/glance/templates/document.html

@@ -5,7 +5,7 @@
     <title>{{ block "document-title" . }}{{ end }}</title>
     <title>{{ block "document-title" . }}{{ end }}</title>
     <script>if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');</script>
     <script>if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');</script>
     <meta charset="UTF-8">
     <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="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">

+ 31 - 1
internal/glance/templates/page.html

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

+ 19 - 2
internal/glance/templates/theme-style.gotmpl

@@ -1,14 +1,31 @@
 <style>
 <style>
-:root {
+.default {
     {{ if .BackgroundColor }}
     {{ if .BackgroundColor }}
     --bgh: {{ .BackgroundColor.Hue }};
     --bgh: {{ .BackgroundColor.Hue }};
     --bgs: {{ .BackgroundColor.Saturation }}%;
     --bgs: {{ .BackgroundColor.Saturation }}%;
     --bgl: {{ .BackgroundColor.Lightness }}%;
     --bgl: {{ .BackgroundColor.Lightness }}%;
     {{ 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 }}
 }
 }
-</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>