From 1aeb6408aae42d79d48c2a790345c0ada839b567 Mon Sep 17 00:00:00 2001 From: Gabe Farrell <90876006+gabehf@users.noreply.github.com> Date: Tue, 18 Nov 2025 18:37:05 -0500 Subject: [PATCH] feat: add server-side configuration with default theme (#90) * docs: add example for usage of the main listenbrainz instance (#71) * docs: add example for usage of the main listenbrainz instance * Update scrobbler.md --------- Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com> * feat: add server-side cfg and default theme * fix: repair custom theme --------- Co-authored-by: m0d3rnX --- client/api/api.ts | 11 ++++++- .../themeSwitcher/ThemeSwitcher.tsx | 14 +++++--- client/app/providers/AppProvider.tsx | 13 ++++++-- client/app/providers/ThemeProvider.tsx | 32 ++++++++++++++----- client/app/root.tsx | 12 ++----- docs/src/content/docs/guides/scrobbler.md | 1 + engine/handlers/server_cfg.go | 18 +++++++++++ engine/routes.go | 1 + internal/cfg/cfg.go | 10 ++++++ 9 files changed, 86 insertions(+), 26 deletions(-) create mode 100644 engine/handlers/server_cfg.go diff --git a/client/api/api.ts b/client/api/api.ts index b744a05..e2fc363 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -101,6 +101,10 @@ function logout(): Promise { }) } +function getCfg(): Promise { + return fetch(`/apis/web/v1/config`).then(r => r.json() as Promise) +} + function getApiKeys(): Promise { return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise) } @@ -214,6 +218,7 @@ export { imageUrl, login, logout, + getCfg, deleteItem, updateUser, getAliases, @@ -309,6 +314,9 @@ type ApiKey = { type ApiError = { error: string } +type Config = { + default_theme: string +} export type { getItemsArgs, @@ -323,5 +331,6 @@ export type { User, Alias, ApiKey, - ApiError + ApiError, + Config } diff --git a/client/app/components/themeSwitcher/ThemeSwitcher.tsx b/client/app/components/themeSwitcher/ThemeSwitcher.tsx index fdc8709..b9d4aee 100644 --- a/client/app/components/themeSwitcher/ThemeSwitcher.tsx +++ b/client/app/components/themeSwitcher/ThemeSwitcher.tsx @@ -1,12 +1,11 @@ -// ThemeSwitcher.tsx -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useTheme } from '../../hooks/useTheme'; import themes from '~/styles/themes.css'; import ThemeOption from './ThemeOption'; import { AsyncButton } from '../AsyncButton'; export function ThemeSwitcher() { - const { theme, themeName, setTheme } = useTheme(); + const { setTheme } = useTheme(); const initialTheme = { bg: "#1e1816", bgSecondary: "#2f2623", @@ -24,7 +23,7 @@ export function ThemeSwitcher() { info: "#87b8dd", } - const { setCustomTheme, getCustomTheme } = useTheme() + const { setCustomTheme, getCustomTheme, resetTheme } = useTheme() const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")) const handleCustomTheme = () => { @@ -42,7 +41,12 @@ export function ThemeSwitcher() { return (
-

Select Theme

+
+

Select Theme

+
+ Reset +
+
{Object.entries(themes).map(([name, themeData]) => ( diff --git a/client/app/providers/AppProvider.tsx b/client/app/providers/AppProvider.tsx index 9614db8..1cceb99 100644 --- a/client/app/providers/AppProvider.tsx +++ b/client/app/providers/AppProvider.tsx @@ -1,10 +1,11 @@ -import type { User } from "api/api"; +import { getCfg, type User } from "api/api"; import { createContext, useContext, useEffect, useState } from "react"; interface AppContextType { user: User | null | undefined; configurableHomeActivity: boolean; homeItems: number; + defaultTheme: string; setConfigurableHomeActivity: (value: boolean) => void; setHomeItems: (value: number) => void; setUsername: (value: string) => void; @@ -22,6 +23,7 @@ export const useAppContext = () => { export const AppProvider = ({ children }: { children: React.ReactNode }) => { const [user, setUser] = useState(undefined); + const [defaultTheme, setDefaultTheme] = useState(undefined) const [configurableHomeActivity, setConfigurableHomeActivity] = useState(false); const [homeItems, setHomeItems] = useState(0); @@ -42,9 +44,15 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { setConfigurableHomeActivity(true); setHomeItems(12); + + getCfg().then(cfg => { + console.log(cfg) + setDefaultTheme(cfg.default_theme) + }) }, []); - if (user === undefined) { + // Block rendering the app until config is loaded + if (user === undefined || defaultTheme === undefined) { return null; } @@ -52,6 +60,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { user, configurableHomeActivity, homeItems, + defaultTheme, setConfigurableHomeActivity, setHomeItems, setUsername, diff --git a/client/app/providers/ThemeProvider.tsx b/client/app/providers/ThemeProvider.tsx index 1a4f9e8..aa4f874 100644 --- a/client/app/providers/ThemeProvider.tsx +++ b/client/app/providers/ThemeProvider.tsx @@ -1,11 +1,13 @@ import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react'; import { type Theme, themes } from '~/styles/themes.css'; import { themeVars } from '~/styles/vars.css'; +import { useAppContext } from './AppProvider'; interface ThemeContextValue { themeName: string; theme: Theme; setTheme: (theme: string) => void; + resetTheme: () => void; setCustomTheme: (theme: Theme) => void; getCustomTheme: () => Theme | undefined; } @@ -43,19 +45,19 @@ function getStoredCustomTheme(): Theme | undefined { } export function ThemeProvider({ - theme: initialTheme, children, }: { - theme: string; children: ReactNode; }) { + let defaultTheme = useAppContext().defaultTheme + let initialTheme = localStorage.getItem("theme") ?? defaultTheme const [themeName, setThemeName] = useState(initialTheme); const [currentTheme, setCurrentTheme] = useState(() => { if (initialTheme === 'custom') { const customTheme = getStoredCustomTheme(); - return customTheme || themes.yuu; + return customTheme || themes[defaultTheme]; } - return themes[initialTheme] || themes.yuu; + return themes[initialTheme] || themes[defaultTheme]; }); const setTheme = (newThemeName: string) => { @@ -66,21 +68,29 @@ export function ThemeProvider({ setCurrentTheme(customTheme); } else { // Fallback to default theme if no custom theme found - setThemeName('yuu'); - setCurrentTheme(themes.yuu); + setThemeName(defaultTheme); + setCurrentTheme(themes[defaultTheme]); } } else { const foundTheme = themes[newThemeName]; if (foundTheme) { + localStorage.setItem('theme', newThemeName) setCurrentTheme(foundTheme); } } } + const resetTheme = () => { + setThemeName(defaultTheme) + localStorage.removeItem('theme') + setCurrentTheme(themes[defaultTheme]) + } + const setCustomTheme = useCallback((customTheme: Theme) => { localStorage.setItem('custom-theme', JSON.stringify(customTheme)); applyCustomThemeVars(customTheme); setThemeName('custom'); + localStorage.setItem('theme', 'custom') setCurrentTheme(customTheme); }, []); @@ -92,7 +102,6 @@ export function ThemeProvider({ const root = document.documentElement; root.setAttribute('data-theme', themeName); - localStorage.setItem('theme', themeName); if (themeName === 'custom') { applyCustomThemeVars(currentTheme); @@ -102,7 +111,14 @@ export function ThemeProvider({ }, [themeName, currentTheme]); return ( - + {children} ); diff --git a/client/app/root.tsx b/client/app/root.tsx index b210088..21e49ff 100644 --- a/client/app/root.tsx +++ b/client/app/root.tsx @@ -58,12 +58,10 @@ export function Layout({ children }: { children: React.ReactNode }) { } export default function App() { - let theme = localStorage.getItem('theme') ?? 'yuu' - return ( <> - +
@@ -99,18 +97,12 @@ export function ErrorBoundary() { stack = error.stack; } - let theme = 'yuu' - try { - theme = localStorage.getItem('theme') ?? theme - } catch(err) { - console.log(err) - } const title = `${message} - Koito` return ( - + {title}
diff --git a/docs/src/content/docs/guides/scrobbler.md b/docs/src/content/docs/guides/scrobbler.md index 720c197..fab9426 100644 --- a/docs/src/content/docs/guides/scrobbler.md +++ b/docs/src/content/docs/guides/scrobbler.md @@ -32,4 +32,5 @@ Once the relay is configured, Koito will automatically forward any requests it r :::note Be sure to include the full path to the ListenBrainz endpoint of the server you are relaying to in the `KOITO_LBZ_RELAY_URL`. +For example, to relay to the main ListenBrainz instance, you would set `KOITO_ENABLE_LBZ_RELAY` to `https://api.listenbrainz.org/1`. ::: diff --git a/engine/handlers/server_cfg.go b/engine/handlers/server_cfg.go new file mode 100644 index 0000000..ebc7b9f --- /dev/null +++ b/engine/handlers/server_cfg.go @@ -0,0 +1,18 @@ +package handlers + +import ( + "net/http" + + "github.com/gabehf/koito/internal/cfg" + "github.com/gabehf/koito/internal/utils" +) + +type ServerConfig struct { + DefaultTheme string `json:"default_theme"` +} + +func GetCfgHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + utils.WriteJSON(w, http.StatusOK, ServerConfig{DefaultTheme: cfg.DefaultTheme()}) + } +} diff --git a/engine/routes.go b/engine/routes.go index 6f43406..83f05fc 100644 --- a/engine/routes.go +++ b/engine/routes.go @@ -35,6 +35,7 @@ func bindRoutes( Get("/images/{size}/{filename}", handlers.ImageHandler(db)) r.Route("/apis/web/v1", func(r chi.Router) { + r.Get("/config", handlers.GetCfgHandler()) r.Get("/artist", handlers.GetArtistHandler(db)) r.Get("/artists", handlers.GetArtistsForItemHandler(db)) r.Get("/album", handlers.GetAlbumHandler(db)) diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index ca69a25..8f702ed 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -31,6 +31,7 @@ const ( CONFIG_DIR_ENV = "KOITO_CONFIG_DIR" DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME" DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD" + DEFAULT_THEME_ENV = "KOITO_DEFAULT_THEME" DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER" DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE" DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" @@ -60,6 +61,7 @@ type config struct { lbzRelayToken string defaultPw string defaultUsername string + defaultTheme string disableDeezer bool disableCAA bool disableMusicBrainz bool @@ -162,6 +164,8 @@ func loadConfig(getenv func(string) string, version string) (*config, error) { cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV) } + cfg.defaultTheme = getenv(DEFAULT_THEME_ENV) + cfg.configDir = getenv(CONFIG_DIR_ENV) if cfg.configDir == "" { cfg.configDir = "/etc/koito" @@ -277,6 +281,12 @@ func DefaultUsername() string { return globalConfig.defaultUsername } +func DefaultTheme() string { + lock.RLock() + defer lock.RUnlock() + return globalConfig.defaultTheme +} + func FullImageCacheEnabled() bool { lock.RLock() defer lock.RUnlock()