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 <jesper@posteo.de>
pull/92/head
Gabe Farrell 3 weeks ago committed by GitHub
parent 70f5198781
commit 1aeb6408aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -101,6 +101,10 @@ function logout(): Promise<Response> {
}) })
} }
function getCfg(): Promise<Config> {
return fetch(`/apis/web/v1/config`).then(r => r.json() as Promise<Config>)
}
function getApiKeys(): Promise<ApiKey[]> { function getApiKeys(): Promise<ApiKey[]> {
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>) return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>)
} }
@ -214,6 +218,7 @@ export {
imageUrl, imageUrl,
login, login,
logout, logout,
getCfg,
deleteItem, deleteItem,
updateUser, updateUser,
getAliases, getAliases,
@ -309,6 +314,9 @@ type ApiKey = {
type ApiError = { type ApiError = {
error: string error: string
} }
type Config = {
default_theme: string
}
export type { export type {
getItemsArgs, getItemsArgs,
@ -323,5 +331,6 @@ export type {
User, User,
Alias, Alias,
ApiKey, ApiKey,
ApiError ApiError,
Config
} }

@ -1,12 +1,11 @@
// ThemeSwitcher.tsx import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTheme } from '../../hooks/useTheme'; import { useTheme } from '../../hooks/useTheme';
import themes from '~/styles/themes.css'; import themes from '~/styles/themes.css';
import ThemeOption from './ThemeOption'; import ThemeOption from './ThemeOption';
import { AsyncButton } from '../AsyncButton'; import { AsyncButton } from '../AsyncButton';
export function ThemeSwitcher() { export function ThemeSwitcher() {
const { theme, themeName, setTheme } = useTheme(); const { setTheme } = useTheme();
const initialTheme = { const initialTheme = {
bg: "#1e1816", bg: "#1e1816",
bgSecondary: "#2f2623", bgSecondary: "#2f2623",
@ -24,7 +23,7 @@ export function ThemeSwitcher() {
info: "#87b8dd", info: "#87b8dd",
} }
const { setCustomTheme, getCustomTheme } = useTheme() const { setCustomTheme, getCustomTheme, resetTheme } = useTheme()
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")) const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
const handleCustomTheme = () => { const handleCustomTheme = () => {
@ -42,7 +41,12 @@ export function ThemeSwitcher() {
return ( return (
<div className='flex flex-col gap-10'> <div className='flex flex-col gap-10'>
<div> <div>
<div className='flex items-center gap-3'>
<h2>Select Theme</h2> <h2>Select Theme</h2>
<div className='mb-3'>
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
</div>
</div>
<div className="grid grid-cols-2 items-center gap-2"> <div className="grid grid-cols-2 items-center gap-2">
{Object.entries(themes).map(([name, themeData]) => ( {Object.entries(themes).map(([name, themeData]) => (
<ThemeOption setTheme={setTheme} key={name} theme={themeData} themeName={name} /> <ThemeOption setTheme={setTheme} key={name} theme={themeData} themeName={name} />

@ -1,10 +1,11 @@
import type { User } from "api/api"; import { getCfg, type User } from "api/api";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
interface AppContextType { interface AppContextType {
user: User | null | undefined; user: User | null | undefined;
configurableHomeActivity: boolean; configurableHomeActivity: boolean;
homeItems: number; homeItems: number;
defaultTheme: string;
setConfigurableHomeActivity: (value: boolean) => void; setConfigurableHomeActivity: (value: boolean) => void;
setHomeItems: (value: number) => void; setHomeItems: (value: number) => void;
setUsername: (value: string) => void; setUsername: (value: string) => void;
@ -22,6 +23,7 @@ export const useAppContext = () => {
export const AppProvider = ({ children }: { children: React.ReactNode }) => { export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<User | null | undefined>(undefined); const [user, setUser] = useState<User | null | undefined>(undefined);
const [defaultTheme, setDefaultTheme] = useState<string | undefined>(undefined)
const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false); const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false);
const [homeItems, setHomeItems] = useState<number>(0); const [homeItems, setHomeItems] = useState<number>(0);
@ -42,9 +44,15 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setConfigurableHomeActivity(true); setConfigurableHomeActivity(true);
setHomeItems(12); 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; return null;
} }
@ -52,6 +60,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
user, user,
configurableHomeActivity, configurableHomeActivity,
homeItems, homeItems,
defaultTheme,
setConfigurableHomeActivity, setConfigurableHomeActivity,
setHomeItems, setHomeItems,
setUsername, setUsername,

@ -1,11 +1,13 @@
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react'; import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
import { type Theme, themes } from '~/styles/themes.css'; import { type Theme, themes } from '~/styles/themes.css';
import { themeVars } from '~/styles/vars.css'; import { themeVars } from '~/styles/vars.css';
import { useAppContext } from './AppProvider';
interface ThemeContextValue { interface ThemeContextValue {
themeName: string; themeName: string;
theme: Theme; theme: Theme;
setTheme: (theme: string) => void; setTheme: (theme: string) => void;
resetTheme: () => void;
setCustomTheme: (theme: Theme) => void; setCustomTheme: (theme: Theme) => void;
getCustomTheme: () => Theme | undefined; getCustomTheme: () => Theme | undefined;
} }
@ -43,19 +45,19 @@ function getStoredCustomTheme(): Theme | undefined {
} }
export function ThemeProvider({ export function ThemeProvider({
theme: initialTheme,
children, children,
}: { }: {
theme: string;
children: ReactNode; children: ReactNode;
}) { }) {
let defaultTheme = useAppContext().defaultTheme
let initialTheme = localStorage.getItem("theme") ?? defaultTheme
const [themeName, setThemeName] = useState(initialTheme); const [themeName, setThemeName] = useState(initialTheme);
const [currentTheme, setCurrentTheme] = useState<Theme>(() => { const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
if (initialTheme === 'custom') { if (initialTheme === 'custom') {
const customTheme = getStoredCustomTheme(); 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) => { const setTheme = (newThemeName: string) => {
@ -66,21 +68,29 @@ export function ThemeProvider({
setCurrentTheme(customTheme); setCurrentTheme(customTheme);
} else { } else {
// Fallback to default theme if no custom theme found // Fallback to default theme if no custom theme found
setThemeName('yuu'); setThemeName(defaultTheme);
setCurrentTheme(themes.yuu); setCurrentTheme(themes[defaultTheme]);
} }
} else { } else {
const foundTheme = themes[newThemeName]; const foundTheme = themes[newThemeName];
if (foundTheme) { if (foundTheme) {
localStorage.setItem('theme', newThemeName)
setCurrentTheme(foundTheme); setCurrentTheme(foundTheme);
} }
} }
} }
const resetTheme = () => {
setThemeName(defaultTheme)
localStorage.removeItem('theme')
setCurrentTheme(themes[defaultTheme])
}
const setCustomTheme = useCallback((customTheme: Theme) => { const setCustomTheme = useCallback((customTheme: Theme) => {
localStorage.setItem('custom-theme', JSON.stringify(customTheme)); localStorage.setItem('custom-theme', JSON.stringify(customTheme));
applyCustomThemeVars(customTheme); applyCustomThemeVars(customTheme);
setThemeName('custom'); setThemeName('custom');
localStorage.setItem('theme', 'custom')
setCurrentTheme(customTheme); setCurrentTheme(customTheme);
}, []); }, []);
@ -92,7 +102,6 @@ export function ThemeProvider({
const root = document.documentElement; const root = document.documentElement;
root.setAttribute('data-theme', themeName); root.setAttribute('data-theme', themeName);
localStorage.setItem('theme', themeName);
if (themeName === 'custom') { if (themeName === 'custom') {
applyCustomThemeVars(currentTheme); applyCustomThemeVars(currentTheme);
@ -102,7 +111,14 @@ export function ThemeProvider({
}, [themeName, currentTheme]); }, [themeName, currentTheme]);
return ( return (
<ThemeContext.Provider value={{ themeName, theme: currentTheme, setTheme, setCustomTheme, getCustomTheme }}> <ThemeContext.Provider value={{
themeName,
theme: currentTheme,
setTheme,
resetTheme,
setCustomTheme,
getCustomTheme
}}>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );

@ -58,12 +58,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
} }
export default function App() { export default function App() {
let theme = localStorage.getItem('theme') ?? 'yuu'
return ( return (
<> <>
<AppProvider> <AppProvider>
<ThemeProvider theme={theme}> <ThemeProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<div className="flex-col flex sm:flex-row"> <div className="flex-col flex sm:flex-row">
<Sidebar /> <Sidebar />
@ -99,18 +97,12 @@ export function ErrorBoundary() {
stack = error.stack; stack = error.stack;
} }
let theme = 'yuu'
try {
theme = localStorage.getItem('theme') ?? theme
} catch(err) {
console.log(err)
}
const title = `${message} - Koito` const title = `${message} - Koito`
return ( return (
<AppProvider> <AppProvider>
<ThemeProvider theme={theme}> <ThemeProvider>
<title>{title}</title> <title>{title}</title>
<div className="flex"> <div className="flex">
<Sidebar /> <Sidebar />

@ -32,4 +32,5 @@ Once the relay is configured, Koito will automatically forward any requests it r
:::note :::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`. 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`.
::: :::

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

@ -35,6 +35,7 @@ func bindRoutes(
Get("/images/{size}/{filename}", handlers.ImageHandler(db)) Get("/images/{size}/{filename}", handlers.ImageHandler(db))
r.Route("/apis/web/v1", func(r chi.Router) { r.Route("/apis/web/v1", func(r chi.Router) {
r.Get("/config", handlers.GetCfgHandler())
r.Get("/artist", handlers.GetArtistHandler(db)) r.Get("/artist", handlers.GetArtistHandler(db))
r.Get("/artists", handlers.GetArtistsForItemHandler(db)) r.Get("/artists", handlers.GetArtistsForItemHandler(db))
r.Get("/album", handlers.GetAlbumHandler(db)) r.Get("/album", handlers.GetAlbumHandler(db))

@ -31,6 +31,7 @@ const (
CONFIG_DIR_ENV = "KOITO_CONFIG_DIR" CONFIG_DIR_ENV = "KOITO_CONFIG_DIR"
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME" DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD" DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
DEFAULT_THEME_ENV = "KOITO_DEFAULT_THEME"
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER" DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE" DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
@ -60,6 +61,7 @@ type config struct {
lbzRelayToken string lbzRelayToken string
defaultPw string defaultPw string
defaultUsername string defaultUsername string
defaultTheme string
disableDeezer bool disableDeezer bool
disableCAA bool disableCAA bool
disableMusicBrainz bool disableMusicBrainz bool
@ -162,6 +164,8 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV) cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV)
} }
cfg.defaultTheme = getenv(DEFAULT_THEME_ENV)
cfg.configDir = getenv(CONFIG_DIR_ENV) cfg.configDir = getenv(CONFIG_DIR_ENV)
if cfg.configDir == "" { if cfg.configDir == "" {
cfg.configDir = "/etc/koito" cfg.configDir = "/etc/koito"
@ -277,6 +281,12 @@ func DefaultUsername() string {
return globalConfig.defaultUsername return globalConfig.defaultUsername
} }
func DefaultTheme() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.defaultTheme
}
func FullImageCacheEnabled() bool { func FullImageCacheEnabled() bool {
lock.RLock() lock.RLock()
defer lock.RUnlock() defer lock.RUnlock()

Loading…
Cancel
Save