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>
dev
Gabe Farrell 3 weeks ago committed by Gabe Farrell
parent 621ca63b6b
commit 68d922ce55

@ -157,6 +157,10 @@ function submitListen(id: string, ts: Date): 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( return fetch(`/apis/web/v1/user/apikeys`).then(
(r) => r.json() as Promise<ApiKey[]> (r) => r.json() as Promise<ApiKey[]>
@ -277,6 +281,7 @@ function getNowPlaying(): Promise<NowPlaying> {
} }
export { export {
<<<<<<< HEAD
getLastListens, getLastListens,
getTopTracks, getTopTracks,
getTopAlbums, getTopAlbums,
@ -308,6 +313,37 @@ export {
submitListen, submitListen,
getNowPlaying, getNowPlaying,
}; };
=======
getLastListens,
getTopTracks,
getTopAlbums,
getTopArtists,
getActivity,
getStats,
search,
replaceImage,
mergeTracks,
mergeAlbums,
mergeArtists,
imageUrl,
login,
logout,
getCfg,
deleteItem,
updateUser,
getAliases,
createAlias,
deleteAlias,
setPrimaryAlias,
getApiKeys,
createApiKey,
deleteApiKey,
updateApiKeyLabel,
deleteListen,
getAlbum,
getExport,
}
>>>>>>> 5d0491a (feat: add server-side configuration with default theme (#90))
type Track = { type Track = {
id: number; id: number;
title: string; title: string;
@ -390,6 +426,7 @@ type ApiKey = {
created_at: Date; created_at: Date;
}; };
type ApiError = { type ApiError = {
<<<<<<< HEAD
error: string; error: string;
}; };
type Config = { type Config = {
@ -417,3 +454,27 @@ export type {
Config, Config,
NowPlaying, NowPlaying,
}; };
=======
error: string
}
type Config = {
default_theme: string
}
export type {
getItemsArgs,
getActivityArgs,
Track,
Artist,
Album,
Listen,
SearchResponse,
PaginatedResponse,
ListenActivityItem,
User,
Alias,
ApiKey,
ApiError,
Config
}
>>>>>>> 5d0491a (feat: add server-side configuration with default theme (#90))

@ -1,69 +1,78 @@
import { useState } from 'react'; import { 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() {
<<<<<<< HEAD const { setTheme } = useTheme();
const { setTheme } = useTheme(); const initialTheme = {
======= bg: "#1e1816",
const { theme, themeName, setTheme } = useTheme(); bgSecondary: "#2f2623",
>>>>>>> fbaa6a6 (Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name) bgTertiary: "#453733",
const initialTheme = { fg: "#f8f3ec",
bg: "#1e1816", fgSecondary: "#d6ccc2",
bgSecondary: "#2f2623", fgTertiary: "#b4a89c",
bgTertiary: "#453733", primary: "#f5a97f",
fg: "#f8f3ec", primaryDim: "#d88b65",
fgSecondary: "#d6ccc2", accent: "#f9db6d",
fgTertiary: "#b4a89c", accentDim: "#d9bc55",
primary: "#f5a97f", error: "#e26c6a",
primaryDim: "#d88b65", warning: "#f5b851",
accent: "#f9db6d", success: "#8fc48f",
accentDim: "#d9bc55", info: "#87b8dd",
error: "#e26c6a", };
warning: "#f5b851",
success: "#8fc48f",
info: "#87b8dd",
}
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme()
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
const handleCustomTheme = () => {
console.log(custom)
try {
const themeData = JSON.parse(custom)
setCustomTheme(themeData)
setCustom(JSON.stringify(themeData, null, " "))
console.log(themeData)
} catch(err) {
console.log(err)
}
}
return ( const { setCustomTheme, getCustomTheme, resetTheme } = useTheme();
<div className='flex flex-col gap-10'> const [custom, setCustom] = useState(
<div> JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")
<div className='flex items-center gap-3'> );
<h2>Select Theme</h2>
<div className='mb-3'> const handleCustomTheme = () => {
<AsyncButton onClick={resetTheme}>Reset</AsyncButton> console.log(custom);
</div> try {
</div> const themeData = JSON.parse(custom);
<div className="grid grid-cols-2 items-center gap-2"> setCustomTheme(themeData);
{Object.entries(themes).map(([name, themeData]) => ( setCustom(JSON.stringify(themeData, null, " "));
<ThemeOption setTheme={setTheme} key={name} theme={themeData} themeName={name} /> console.log(themeData);
))} } catch (err) {
</div> console.log(err);
</div> }
<div> };
<h2>Use Custom Theme</h2>
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg"> return (
<textarea name="custom-theme" onChange={(e) => setCustom(e.target.value)} id="custom-theme-input" className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md" value={custom} /> <div className="flex flex-col gap-10">
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton> <div>
</div> <div className="flex items-center gap-3">
</div> <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">
{Object.entries(themes).map(([name, themeData]) => (
<ThemeOption
setTheme={setTheme}
key={name}
theme={themeData}
themeName={name}
/>
))}
</div>
</div>
<div>
<h2>Use Custom Theme</h2>
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
<textarea
name="custom-theme"
onChange={(e) => setCustom(e.target.value)}
id="custom-theme-input"
className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md"
value={custom}
/>
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
</div> </div>
); </div>
</div>
);
} }

@ -23,16 +23,19 @@ 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 [defaultTheme, setDefaultTheme] = useState<string | undefined>(
const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false); undefined
);
const [configurableHomeActivity, setConfigurableHomeActivity] =
useState<boolean>(false);
const [homeItems, setHomeItems] = useState<number>(0); const [homeItems, setHomeItems] = useState<number>(0);
const setUsername = (value: string) => { const setUsername = (value: string) => {
if (!user) { if (!user) {
return return;
} }
setUser({...user, username: value}) setUser({ ...user, username: value });
} };
useEffect(() => { useEffect(() => {
fetch("/apis/web/v1/user/me") fetch("/apis/web/v1/user/me")
@ -45,14 +48,14 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setConfigurableHomeActivity(true); setConfigurableHomeActivity(true);
setHomeItems(12); setHomeItems(12);
getCfg().then(cfg => { getCfg().then((cfg) => {
console.log(cfg) console.log(cfg);
if (cfg.default_theme !== '') { if (cfg.default_theme !== "") {
setDefaultTheme(cfg.default_theme) setDefaultTheme(cfg.default_theme);
} else { } else {
setDefaultTheme('yuu') setDefaultTheme("yuu");
} }
}) });
}, []); }, []);
// Block rendering the app until config is loaded // Block rendering the app until config is loaded
@ -70,5 +73,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setUsername, setUsername,
}; };
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>; return (
}; <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>
);
};

@ -1,158 +1,131 @@
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react'; import {
import { type Theme, themes } from '~/styles/themes.css'; createContext,
import { themeVars } from '~/styles/vars.css'; useEffect,
import { useAppContext } from './AppProvider'; 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 { interface ThemeContextValue {
themeName: string; themeName: string;
theme: Theme; theme: Theme;
setTheme: (theme: string) => void; setTheme: (theme: string) => void;
resetTheme: () => void; resetTheme: () => void;
setCustomTheme: (theme: Theme) => void; setCustomTheme: (theme: Theme) => void;
getCustomTheme: () => Theme | undefined; getCustomTheme: () => Theme | undefined;
} }
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined); const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function toKebabCase(str: string) { function toKebabCase(str: string) {
return str.replace(/[A-Z]/g, m => '-' + m.toLowerCase()); return str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
} }
function applyCustomThemeVars(theme: Theme) { function applyCustomThemeVars(theme: Theme) {
const root = document.documentElement; const root = document.documentElement;
for (const [key, value] of Object.entries(theme)) { for (const [key, value] of Object.entries(theme)) {
if (key === 'name') continue; if (key === "name") continue;
root.style.setProperty(`--color-${toKebabCase(key)}`, value); root.style.setProperty(`--color-${toKebabCase(key)}`, value);
} }
} }
function clearCustomThemeVars() { function clearCustomThemeVars() {
for (const cssVar of Object.values(themeVars)) { for (const cssVar of Object.values(themeVars)) {
document.documentElement.style.removeProperty(cssVar); document.documentElement.style.removeProperty(cssVar);
} }
} }
function getStoredCustomTheme(): Theme | undefined { function getStoredCustomTheme(): Theme | undefined {
const themeStr = localStorage.getItem('custom-theme'); const themeStr = localStorage.getItem("custom-theme");
if (!themeStr) return undefined; if (!themeStr) return undefined;
try { try {
const parsed = JSON.parse(themeStr); const parsed = JSON.parse(themeStr);
const { name, ...theme } = parsed; const { name, ...theme } = parsed;
return theme as Theme; return theme as Theme;
} catch { } catch {
return undefined; return undefined;
} }
} }
export function ThemeProvider({ export function ThemeProvider({ children }: { children: ReactNode }) {
children, let defaultTheme = useAppContext().defaultTheme;
}: { let initialTheme = localStorage.getItem("theme") ?? defaultTheme;
children: ReactNode; const [themeName, setThemeName] = useState(initialTheme);
}) { const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
<<<<<<< HEAD if (initialTheme === "custom") {
let defaultTheme = useAppContext().defaultTheme const customTheme = getStoredCustomTheme();
let initialTheme = localStorage.getItem("theme") ?? defaultTheme return customTheme || themes[defaultTheme];
=======
>>>>>>> fbaa6a6 (Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name)
const [themeName, setThemeName] = useState(initialTheme);
const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
if (initialTheme === 'custom') {
const customTheme = getStoredCustomTheme();
<<<<<<< HEAD
return customTheme || themes[defaultTheme];
}
return themes[initialTheme] || themes[defaultTheme];
=======
return customTheme || themes.yuu;
}
return themes[initialTheme] || themes.yuu;
>>>>>>> fbaa6a6 (Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name)
});
const setTheme = (newThemeName: string) => {
setThemeName(newThemeName);
if (newThemeName === 'custom') {
const customTheme = getStoredCustomTheme();
if (customTheme) {
setCurrentTheme(customTheme);
} else {
// Fallback to default theme if no custom theme found
<<<<<<< HEAD
setThemeName(defaultTheme);
setCurrentTheme(themes[defaultTheme]);
=======
setThemeName('yuu');
setCurrentTheme(themes.yuu);
>>>>>>> fbaa6a6 (Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name)
}
} else {
const foundTheme = themes[newThemeName];
if (foundTheme) {
<<<<<<< HEAD
localStorage.setItem('theme', newThemeName)
setCurrentTheme(foundTheme);
}
}
} }
return themes[initialTheme] || themes[defaultTheme];
const resetTheme = () => { });
setThemeName(defaultTheme)
localStorage.removeItem('theme') const setTheme = (newThemeName: string) => {
setCurrentTheme(themes[defaultTheme]) setThemeName(newThemeName);
======= if (newThemeName === "custom") {
setCurrentTheme(foundTheme); const customTheme = getStoredCustomTheme();
} if (customTheme) {
} setCurrentTheme(customTheme);
>>>>>>> fbaa6a6 (Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name) } else {
// Fallback to default theme if no custom theme found
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);
}, []);
const getCustomTheme = (): Theme | undefined => {
return getStoredCustomTheme();
};
useEffect(() => {
const root = document.documentElement;
const setCustomTheme = useCallback((customTheme: Theme) => { root.setAttribute("data-theme", themeName);
localStorage.setItem('custom-theme', JSON.stringify(customTheme));
applyCustomThemeVars(customTheme);
setThemeName('custom');
<<<<<<< HEAD
localStorage.setItem('theme', 'custom')
=======
>>>>>>> fbaa6a6 (Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name)
setCurrentTheme(customTheme);
}, []);
const getCustomTheme = (): Theme | undefined => { if (themeName === "custom") {
return getStoredCustomTheme(); applyCustomThemeVars(currentTheme);
} else {
clearCustomThemeVars();
} }
}, [themeName, currentTheme]);
useEffect(() => {
const root = document.documentElement; return (
<ThemeContext.Provider
root.setAttribute('data-theme', themeName); value={{
<<<<<<< HEAD themeName,
======= theme: currentTheme,
localStorage.setItem('theme', themeName); setTheme,
>>>>>>> fbaa6a6 (Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name) resetTheme,
setCustomTheme,
if (themeName === 'custom') { getCustomTheme,
applyCustomThemeVars(currentTheme); }}
} else { >
clearCustomThemeVars(); {children}
} </ThemeContext.Provider>
}, [themeName, currentTheme]); );
return (
<<<<<<< HEAD
<ThemeContext.Provider value={{
themeName,
theme: currentTheme,
setTheme,
resetTheme,
setCustomTheme,
getCustomTheme
}}>
=======
<ThemeContext.Provider value={{ themeName, theme: currentTheme, setTheme, setCustomTheme, getCustomTheme }}>
>>>>>>> fbaa6a6 (Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name)
{children}
</ThemeContext.Provider>
);
} }
export { ThemeContext }; export { ThemeContext };

Loading…
Cancel
Save