mirror of
https://github.com/Xevion/glance.git
synced 2025-12-15 12:12:01 -06:00
Merge pull request #299 from hecht-a/theme_switcher
feat: theme switcher
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
19
internal/glance/templates/theme-preset-preview.html
Normal file
19
internal/glance/templates/theme-preset-preview.html
Normal 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>
|
||||
@@ -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
104
internal/glance/theme.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user