From 88c3736110e99140177ed2abb2d62b97d7b2d3e2 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Fri, 20 Feb 2026 14:43:35 -0500 Subject: [PATCH] added color/ theme settings, new config window --- backend/app.go | 1 - backend/config/config-form.templ | 15 - backend/config/config-form_templ.go | 113 -- backend/config/config.go | 128 ++- backend/config/httphandler.go | 80 -- backend/events/events.go | 1 + backend/library/config.templ | 7 - backend/library/config_templ.go | 53 - backend/theme/config.go | 80 ++ frontend/index.css | 20 +- frontend/index.html | 3 - frontend/index.ts | 9 +- frontend/package.json | 1 - .../audio-player/controls/player-controls.ts | 4 +- .../audio-player/seekbar/seek-bar.ts | 12 +- .../volume-control/volume-control.ts | 10 +- .../components/config-page/config-field.ts | 410 +++++++ .../src/components/config-page/config-page.ts | 1000 +++++++++++++++++ .../components/config-page/config-section.ts | 69 ++ .../components/cover-grid/album-dropdown.ts | 28 +- .../src/components/cover-grid/cover-grid.ts | 44 +- .../library-manager/library-manager.ts | 80 +- .../src/components/now-playing/now-playing.ts | 8 +- .../playlist-picker/playlist-picker.ts | 34 +- .../components/playlist-view/playlist-view.ts | 100 +- .../src/components/queue-panel/queue-panel.ts | 50 +- .../src/components/sidebar/app-sidebar.ts | 43 +- .../src/components/track-info/track-info.ts | 10 +- .../src/components/track-list/track-list.ts | 28 +- frontend/src/events.ts | 3 + frontend/src/pages/config/config.css | 11 - frontend/src/pages/config/config.ts | 42 - frontend/src/pages/config/index.html | 26 - .../src/store/controllers/theme-controller.ts | 65 ++ frontend/src/store/index.ts | 3 + frontend/src/store/theme-store.ts | 291 +++++ frontend/vite.config.mts | 1 - frontend/wailsjs/go/config/Config.d.ts | 11 +- frontend/wailsjs/go/config/Config.js | 20 +- frontend/wailsjs/go/models.ts | 601 ---------- go.mod | 1 - go.sum | 2 - package.json | 3 - 43 files changed, 2314 insertions(+), 1207 deletions(-) delete mode 100644 backend/config/config-form.templ delete mode 100644 backend/config/config-form_templ.go delete mode 100644 backend/config/httphandler.go delete mode 100644 backend/library/config.templ delete mode 100644 backend/library/config_templ.go create mode 100644 backend/theme/config.go create mode 100644 frontend/src/components/config-page/config-field.ts create mode 100644 frontend/src/components/config-page/config-page.ts create mode 100644 frontend/src/components/config-page/config-section.ts delete mode 100644 frontend/src/pages/config/config.css delete mode 100644 frontend/src/pages/config/config.ts delete mode 100644 frontend/src/pages/config/index.html create mode 100644 frontend/src/store/controllers/theme-controller.ts create mode 100644 frontend/src/store/theme-store.ts diff --git a/backend/app.go b/backend/app.go index e00877d..5e41e50 100644 --- a/backend/app.go +++ b/backend/app.go @@ -65,7 +65,6 @@ func NewYellowJacketApp( } yjApp.appConfig = appConfig - yjApp.assetHandler.RegisterHandler("/config", yjApp.appConfig) // create frontendUtil feUtil, err := frontendutil.NewFrontendUtil() diff --git a/backend/config/config-form.templ b/backend/config/config-form.templ deleted file mode 100644 index 2ee7d6b..0000000 --- a/backend/config/config-form.templ +++ /dev/null @@ -1,15 +0,0 @@ -package config - -import "yellowjacket/pkg/templcomp" - -templ (c *Config) form() { - @templcomp.ToForm(c, templ.URL("/config"), "config") -} - -templ (c *Config) formSubmitError(msg string) { - Error: { msg } -} - -templ (c *Config) formSubmitSuccess() { -

Config saved

-} diff --git a/backend/config/config-form_templ.go b/backend/config/config-form_templ.go deleted file mode 100644 index 593600d..0000000 --- a/backend/config/config-form_templ.go +++ /dev/null @@ -1,113 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.977 -package config - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -import "yellowjacket/pkg/templcomp" - -func (c *Config) form() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templcomp.ToForm(c, templ.URL("/config"), "config").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func (c *Config) formSubmitError(msg string) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var2 := templ.GetChildren(ctx) - if templ_7745c5c3_Var2 == nil { - templ_7745c5c3_Var2 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Error: ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(msg) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `config/config-form.templ`, Line: 10, Col: 21} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func (c *Config) formSubmitSuccess() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var4 := templ.GetChildren(ctx) - if templ_7745c5c3_Var4 == nil { - templ_7745c5c3_Var4 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Config saved

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/backend/config/config.go b/backend/config/config.go index 85fedd8..d2739f8 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "log/slog" - "net/http" "os" "path" @@ -16,17 +15,17 @@ import ( "yellowjacket/backend/events" "yellowjacket/backend/library" "yellowjacket/backend/system" + "yellowjacket/backend/theme" ) // Config represents the application configuration. type Config struct { ctx context.Context logger *slog.Logger - serveMux *http.ServeMux filePath string // required - Library *library.Config `form:"Library" schema:"library,required"` - - Window *WindowConfig `toml:"Window"` + Library *library.Config `toml:"Library"` + Theme *theme.Config `toml:"Theme"` + Window *WindowConfig `toml:"Window"` } // NewConfig creates a new config by loading it from disk. @@ -38,11 +37,9 @@ func NewConfig(logger *slog.Logger) (*Config, error) { conf := &Config{ filePath: path.Join(confDir, "config.toml"), - serveMux: http.NewServeMux(), } conf.applyDefaults() conf.logger = logger.WithGroup("config").With("config", conf) - conf.serveMux.HandleFunc("/", conf.handle) if err := conf.Load(); err != nil { return nil, fmt.Errorf("could not load config: %w", err) @@ -67,8 +64,17 @@ func (c *Config) Validate() error { } } + if c.Theme != nil { + if err := c.Theme.Validate(); err != nil { + configErrs = errors.Join(configErrs, err) + } + } + if configErrs != nil { - return fmt.Errorf("one or more config parts are invalid: %w", configErrs) + return fmt.Errorf( + "one or more config parts are invalid: %w", + configErrs, + ) } return nil @@ -148,6 +154,12 @@ func (c *Config) applyDefaults() { if c.Library != nil { c.Library.ApplyDefaults() } + + if c.Theme == nil { + c.Theme = &theme.Config{} + } + + c.Theme.ApplyDefaults() } // SetContext sets the Wails runtime context for event emission. @@ -245,3 +257,103 @@ func (c *Config) SetScanConcurrency(mode string) error { return nil } + +// GetThemeAccentColor returns the configured accent colour. +func (c *Config) GetThemeAccentColor() string { + if c.Theme == nil { + return theme.DefaultAccentColor + } + + return c.Theme.AccentColor +} + +// GetThemeBackgroundShade returns the configured background shade. +func (c *Config) GetThemeBackgroundShade() string { + if c.Theme == nil { + return string(theme.DefaultBackgroundShade) + } + + return string(c.Theme.BackgroundShade) +} + +// SetThemeAccentColor validates and saves a new accent colour. +func (c *Config) SetThemeAccentColor( + color string, +) error { + if c.Theme == nil { + c.Theme = &theme.Config{} + c.Theme.ApplyDefaults() + } + + c.Theme.AccentColor = color + + if err := c.Theme.Validate(); err != nil { + return fmt.Errorf( + "invalid theme accent color: %w", err, + ) + } + + if err := c.Save(); err != nil { + return fmt.Errorf( + "could not save config: %w", err, + ) + } + + c.emitThemeChanged() + + c.logger.Info( + "theme accent color updated", + "color", color, + ) + + return nil +} + +// SetThemeBackgroundShade validates and saves a new background shade. +func (c *Config) SetThemeBackgroundShade( + shade string, +) error { + if c.Theme == nil { + c.Theme = &theme.Config{} + c.Theme.ApplyDefaults() + } + + c.Theme.BackgroundShade = theme.BackgroundShade(shade) + + if err := c.Theme.Validate(); err != nil { + return fmt.Errorf( + "invalid theme background shade: %w", err, + ) + } + + if err := c.Save(); err != nil { + return fmt.Errorf( + "could not save config: %w", err, + ) + } + + c.emitThemeChanged() + + c.logger.Info( + "theme background shade updated", + "shade", shade, + ) + + return nil +} + +// emitThemeChanged sends the ThemeConfigChanged event to the frontend. +func (c *Config) emitThemeChanged() { + if c.ctx == nil || c.Theme == nil { + return + } + + runtime.EventsEmit( + c.ctx, + events.ThemeConfigChanged, + map[string]any{ + "AccentColor": c.Theme.AccentColor, + "BackgroundShade": string(c.Theme.BackgroundShade), + }, + ) +} diff --git a/backend/config/httphandler.go b/backend/config/httphandler.go deleted file mode 100644 index 8aa509f..0000000 --- a/backend/config/httphandler.go +++ /dev/null @@ -1,80 +0,0 @@ -package config - -import ( - "fmt" - "net/http" - - "github.com/gorilla/schema" - "github.com/wailsapp/wails/v2/pkg/runtime" - - "yellowjacket/backend/events" -) - -var formDecoder = schema.NewDecoder() - -func (c *Config) ServeHTTP(w http.ResponseWriter, r *http.Request) { - c.serveMux.ServeHTTP(w, r) -} - -func (c *Config) handle(w http.ResponseWriter, r *http.Request) { - c.logger.Debug("handling request from config http handler") - - switch r.Method { - case http.MethodGet: - if err := c.form().Render(r.Context(), w); err != nil { - c.logger.Error("problem getting config html", "err", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - } - case http.MethodPost: - if err := c.handleConfigPost(r); err != nil { - c.logger.Error("problem handling config post request", "err", err.Error()) - - renderErr := c.formSubmitError(err.Error()).Render(r.Context(), w) - if renderErr != nil { - c.logger.Error("problem rendering error response", "err", renderErr.Error()) - } - - w.WriteHeader(http.StatusInternalServerError) - - return - } - - if err := c.formSubmitSuccess().Render(r.Context(), w); err != nil { - c.logger.Error("problem rendering success response", "err", err.Error()) - } - - w.WriteHeader(http.StatusOK) - } -} - -func (c *Config) handleConfigPost(r *http.Request) error { - if err := r.ParseForm(); err != nil { - return fmt.Errorf("could not parse form data: %w", err) - } - - var postedConfig Config - - err := formDecoder.Decode(&postedConfig, r.PostForm) - if err != nil { - return fmt.Errorf("could not decode form data: %w", err) - } - - c.logger.Debug("decoded config post form data", "postedConfig", postedConfig) - - // Update local config and emit event for listeners - if postedConfig.Library != nil { - c.Library = postedConfig.Library - - if c.ctx != nil { - runtime.EventsEmit(c.ctx, events.LibraryConfigChanged, map[string]any{ - "DirectoryPath": string(c.Library.DirectoryPath), - }) - } - } - - if err := c.Save(); err != nil { - return fmt.Errorf("could not save posted config: %w", err) - } - - return nil -} diff --git a/backend/events/events.go b/backend/events/events.go index 12fc7e4..6635320 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -55,6 +55,7 @@ const ( // Config events. const ( LibraryConfigChanged = "LibraryConfigChanged" + ThemeConfigChanged = "ThemeConfigChanged" ) // Playlist events. diff --git a/backend/library/config.templ b/backend/library/config.templ deleted file mode 100644 index 099fce2..0000000 --- a/backend/library/config.templ +++ /dev/null @@ -1,7 +0,0 @@ -package library - -templ (d Directory) ToFormElement() { - - - -} diff --git a/backend/library/config_templ.go b/backend/library/config_templ.go deleted file mode 100644 index a110626..0000000 --- a/backend/library/config_templ.go +++ /dev/null @@ -1,53 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.977 -package library - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func (d Directory) ToFormElement() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/backend/theme/config.go b/backend/theme/config.go new file mode 100644 index 0000000..c29b8c7 --- /dev/null +++ b/backend/theme/config.go @@ -0,0 +1,80 @@ +// Package theme manages visual theme configuration. +package theme + +import ( + "errors" + "fmt" + "regexp" +) + +var ( + errInvalidHexColor = errors.New("invalid hex color") + errUnknownBackgroundShade = errors.New("unknown background shade") +) + +// hexColorRe matches 3- or 6-digit CSS hex colours (e.g. "#fff", "#ffd43b"). +var hexColorRe = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) + +// BackgroundShade controls the base grayscale palette. +type BackgroundShade string + +// Valid BackgroundShade values. +const ( + // BackgroundDarker is an OLED-friendly palette with true black. + BackgroundDarker BackgroundShade = "darker" + + // BackgroundDark is the default dark palette. + BackgroundDark BackgroundShade = "dark" + + // BackgroundLight is a light-mode palette. + BackgroundLight BackgroundShade = "light" +) + +// Defaults. +const ( + DefaultAccentColor = "#ffd43b" + DefaultBackgroundShade = BackgroundDark +) + +// Config holds visual theme preferences. +type Config struct { + AccentColor string `toml:"AccentColor"` + BackgroundShade BackgroundShade `toml:"BackgroundShade"` +} + +// ApplyDefaults fills zero-value fields with sensible defaults. +func (c *Config) ApplyDefaults() { + if c.AccentColor == "" { + c.AccentColor = DefaultAccentColor + } + + if c.BackgroundShade == "" { + c.BackgroundShade = DefaultBackgroundShade + } +} + +// Validate checks that all values are well-formed. +func (c *Config) Validate() error { + c.ApplyDefaults() + + if !hexColorRe.MatchString(c.AccentColor) { + return fmt.Errorf( + "%w: %q", + errInvalidHexColor, + c.AccentColor, + ) + } + + switch c.BackgroundShade { + case BackgroundDarker, BackgroundDark, BackgroundLight: + // Valid. + default: + return fmt.Errorf( + "%w: %q", + errUnknownBackgroundShade, + c.BackgroundShade, + ) + } + + return nil +} diff --git a/frontend/index.css b/frontend/index.css index dd50ca3..9edc5f8 100644 --- a/frontend/index.css +++ b/frontend/index.css @@ -10,8 +10,8 @@ html { } body { - background-color: black; - color: white; + background-color: var(--yj-bg-base, black); + color: var(--yj-text-primary, white); margin: 0; height: 100vh; display: grid; @@ -32,7 +32,7 @@ p { display: flex; justify-content: space-between; align-items: center; - background-color: #343a40; + background-color: var(--yj-bg-elevated, #343a40); } ul { @@ -51,14 +51,14 @@ ul { body div.sidebar { grid-area: sidebar; - background-color: #212529; + background-color: var(--yj-bg-surface, #212529); overflow: hidden; } .bottom-bar { grid-area: bottom-bar; padding: 0.25em; - background-color: #343a40; + background-color: var(--yj-bg-elevated, #343a40); display: grid; grid-template-columns: var(--now-playing-width, 200px) 1fr auto; align-items: center; @@ -75,7 +75,7 @@ body div.sidebar { height: 3.5em; min-height: 3.5em; border-radius: 0.25em; - background-color: #ffd43b; + background-color: var(--yj-accent, #ffd43b); } #track-info { @@ -114,12 +114,12 @@ body div.sidebar { } #queue-button:hover { - color: #ffd43b; + color: var(--yj-accent, #ffd43b); } #queue-button.drag-over { - color: #ffd43b; - outline: 2px dashed #ffd43b; + color: var(--yj-accent, #ffd43b); + outline: 2px dashed var(--yj-accent, #ffd43b); outline-offset: -2px; border-radius: 4px; } @@ -135,7 +135,7 @@ body div.sidebar { flex: 1; min-width: 0; padding: 0.25em; - background-color: #212529; + background-color: var(--yj-bg-surface, #212529); overflow: hidden; } diff --git a/frontend/index.html b/frontend/index.html index 0a89701..06c1258 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,9 +15,6 @@

YellowJacket

Music how it was meant to bee.

- - -