fix(ui): honor dark and local logos across Connect and Chat (#48532)

This commit is contained in:
tin-berri 2026-10-26 23:00:03 -04:00 • committed by GitHub
parent ebe0871a28
commit 53a4cee0d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 267 additions and 67 deletions

View file

@ -234,6 +234,9 @@ class DefaultTeamSettingsResponse(SettingsResponse):
class UIThemeSettingsResponse(SettingsResponse):
"""Response model for UI theme settings"""
logo_url_via_proxy: bool = False
logo_url_dark_via_proxy: bool = False
_TEAM_ADMIN_FIELD_ENUM: Final = tuple(sorted(SUPPORTED_TEAM_ADMIN_PERMISSIONS))
@ -1377,7 +1380,7 @@ async def update_sso_settings(
tags=["UI Theme Settings"],
response_model=UIThemeSettingsResponse,
)
async def get_ui_theme_settings():
async def get_ui_theme_settings() -> UIThemeSettingsResponse:
"""
Get UI theme configuration from the litellm_settings.
Returns current logo settings for UI customization.
@ -1390,18 +1393,23 @@ async def get_ui_theme_settings():
# Load existing config
config: Final = await proxy_config.get_config()
result: Final = await _get_settings_with_schema(
settings_key="ui_theme_config",
settings_class=UIThemeConfig,
config=config,
result: Final = _SettingsWithSchema.model_validate(
await _get_settings_with_schema(
settings_key="ui_theme_config",
settings_class=UIThemeConfig,
config=config,
)
)
stored_values: Final = result.get("values", {})
result["values"] = {
**stored_values,
**{field: _resolve_ui_theme_field(stored_values, field) for field in _UI_THEME_FIELD_ENV_VARS},
values: Final = {
**result.values,
**{field: _resolve_ui_theme_field(result.values, field) for field in _UI_THEME_FIELD_ENV_VARS},
}
return result
return UIThemeSettingsResponse(
values=values,
field_schema=result.field_schema,
logo_url_via_proxy=values["logo_url"] is None and bool(os.getenv("UI_LOGO_PATH", "").strip()),
logo_url_dark_via_proxy=values["logo_url_dark"] is None and bool(os.getenv("UI_LOGO_PATH_DARK", "").strip()),
)
def _validate_public_image_url(value: str | None, field_name: str) -> None:

View file

@ -1359,35 +1359,45 @@ class TestProxySettingEndpoints:
assert values["logo_url"] == "https://cdn.example.com/logo.png"
assert values["favicon_url"] == "https://cdn.example.com/favicon.ico"
@pytest.mark.parametrize("env_logo", [
"https://env.example.com/logo.png", "/mnt/secret/internal/logo.png",
])
def test_get_ui_theme_settings_stored_value_wins_over_env(
self, mock_auth, monkeypatch
):
self, monkeypatch: pytest.MonkeyPatch, env_logo: str,
) -> None:
"""A stored ui_theme_config field outranks the env var for that field.
The env fallback only fills fields the stored config leaves blank, so the
UI-driven flow is unchanged while an unstored field still resolves.
"""
from litellm.proxy.proxy_server import proxy_config
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import UIThemeSettingsResponse
stored_config = {
stored_config: Final[dict[str, object]] = {
"litellm_settings": {
"ui_theme_config": {"logo_url": "https://db.example.com/logo.png"}
"ui_theme_config": {
"logo_url": "https://db.example.com/logo.png",
"logo_url_dark": "https://db.example.com/logo-dark.png",
}
}
}
async def mock_get_config():
async def mock_get_config() -> dict[str, object]:
return stored_config
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
monkeypatch.setenv("UI_LOGO_PATH", "https://env.example.com/logo.png")
monkeypatch.setenv("UI_LOGO_PATH", env_logo)
monkeypatch.setenv("UI_LOGO_PATH_DARK", env_logo)
monkeypatch.setenv("LITELLM_FAVICON_URL", "https://env.example.com/favicon.ico")
response = client.get("/get/ui_theme_settings")
response: Final = client.get("/get/ui_theme_settings")
assert response.status_code == 200
values = response.json()["values"]
assert values["logo_url"] == "https://db.example.com/logo.png"
assert values["favicon_url"] == "https://env.example.com/favicon.ico"
result: Final = UIThemeSettingsResponse.model_validate(response.json())
assert result.values["logo_url"] == "https://db.example.com/logo.png"
assert result.values["logo_url_dark"] == "https://db.example.com/logo-dark.png"
assert result.values["favicon_url"] == "https://env.example.com/favicon.ico"
assert (result.logo_url_via_proxy, result.logo_url_dark_via_proxy) == (False, False)
def test_get_ui_theme_settings_reports_unset_when_absent_everywhere(
self, mock_proxy_config, monkeypatch
@ -1403,22 +1413,45 @@ class TestProxySettingEndpoints:
assert values["logo_url"] is None
assert values["favicon_url"] is None
def test_get_ui_theme_settings_does_not_disclose_local_path_env_value(
self, mock_proxy_config, monkeypatch
):
"""This endpoint is public, so an env-configured local filesystem branding
path must never be surfaced to anonymous callers; only public http(s) URLs.
"""
monkeypatch.setenv("UI_LOGO_PATH", "/mnt/secret/internal/logo.png")
@pytest.mark.usefixtures("mock_proxy_config")
@pytest.mark.parametrize("light,public_light,light_via_proxy", [
(None, None, False),
(" ", None, False),
("https://cdn.example.com/light.png", "https://cdn.example.com/light.png", False),
("/mnt/secret/internal/light.png", None, True),
])
@pytest.mark.parametrize("dark,public_dark,dark_via_proxy", [
(None, None, False),
(" ", None, False),
("https://cdn.example.com/dark.png", "https://cdn.example.com/dark.png", False),
("/mnt/secret/internal/dark.png", None, True),
])
def test_get_ui_theme_settings_separates_private_logo_rendering_from_settings(
self, monkeypatch: pytest.MonkeyPatch,
light: str | None, public_light: str | None, light_via_proxy: bool,
dark: str | None, public_dark: str | None, dark_via_proxy: bool,
) -> None:
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import UIThemeSettingsResponse
for key, value in (("UI_LOGO_PATH", light), ("UI_LOGO_PATH_DARK", dark)):
if value is None:
monkeypatch.delenv(key, raising=False)
else:
monkeypatch.setenv(key, value)
monkeypatch.setenv("LITELLM_FAVICON_URL", "file:///etc/favicon.ico")
response = client.get("/get/ui_theme_settings")
response: Final = client.get("/get/ui_theme_settings")
assert response.status_code == 200
values = response.json()["values"]
# the local path / file scheme is withheld rather than disclosed
assert values["logo_url"] is None
assert values["favicon_url"] is None
result: Final = UIThemeSettingsResponse.model_validate(response.json())
assert (result.values["logo_url"], result.values["logo_url_dark"]) == (public_light, public_dark)
assert (result.logo_url_via_proxy, result.logo_url_dark_via_proxy) == (light_via_proxy, dark_via_proxy)
assert result.values["favicon_url"] is None
assert "/mnt/secret/internal/" not in response.text
assert "file:///etc/favicon.ico" not in response.text
for hint in ("logo_url_via_proxy", "logo_url_dark_via_proxy"):
assert hint not in result.values
assert hint not in json.dumps(result.field_schema)
def test_get_ui_settings(self, mock_auth, monkeypatch):
"""Test retrieving UI settings with allowlist sanitization"""

View file

@ -10,7 +10,8 @@ const setLogoUrl = vi.fn();
const setLogoUrlDark = vi.fn();
const setFaviconUrl = vi.fn();
vi.mock("@/contexts/ThemeContext", () => ({
vi.mock("@/contexts/ThemeContext", async (importOriginal) => ({
...(await importOriginal<typeof import("@/contexts/ThemeContext")>()),
useTheme: () => ({
logoUrl: null,
setLogoUrl,
@ -22,7 +23,7 @@ vi.mock("@/contexts/ThemeContext", () => ({
}));
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "",
getProxyBaseUrl: () => "/gateway",
getGlobalLitellmHeaderName: () => "Authorization",
}));
@ -111,6 +112,34 @@ describe("UIThemeSettings", () => {
expect(bodyOf(patchCalls()[0]).logo_url_dark).toBe("https://a.test/logo-dark.png");
});
it.each(["Save Changes", "Reset to Default"])(
"should keep local logo routes out of settings and clear preview overrides with %s",
async (action) => {
const user = userEvent.setup();
fetchMock.mockImplementation(async () =>
Response.json({
values: { logo_url: null, logo_url_dark: null },
logo_url_via_proxy: true,
logo_url_dark_via_proxy: true,
}),
);
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
await waitFor(() => expect(setLogoUrlDark).toHaveBeenCalledWith("/gateway/get_image?theme=dark"));
expect(setLogoUrl).toHaveBeenCalledWith("/gateway/get_image");
expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("");
expect(screen.getByPlaceholderText(LOGO_DARK_PLACEHOLDER)).toHaveValue("");
await user.click(screen.getByRole("button", { name: action }));
await waitFor(() => expect(patchCalls()).toHaveLength(1));
expect(bodyOf(patchCalls()[0])).toEqual({ logo_url: null, logo_url_dark: null, favicon_url: null });
expect(setLogoUrl).toHaveBeenLastCalledWith(null);
expect(setLogoUrlDark).toHaveBeenLastCalledWith(null);
},
);
it("should surface a backend failure when saving fails", async () => {
const user = userEvent.setup();
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);

View file

@ -4,7 +4,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useTheme } from "@/contexts/ThemeContext";
import { resolveThemeImages, useTheme } from "@/contexts/ThemeContext";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { toast } from "@/lib/toast";
@ -40,12 +40,13 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
});
if (response.ok) {
const data = await response.json();
const images = resolveThemeImages(data, proxyBaseUrl);
setLogoUrlInput(data.values?.logo_url || "");
setLogoUrlDarkInput(data.values?.logo_url_dark || "");
setFaviconUrlInput(data.values?.favicon_url || "");
setLogoUrl(data.values?.logo_url || null);
setLogoUrlDark(data.values?.logo_url_dark || null);
setFaviconUrl(data.values?.favicon_url || null);
setLogoUrl(images.logoUrl);
setLogoUrlDark(images.logoUrlDark);
setFaviconUrl(images.faviconUrl);
}
} catch (error) {
console.error("Error fetching theme settings:", error);

View file

@ -1,7 +1,8 @@
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, testQueryClient } from "@/../tests/test-utils";
import { renderWithProviders, screen, testQueryClient, within } from "@/../tests/test-utils";
import { setServerRootPath } from "@/lib/serverRootPath";
import { getProxyBaseUrl } from "@/components/networking";
import ChatLayout from "./layout";
const { router, route } = vi.hoisted(() => ({
@ -33,9 +34,13 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
const plugin = { name: "observability", display_name: "Observability", url: "https://plugin.example.com" };
const originalLocation = Object.getOwnPropertyDescriptor(window, "location")!;
const assign = vi.fn();
const LIGHT_LOGO = "https://cdn.test/light.svg";
const DARK_LOGO = "https://cdn.test/dark.svg";
let themeResponse: unknown = { values: {} };
describe("Chat layout product navigation", () => {
beforeEach(() => {
themeResponse = { values: {} };
route.pathname = "/ui/chat";
localStorage.clear();
localStorage.setItem("litellm_plugin_mode", plugin.name);
@ -55,7 +60,7 @@ describe("Chat layout product navigation", () => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer chat-layout-test-token");
return Response.json([plugin]);
case "/get/ui_theme_settings":
return Response.json({ values: {} });
return Response.json(themeResponse);
case "/sso/get/ui_settings":
return Response.json({});
default:
@ -75,6 +80,55 @@ describe("Chat layout product navigation", () => {
vi.clearAllMocks();
});
it.each([
["public", [LIGHT_LOGO, DARK_LOGO], [false, false], [LIGHT_LOGO, DARK_LOGO]],
["local", [null, null], [true, true], ["/get_image", "/get_image?theme=dark"]],
["public light and local dark", [LIGHT_LOGO, null], [false, true], [LIGHT_LOGO, "/get_image?theme=dark"]],
["local light and public dark", [null, DARK_LOGO], [true, false], ["/get_image", DARK_LOGO]],
["public light only", [LIGHT_LOGO, null], [false, false], [LIGHT_LOGO, LIGHT_LOGO]],
["public dark only", [null, DARK_LOGO], [false, false], [DARK_LOGO, DARK_LOGO]],
["local light only", [null, null], [true, false], ["/get_image", "/get_image"]],
["local dark only", [null, null], [false, true], ["/get_image?theme=dark", "/get_image?theme=dark"]],
] as const)(
"should render %s organization logos from the theme response",
async (
_label,
[logo_url, logo_url_dark],
[logo_url_via_proxy, logo_url_dark_via_proxy],
[lightSource, darkSource],
) => {
themeResponse = { values: { logo_url, logo_url_dark }, logo_url_via_proxy, logo_url_dark_via_proxy };
const user = userEvent.setup();
renderWithProviders(
<ChatLayout>
<h1>Chat content</h1>
</ChatLayout>,
);
await user.click(screen.getByRole("button", { name: "Open chat navigation" }));
const logo = await screen.findByRole("img", { name: "Organization logo" });
const [light, dark] = within(logo).getAllByAltText("");
const source = (path: string) => (path.startsWith("/") ? `${getProxyBaseUrl()}${path}` : path);
expect(light).toHaveAttribute("src", source(lightSource));
expect(dark).toHaveAttribute("src", source(darkSource));
expect(light).toHaveClass("dark:hidden");
expect(dark).toHaveClass("hidden", "dark:block");
},
);
it("should omit organization branding when no logo is configured", async () => {
const user = userEvent.setup();
renderWithProviders(
<ChatLayout>
<h1>Chat content</h1>
</ChatLayout>,
);
await user.click(screen.getByRole("button", { name: "Open chat navigation" }));
expect(await screen.findByRole("dialog", { name: "LiteLLM Chat" })).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "Organization logo" })).not.toBeInTheDocument();
});
it.each([
{ pathname: "/ui/chat", root: "/", gateway: "/ui/" },
{ pathname: "/gateway/ui/chat/models/", root: "/gateway", gateway: "/gateway/ui/" },

View file

@ -1,9 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders as render } from "@/../tests/test-utils";
import { renderWithProviders } from "@/../tests/test-utils";
import type { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import ChatShell from "./ChatShell";
const render = (ui: ReactElement) => renderWithProviders(<ThemeProvider>{ui}</ThemeProvider>);
const { mockPush, mockUsePathname, mockUseChatShell, mockUseMediaQuery } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockUsePathname: vi.fn(() => "/ui/chat"),
@ -34,7 +38,15 @@ vi.mock("usehooks-ts", () => ({ useMediaQuery: mockUseMediaQuery }));
vi.mock("./ConversationList", () => ({ default: () => <div data-testid="conversation-list" /> }));
describe("ChatShell", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json({ values: {} })),
);
});
afterEach(() => {
vi.unstubAllGlobals();
mockPush.mockClear();
mockUsePathname.mockReturnValue("/ui/chat");
mockUseMediaQuery.mockReturnValue(true);

View file

@ -1,6 +1,7 @@
"use client";
import React, { useState } from "react";
import Image from "next/image";
import { usePathname, useRouter } from "next/navigation";
import {
Plus,
@ -34,6 +35,7 @@ import ThemeToggle from "@/components/ThemeToggle/ThemeToggle";
import { useLogout } from "@/app/(dashboard)/hooks/useLogout";
import { uiHref } from "@/utils/uiHref";
import { useChatShell } from "@/contexts/ChatShellContext";
import { useTheme } from "@/contexts/ThemeContext";
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
import ConversationList from "./ConversationList";
@ -94,6 +96,9 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [searchOpen, setSearchOpen] = useState(false);
if (isDesktop && sidebarOpen) setSidebarOpen(false);
const collapsed = isDesktop && sidebarCollapsed;
const { logoUrl, logoUrlDark } = useTheme();
const lightLogo = logoUrl || logoUrlDark;
const darkLogo = logoUrlDark || logoUrl;
const routes = getChatRoutes();
const isChatsRoute = pathname === routes.chats;
const accountName = userEmail?.split("@")[0] || userId || "Account";
@ -216,6 +221,26 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
hideSearchButton
/>
</div>
{lightLogo && darkLogo && (
<div className="flex h-12 shrink-0 items-center px-1 pb-2" role="img" aria-label="Organization logo">
<Image
src={lightLogo}
alt=""
width={160}
height={32}
unoptimized
className="h-8 max-w-full object-contain object-left dark:hidden"
/>
<Image
src={darkLogo}
alt=""
width={160}
height={32}
unoptimized
className="hidden h-8 max-w-full object-contain object-left dark:block"
/>
</div>
)}
<footer className="moyai-chat-account">
<span className="min-w-0 flex-1 truncate text-[13px]" title={userEmail || userId}>
{accountName}

View file

@ -182,6 +182,8 @@ At 600px and below, a compact header keeps the workspace switcher beside the lab
The landing page reuses Moyai's left-aligned 720px home content, dot/grid backdrop, 46px heading, 132px editor and compact starter rows. `ChatModelPicker` sits at the right of the composer action row, matching Moyai's model selector position. Selecting a model chooses chat or image generation. Preserve LiteLLM's existing model access and request behavior; do not copy Moyai product or backend logic.
Configured organization branding appears above the account row in the expanded sidebar and mobile navigation. Light and dark variants fall back to the available image when only one is configured. Private filesystem images use the proxy image endpoint without exposing their paths. With no configured branding, the logo row is absent. The existing collapsed rail hides it along with the account row.
### ScrollArea
**Known gotcha — cost real debugging time:** a `ScrollArea` won't actually scroll if its container is sized with `max-height` instead of `height`, even though the box _looks_ correctly capped. Radix's `ScrollArea` viewport is `size-full` (`height: 100%`), and CSS percentage-height resolution requires the ancestor chain to have a genuinely _definite_ height — `max-height` on a flex container doesn't count as definite for this purpose, even though the browser renders the box at the capped size. The result: the viewport silently grows to its full content height instead of 100% of the visible box, `scrollHeight === clientHeight` is reported (no overflow, so no scrollbar), and scroll events fall through to whatever's behind the popover/panel instead of scrolling the list.

View file

@ -1,7 +1,7 @@
import userEvent from "@testing-library/user-event";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, waitFor } from "../../tests/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, waitFor, within } from "../../tests/test-utils";
import Navbar from "./navbar";
// Mock the hooks and utilities
@ -90,7 +90,7 @@ vi.mock("./Navbar/CommunityEngagementButtons/CommunityEngagementButtons", () =>
}));
// Create mock functions that can be controlled in tests
let mockUseThemeImpl = () => ({ logoUrl: null as string | null });
let mockUseThemeImpl = (): { logoUrl: string | null; logoUrlDark?: string | null } => ({ logoUrl: null });
let mockUseHealthReadinessDetailsImpl = () => ({ data: null as any });
let mockGetLocalStorageItemImpl = (key: string) => null as string | null;
const mockUseAuthorizedImpl = () => ({
@ -136,6 +136,10 @@ Object.defineProperty(window, "location", {
});
describe("Navbar", () => {
afterEach(() => {
mockUseThemeImpl = () => ({ logoUrl: null });
});
const defaultProps = {
accessToken: "test-token",
isPublicPage: false,
@ -250,17 +254,21 @@ describe("Navbar", () => {
expect(useHealthReadinessDetailsSpy).toHaveBeenCalledWith(null);
});
it("should use custom logo from theme context", () => {
mockUseThemeImpl = () => ({ logoUrl: "https://example.com/custom-logo.png" });
it.each(["https://example.com/dark-logo.png", null])(
"should use the configured dark logo %s with the light logo as fallback",
(logoUrlDark) => {
const logoUrl = "https://example.com/custom-logo.png";
mockUseThemeImpl = () => ({ logoUrl, logoUrlDark });
renderWithProviders(<Navbar {...defaultProps} />);
renderWithProviders(<Navbar {...defaultProps} />);
const logoImg = screen.getByAltText("LiteLLM Brand");
expect(logoImg).toHaveAttribute("src", "https://example.com/custom-logo.png");
// Reset mock
mockUseThemeImpl = () => ({ logoUrl: null });
});
const brand = within(screen.getByRole("link", { name: /litellm brand/i }));
const light = brand.getByAltText("LiteLLM Brand");
const dark = brand.getByAltText("");
expect(light).toHaveAttribute("src", logoUrl);
expect(dark).toHaveAttribute("src", logoUrlDark ?? logoUrl);
},
);
it("should hide user dropdown and notifications on public pages", () => {
const publicPageProps = { ...defaultProps, isPublicPage: true };

View file

@ -39,7 +39,7 @@ const Navbar: React.FC<NavbarProps> = ({
deploymentHealth,
}) => {
const baseUrl = getProxyBaseUrl();
const { logoUrl } = useTheme();
const { logoUrl, logoUrlDark } = useTheme();
const { data: healthData } = useHealthReadinessDetails(accessToken);
const version = healthData?.litellm_version;
const disableBouncingIcon = useDisableBouncingIcon();
@ -48,7 +48,7 @@ const Navbar: React.FC<NavbarProps> = ({
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
const imageUrl = logoUrl || `${baseUrl}/get_image`;
const darkImageUrl = logoUrl || `${baseUrl}/get_image?theme=dark`;
const darkImageUrl = logoUrlDark || logoUrl || `${baseUrl}/get_image?theme=dark`;
const handleLogout = useLogout(accessToken);

View file

@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { getProxyBaseUrl } from "@/components/networking";
import { z } from "zod";
interface ThemeContextType {
logoUrl: string | null;
@ -10,6 +11,29 @@ interface ThemeContextType {
setFaviconUrl: (url: string | null) => void;
}
const themeImagesSchema = z.object({
values: z.object({
logo_url: z.string().nullish(),
logo_url_dark: z.string().nullish(),
favicon_url: z.string().nullish(),
}),
logo_url_via_proxy: z.boolean().optional(),
logo_url_dark_via_proxy: z.boolean().optional(),
});
export function resolveThemeImages(
data: unknown,
proxyBaseUrl: string | null,
): Pick<ThemeContextType, "logoUrl" | "logoUrlDark" | "faviconUrl"> {
const settings = themeImagesSchema.parse(data);
const imageUrl = `${proxyBaseUrl ?? ""}/get_image`;
return {
logoUrl: settings.logo_url_via_proxy ? imageUrl : settings.values.logo_url || null,
logoUrlDark: settings.logo_url_dark_via_proxy ? `${imageUrl}?theme=dark` : settings.values.logo_url_dark || null,
faviconUrl: settings.values.favicon_url || null,
};
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = () => {
@ -41,16 +65,10 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, accessTo
});
if (response.ok) {
const data = await response.json();
if (data.values?.logo_url) {
setLogoUrl(data.values.logo_url);
}
if (data.values?.logo_url_dark) {
setLogoUrlDark(data.values.logo_url_dark);
}
if (data.values?.favicon_url) {
setFaviconUrl(data.values.favicon_url);
}
const images = resolveThemeImages(await response.json(), proxyBaseUrl);
setLogoUrl(images.logoUrl);
setLogoUrlDark(images.logoUrlDark);
setFaviconUrl(images.faviconUrl);
}
} catch (error) {
console.warn("Failed to load theme settings from backend:", error);

View file

@ -48094,6 +48094,16 @@ export interface components {
field_schema: {
[key: string]: unknown;
};
/**
* Logo Url Dark Via Proxy
* @default false
*/
logo_url_dark_via_proxy: boolean;
/**
* Logo Url Via Proxy
* @default false
*/
logo_url_via_proxy: boolean;
/** Values */
values: {
[key: string]: unknown;