mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-23 16:24:19 +00:00
ui changes
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface BlogPost {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BlogPostsResponse {
|
||||
posts: BlogPost[];
|
||||
}
|
||||
|
||||
async function fetchBlogPosts(): Promise<BlogPostsResponse> {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const response = await fetch(`${baseUrl}/public/litellm_blog_posts`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch blog posts: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const useBlogPosts = () => {
|
||||
return useQuery<BlogPostsResponse>({
|
||||
queryKey: ["blogPosts"],
|
||||
queryFn: fetchBlogPosts,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
retry: 1,
|
||||
retryDelay: 0,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { LOCAL_STORAGE_EVENT, getLocalStorageItem } from "@/utils/localStorageUtils";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === "disableBlogPosts") {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const onCustom = (e: Event) => {
|
||||
const { key } = (e as CustomEvent).detail;
|
||||
if (key === "disableBlogPosts") {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(LOCAL_STORAGE_EVENT, onCustom);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(LOCAL_STORAGE_EVENT, onCustom);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return getLocalStorageItem("disableBlogPosts") === "true";
|
||||
}
|
||||
|
||||
export function useDisableBlogPosts() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useUISettings } from "./uiSettings/useUISettings";
|
||||
|
||||
export function useDisableShowBlog(): boolean {
|
||||
const { data } = useUISettings();
|
||||
return (data?.values?.disable_show_blog as boolean) ?? false;
|
||||
}
|
||||
@@ -1,140 +1,230 @@
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
|
||||
import { BlogDropdown } from "./BlogDropdown";
|
||||
import { useDisableShowBlog } from "@/app/(dashboard)/hooks/useDisableShowBlog";
|
||||
|
||||
// Mock hooks
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableShowBlog", () => ({
|
||||
useDisableShowBlog: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getProxyBaseUrl: () => "http://localhost:4000",
|
||||
}));
|
||||
|
||||
const SAMPLE_POSTS = {
|
||||
posts: [
|
||||
{
|
||||
title: "Test Post 1",
|
||||
description: "First test post description.",
|
||||
date: "2026-02-01",
|
||||
url: "https://www.litellm.ai/blog/test-1",
|
||||
},
|
||||
{
|
||||
title: "Test Post 2",
|
||||
description: "Second test post description.",
|
||||
date: "2026-01-15",
|
||||
url: "https://www.litellm.ai/blog/test-2",
|
||||
},
|
||||
],
|
||||
let mockDisableBlogPosts = false;
|
||||
let mockRefetch = vi.fn();
|
||||
let mockUseBlogPostsResult: {
|
||||
data: { posts: { title: string; date: string; description: string; url: string }[] } | null | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
} = {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: mockRefetch,
|
||||
};
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableBlogPosts", () => ({
|
||||
useDisableBlogPosts: () => mockDisableBlogPosts,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/blogPosts/useBlogPosts", () => ({
|
||||
useBlogPosts: () => mockUseBlogPostsResult,
|
||||
}));
|
||||
|
||||
const MOCK_POSTS = [
|
||||
{ title: "Post One", date: "2026-02-01", description: "Description one", url: "https://example.com/1" },
|
||||
{ title: "Post Two", date: "2026-02-02", description: "Description two", url: "https://example.com/2" },
|
||||
{ title: "Post Three", date: "2026-02-03", description: "Description three", url: "https://example.com/3" },
|
||||
{ title: "Post Four", date: "2026-02-04", description: "Description four", url: "https://example.com/4" },
|
||||
{ title: "Post Five", date: "2026-02-05", description: "Description five", url: "https://example.com/5" },
|
||||
{ title: "Post Six", date: "2026-02-06", description: "Description six", url: "https://example.com/6" },
|
||||
];
|
||||
|
||||
async function openDropdown() {
|
||||
const user = userEvent.setup();
|
||||
await user.hover(screen.getByRole("button", { name: /blog/i }));
|
||||
}
|
||||
|
||||
describe("BlogDropdown", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDisableBlogPosts = false;
|
||||
mockRefetch = vi.fn();
|
||||
mockUseBlogPostsResult = {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: mockRefetch,
|
||||
};
|
||||
});
|
||||
|
||||
it("renders the Blog button", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => SAMPLE_POSTS,
|
||||
});
|
||||
|
||||
render(<BlogDropdown />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText("Blog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows posts on success", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => SAMPLE_POSTS,
|
||||
});
|
||||
|
||||
render(<BlogDropdown />, { wrapper: createWrapper() });
|
||||
|
||||
// Open the dropdown
|
||||
fireEvent.click(screen.getByText("Blog"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Post 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Post 2")).toBeInTheDocument();
|
||||
describe("when blog posts are disabled", () => {
|
||||
it("renders nothing", () => {
|
||||
mockDisableBlogPosts = true;
|
||||
const { container } = renderWithProviders(<BlogDropdown />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows at most 5 posts", async () => {
|
||||
const manyPosts = Array.from({ length: 8 }, (_, i) => ({
|
||||
title: `Post ${i + 1}`,
|
||||
description: `Description ${i + 1}`,
|
||||
date: "2026-02-01",
|
||||
url: `https://www.litellm.ai/blog/post-${i + 1}`,
|
||||
}));
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ posts: manyPosts }),
|
||||
describe("when blog posts are enabled", () => {
|
||||
it("renders the Blog trigger button", () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
render(<BlogDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByText("Blog"));
|
||||
describe("loading state", () => {
|
||||
it("shows a loading spinner", async () => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true };
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Post 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Post 5")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Post 6")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
await openDropdown();
|
||||
|
||||
it("shows error message and Retry button on fetch failure", async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<BlogDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByText("Blog"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load blog posts/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls refetch when Retry is clicked", async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_POSTS });
|
||||
|
||||
render(<BlogDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByText("Blog"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".anticon-loading")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||
describe("error state", () => {
|
||||
beforeEach(() => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isError: true };
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Post 1")).toBeInTheDocument();
|
||||
it("shows an error message", async () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Failed to load posts")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a Retry button", async () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls refetch when Retry is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await user.hover(screen.getByRole("button", { name: /blog/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /retry/i }));
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when useDisableShowBlog is true", () => {
|
||||
vi.mocked(useDisableShowBlog).mockReturnValue(true);
|
||||
describe("empty state", () => {
|
||||
it("shows 'No posts available' when data is null", async () => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: null };
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
const { container } = render(<BlogDropdown />, { wrapper: createWrapper() });
|
||||
expect(container.firstChild).toBeNull();
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No posts available")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'No posts available' when posts array is empty", async () => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: [] } };
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No posts available")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("with posts", () => {
|
||||
beforeEach(() => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 3) } };
|
||||
});
|
||||
|
||||
it("renders post titles", async () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Post One")).toBeInTheDocument();
|
||||
expect(screen.getByText("Post Two")).toBeInTheDocument();
|
||||
expect(screen.getByText("Post Three")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders post descriptions", async () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Description one")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders post links with correct attributes", async () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
const link = screen.getByRole("link", { name: /post one/i });
|
||||
expect(link).toHaveAttribute("href", "https://example.com/1");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders formatted post dates", async () => {
|
||||
mockUseBlogPostsResult = {
|
||||
...mockUseBlogPostsResult,
|
||||
data: { posts: [{ title: "Date Post", date: "2026-02-15", description: "Desc", url: "https://example.com" }] },
|
||||
};
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Feb 15, 2026")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the 'View all posts' link", async () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
const viewAllLink = screen.getByRole("link", { name: /view all posts/i });
|
||||
expect(viewAllLink).toHaveAttribute("href", "https://docs.litellm.ai/blog");
|
||||
expect(viewAllLink).toHaveAttribute("target", "_blank");
|
||||
expect(viewAllLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("post limit", () => {
|
||||
it("renders at most 5 posts when more than 5 are provided", async () => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS } };
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Post One")).toBeInTheDocument();
|
||||
expect(screen.getByText("Post Five")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Post Six")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,11 @@
|
||||
import { useDisableShowBlog } from "@/app/(dashboard)/hooks/useDisableShowBlog";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import {
|
||||
DownOutlined,
|
||||
LoadingOutlined,
|
||||
ReadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { Button, Dropdown, Space, Typography } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import React from "react";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface BlogPost {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface BlogPostsResponse {
|
||||
posts: BlogPost[];
|
||||
}
|
||||
|
||||
async function fetchBlogPosts(): Promise<BlogPostsResponse> {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const response = await fetch(`${baseUrl}/public/litellm_blog_posts`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch blog posts: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
@@ -41,106 +17,66 @@ function formatDate(dateStr: string): string {
|
||||
}
|
||||
|
||||
export const BlogDropdown: React.FC = () => {
|
||||
const disableShowBlog = useDisableShowBlog();
|
||||
const disableBlogPosts = useDisableBlogPosts();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery<BlogPostsResponse>({
|
||||
queryKey: ["blogPosts"],
|
||||
queryFn: fetchBlogPosts,
|
||||
staleTime: 60 * 60 * 1000, // 1 hour — matches server-side TTL
|
||||
retry: 1,
|
||||
retryDelay: 0,
|
||||
enabled: !disableShowBlog,
|
||||
});
|
||||
const { data, isLoading, isError, refetch } = useBlogPosts();
|
||||
|
||||
if (disableShowBlog) {
|
||||
if (disableBlogPosts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dropdownContent = () => {
|
||||
if (isError) {
|
||||
return (
|
||||
<div style={{ padding: "12px 16px", minWidth: 200 }}>
|
||||
<Text type="danger" style={{ display: "block", marginBottom: 8 }}>
|
||||
Failed to load blog posts
|
||||
</Text>
|
||||
<Button size="small" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
let items: MenuProps["items"];
|
||||
|
||||
if (!data || data.posts.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: "12px 16px" }}>
|
||||
<Text type="secondary">No posts available</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: 280, maxWidth: 360 }}>
|
||||
{data.posts.slice(0, 5).map((post) => (
|
||||
<a
|
||||
key={post.url}
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: "block", textDecoration: "none", color: "inherit" }}
|
||||
>
|
||||
<div
|
||||
style={{ padding: "10px 16px" }}
|
||||
className="hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
fontSize: 13,
|
||||
marginBottom: 2,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{post.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#8c8c8c", marginBottom: 3 }}>
|
||||
{formatDate(post.date)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#595959",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{post.description}
|
||||
</div>
|
||||
</div>
|
||||
if (isLoading) {
|
||||
items = [{ key: "loading", label: <LoadingOutlined />, disabled: true }];
|
||||
} else if (isError) {
|
||||
items = [
|
||||
{
|
||||
key: "error",
|
||||
label: (
|
||||
<Space>
|
||||
<Text type="danger">Failed to load posts</Text>
|
||||
<Button size="small" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
} else if (!data || data.posts.length === 0) {
|
||||
items = [{ key: "empty", label: <Text type="secondary">No posts available</Text>, disabled: true }];
|
||||
} else {
|
||||
items = [
|
||||
...data.posts.slice(0, 5).map((post: BlogPost) => ({
|
||||
key: post.url,
|
||||
label: (
|
||||
<a href={post.url} target="_blank" rel="noopener noreferrer" style={{ display: "block", width: 380 }}>
|
||||
<Title level={5} style={{ marginBottom: 2 }}>
|
||||
{post.title}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{formatDate(post.date)}
|
||||
</Text>
|
||||
<Paragraph ellipsis={{ rows: 2 }}>{post.description}</Paragraph>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
),
|
||||
})),
|
||||
{ type: "divider" as const },
|
||||
{
|
||||
key: "view-all",
|
||||
label: (
|
||||
<a href="https://docs.litellm.ai/blog" target="_blank" rel="noopener noreferrer">
|
||||
View all posts
|
||||
</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
popupRender={dropdownContent}
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
icon={<ReadOutlined />}
|
||||
>
|
||||
<Space>
|
||||
Blog
|
||||
{isLoading ? <LoadingOutlined /> : <DownOutlined />}
|
||||
</Space>
|
||||
</Button>
|
||||
<Dropdown menu={{ items }} trigger={["hover"]} placement="bottomRight">
|
||||
<Button type="text">Blog</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator";
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
||||
const { userId, userEmail, userRole, premiumUser } = useAuthorized();
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
const disableUsageIndicator = useDisableUsageIndicator();
|
||||
const disableBlogPosts = useDisableBlogPosts();
|
||||
const [disableShowNewBadge, setDisableShowNewBadge] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,6 +150,23 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
||||
aria-label="Toggle hide usage indicator"
|
||||
/>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide Blog Posts</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={disableBlogPosts}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
setLocalStorageItem("disableBlogPosts", "true");
|
||||
emitLocalStorageChange("disableBlogPosts");
|
||||
} else {
|
||||
removeLocalStorageItem("disableBlogPosts");
|
||||
emitLocalStorageChange("disableBlogPosts");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide blog posts"
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
);
|
||||
|
||||
|
||||
@@ -3,13 +3,8 @@ import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { clearTokenCookies } from "@/utils/cookieUtils";
|
||||
import { fetchProxySettings } from "@/utils/proxyUtils";
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MoonOutlined,
|
||||
SunOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Switch, Tag } from "antd";
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, SunOutlined } from "@ant-design/icons";
|
||||
import { Button, Switch, Tag } from "antd";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
|
||||
@@ -43,7 +38,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
||||
sidebarCollapsed = false,
|
||||
onToggleSidebar,
|
||||
isDarkMode,
|
||||
toggleDarkMode
|
||||
toggleDarkMode,
|
||||
}) => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const [logoutUrl, setLogoutUrl] = useState("");
|
||||
@@ -111,7 +106,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
||||
style={{ animationDuration: "2s" }}
|
||||
title="Thanks for using LiteLLM!"
|
||||
>
|
||||
❄️
|
||||
🌑
|
||||
</span>
|
||||
<Tag className="relative text-xs font-medium cursor-pointer z-10">
|
||||
<a
|
||||
@@ -132,26 +127,21 @@ const Navbar: React.FC<NavbarProps> = ({
|
||||
<CommunityEngagementButtons />
|
||||
{/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below.
|
||||
Do not set this to true by default until all components are confirmed to support dark mode styles. */}
|
||||
{false && <Switch
|
||||
data-testid="dark-mode-toggle"
|
||||
checked={isDarkMode}
|
||||
onChange={toggleDarkMode}
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
/>}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
{false && (
|
||||
<Switch
|
||||
data-testid="dark-mode-toggle"
|
||||
checked={isDarkMode}
|
||||
onChange={toggleDarkMode}
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
/>
|
||||
)}
|
||||
<Button type="text" href="https://docs.litellm.ai/docs/" target="_blank" rel="noopener noreferrer">
|
||||
Docs
|
||||
</a>
|
||||
</Button>
|
||||
<BlogDropdown />
|
||||
|
||||
{!isPublicPage && (
|
||||
<UserDropdown onLogout={handleLogout} />
|
||||
)}
|
||||
{!isPublicPage && <UserDropdown onLogout={handleLogout} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user