Merge pull request #299 from hecht-a/theme_switcher

feat: theme switcher
This commit is contained in:
Svilen Markov
2025-05-04 16:53:56 +01:00
committed by GitHub
17 changed files with 606 additions and 95 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]);
}

View File

@@ -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);
}
}

View File

@@ -1,9 +1,16 @@
<!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>
{{ 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>
<script>if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');</script>
<meta charset="UTF-8">
<meta name="color-scheme" content="dark">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
@@ -11,12 +18,15 @@
<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-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="manifest" href='{{ .App.VersionedAssetPath "manifest.json" }}'>
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
<script type="module" src='{{ .App.StaticAssetPath "js/main.js" }}'></script>
<style id="theme-style">
{{ .Request.Theme.CSS }}
</style>
{{ block "document-head-after" . }}{{ end }}
</head>
<body>

View File

@@ -2,20 +2,7 @@
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
{{ define "document-head-before" }}
<script>
const pageData = {
slug: "{{ .Page.Slug }}",
baseURL: "{{ .App.Config.Server.BaseURL }}",
};
</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" }}
{{ .App.ParsedThemeStyle }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.CreatedAt.Unix }}">
{{ end }}
@@ -36,9 +23,22 @@
<div class="header flex padding-inline-widget widget-content-frame">
<!-- 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>
<nav class="nav flex grow">
<nav class="nav flex grow hide-scrollbars">
{{ template "navigation-links" . }}
</nav>
<div class="theme-picker self-center" data-popover-type="html" data-popover-position="below" data-popover-show-delay="0" data-popover-offset="0.7">
<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>
{{ end }}
@@ -49,15 +49,35 @@
{{ 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>
{{ 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 class="mobile-navigation-page-links">
<div class="mobile-navigation-page-links hide-scrollbars">
{{ template "navigation-links" . }}
</div>
<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 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>
<div class="page-content" id="page-content"></div>
<div class="page-loading-container">

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

View File

@@ -1,9 +1,8 @@
<style>
:root {
{{ if .BackgroundColor }}
--bgh: {{ .BackgroundColor.Hue }};
--bgs: {{ .BackgroundColor.Saturation }}%;
--bgl: {{ .BackgroundColor.Lightness }}%;
--bgh: {{ .BackgroundColor.H }};
--bgs: {{ .BackgroundColor.S }}%;
--bgl: {{ .BackgroundColor.L }}%;
{{ end }}
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
@@ -11,4 +10,3 @@
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.String | safeCSS }};{{ end }}
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.String | safeCSS }};{{ end }}
}
</style>

104
internal/glance/theme.go Normal file
View 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
}

View File

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