Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -783,10 +783,11 @@ curl -X POST http://localhost:20128/api/db-backups/import \

### Settings Dashboard

The settings page is organized into 5 tabs for easy navigation:
The settings page is organized into 6 tabs for easy navigation:

| Tab | Contents |
| -------------- | ---------------------------------------------------------------------------------------------- |
| **General** | System storage tools, appearance settings, theme controls, and per-item sidebar visibility |
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, and Provider Blocking |
| **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Provider profiles, editable rate limits, circuit breaker status, policies & locked identifiers |
Expand Down
110 changes: 105 additions & 5 deletions src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,35 @@ import { useTheme } from "@/shared/hooks/useTheme";
import useThemeStore, { COLOR_THEMES } from "@/store/themeStore";
import { cn } from "@/shared/utils/cn";
import { useTranslations } from "next-intl";
import {
HIDDEN_SIDEBAR_ITEMS_SETTING_KEY,
SIDEBAR_SECTIONS,
SIDEBAR_SETTINGS_UPDATED_EVENT,
normalizeHiddenSidebarItems,
type HideableSidebarItemId,
} from "@/shared/constants/sidebarVisibility";

export default function AppearanceTab() {
const { theme, setTheme, isDark } = useTheme();
const { colorTheme, customColor, setColorTheme, setCustomColorTheme } = useThemeStore();
const t = useTranslations("settings");
const tSidebar = useTranslations("sidebar");
const [settings, setSettings] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
const [customThemeColor, setCustomThemeColor] = useState(customColor || "#3b82f6");
const isValidHex = /^#([0-9a-fA-F]{6})$/.test(customThemeColor.startsWith("#") ? customThemeColor : `#${customThemeColor}`);
const isValidHex = /^#([0-9a-fA-F]{6})$/.test(
customThemeColor.startsWith("#") ? customThemeColor : `#${customThemeColor}`
);
const hiddenSidebarItems = normalizeHiddenSidebarItems(
settings[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]
);
const hiddenSidebarSet = new Set(hiddenSidebarItems);

const getSettingsLabel = (key: string, fallback: string) =>
typeof t.has === "function" && t.has(key) ? t(key) : fallback;
const getSidebarLabel = (key: string, fallback: string) =>
typeof tSidebar.has === "function" && tSidebar.has(key) ? tSidebar(key) : fallback;
Comment on lines +33 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The getSettingsLabel and getSidebarLabel helper functions are re-created on every render. This can lead to unnecessary re-renders of child components if these functions are passed as props. It's a good practice to memoize them using useCallback to improve performance.

Remember to import useCallback from react.

  const getSettingsLabel = useCallback((key: string, fallback: string) =>
    typeof t.has === "function" && t.has(key) ? t(key) : fallback,
  [t]);
  const getSidebarLabel = useCallback((key: string, fallback: string) =>
    typeof tSidebar.has === "function" && tSidebar.has(key) ? tSidebar(key) : fallback,
  [tSidebar]);


// Subscribe to store changes (e.g. from another tab) via Zustand external subscription
useEffect(() => {
const unsubscribe = useThemeStore.subscribe((state) => {
if (state.customColor && state.customColor !== customThemeColor) {
Expand All @@ -25,6 +43,7 @@ export default function AppearanceTab() {
});
return unsubscribe;
}, []); // eslint-disable-line react-hooks/exhaustive-deps

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The useEffect hook at line 38 has an empty dependency array, which can lead to a stale closure over the customThemeColor state variable. This can cause incorrect behavior when the theme color is updated from another source. To fix this, you should add customThemeColor to the dependency array to ensure the subscription callback always has the latest value.

Suggested change
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, [customThemeColor]);


const themeOptionLabels: Record<string, string> = {
light: t("themeLight"),
dark: t("themeDark"),
Expand All @@ -40,7 +59,12 @@ export default function AppearanceTab() {
return res.json();
})
.then((data) => {
setSettings(data);
setSettings({
...data,
[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]: normalizeHiddenSidebarItems(
data[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]
),
});
setLoading(false);
})
.catch(() => setLoading(false));
Expand All @@ -54,7 +78,18 @@ export default function AppearanceTab() {
body: JSON.stringify({ [key]: value }),
});
if (res.ok) {
setSettings((prev) => ({ ...prev, [key]: value }));
setSettings((prev) => ({
...prev,
[key]:
key === HIDDEN_SIDEBAR_ITEMS_SETTING_KEY ? normalizeHiddenSidebarItems(value) : value,
}));
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(SIDEBAR_SETTINGS_UPDATED_EVENT, {
detail: { [key]: value },
})
);
}
}
} catch (err) {
console.error(`Failed to update ${key}:`, err);
Expand All @@ -71,6 +106,20 @@ export default function AppearanceTab() {
{ id: "cyan", color: COLOR_THEMES.cyan, label: t("themeCyan") },
];

const sidebarSections = SIDEBAR_SECTIONS.map((section) => ({
...section,
title: getSidebarLabel(section.titleKey, section.titleFallback),
items: section.items.map((item) => ({ ...item, label: tSidebar(item.i18nKey) })),
}));

const toggleSidebarItem = (itemId: HideableSidebarItemId) => {
const nextHiddenItems = hiddenSidebarSet.has(itemId)
? hiddenSidebarItems.filter((id) => id !== itemId)
: [...hiddenSidebarItems, itemId];

updateSetting(HIDDEN_SIDEBAR_ITEMS_SETTING_KEY, nextHiddenItems);
};

return (
<Card>
<div className="flex items-center gap-3 mb-4">
Expand Down Expand Up @@ -164,10 +213,61 @@ export default function AppearanceTab() {
maxLength={7}
className={`flex-1 h-10 px-3 rounded-lg bg-surface border text-sm text-text-main focus:outline-none ${isValidHex ? "border-border focus:border-primary" : "border-red-400 focus:border-red-500"}`}
/>
<Button onClick={() => setCustomColorTheme(customThemeColor)} disabled={!isValidHex}>{t("themeCreate")}</Button>
<Button onClick={() => setCustomColorTheme(customThemeColor)} disabled={!isValidHex}>
{t("themeCreate")}
</Button>
</div>
</div>

<div className="pt-4 border-t border-border">
<div className="mb-3">
<p className="font-medium">
{getSettingsLabel("sidebarVisibility", "Hide sidebar items")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"sidebarVisibilityDesc",
"Hide any sidebar navigation entry to reduce visual clutter without disabling any features"
)}
</p>
</div>

<div className="flex flex-col gap-4">
{sidebarSections.map((section) => (
<div key={section.id} className="rounded-lg border border-border bg-surface/40">
<div className="px-4 py-3 border-b border-border/70">
<p className="text-xs font-semibold uppercase tracking-wider text-text-muted/70">
{section.title}
</p>
</div>

<div className="divide-y divide-border/70">
{section.items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<p className="font-medium">{item.label}</p>
<Toggle
checked={hiddenSidebarSet.has(item.id)}
onChange={() => toggleSidebarItem(item.id)}
disabled={loading}
/>
</div>
))}
</div>
</div>
))}
</div>

<p className="mt-3 text-xs text-text-muted">
{getSettingsLabel(
"sidebarVisibilityHint",
"Any sidebar section is hidden automatically when all of its entries are hidden"
)}
</p>
</div>

<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between">
<div>
Expand Down
1 change: 1 addition & 0 deletions src/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
* @property {string} [instanceName] - Instance display name
* @property {string} [corsOrigins] - Allowed CORS origins
* @property {number} [logRetentionDays] - Log retention in days
* @property {string[]} [hiddenSidebarItems] - Sidebar entry ids hidden for visual decluttering
*/

/**
Expand Down
8 changes: 8 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@
"debug": "Debug",
"system": "System",
"help": "Help",
"primarySection": "Main",
"cliSection": "CLI",
"debugSection": "Debug",
"systemSection": "System",
"helpSection": "Help",
"serverDisconnected": "Server Disconnected",
"serverDisconnectedMsg": "The proxy server has been stopped or is restarting.",
"expandSidebar": "Expand sidebar",
Expand Down Expand Up @@ -1724,6 +1729,9 @@
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"sidebarVisibility": "Hide sidebar items",
"sidebarVisibilityDesc": "Hide any sidebar navigation entry to reduce visual clutter without disabling any features",
"sidebarVisibilityHint": "Any sidebar section is hidden automatically when all of its entries are hidden",
Comment on lines +1732 to +1734

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New i18n keys were added here (and under sidebar) but not to the other locale bundles in src/i18n/messages/*.json (e.g., ar/pt-BR have no primarySection/sidebarVisibility* keys). If the repo expects per-locale message files to stay key-synchronized, please add these keys to all locales (even if using English placeholders) to avoid missing-string fallbacks and future runtime errors where t() is used without has checks.

Suggested change
"sidebarVisibility": "Hide sidebar items",
"sidebarVisibilityDesc": "Hide any sidebar navigation entry to reduce visual clutter without disabling any features",
"sidebarVisibilityHint": "Any sidebar section is hidden automatically when all of its entries are hidden",

Copilot uses AI. Check for mistakes.
"hideHealthLogs": "Hide Health Check Logs",
"hideHealthLogsDesc": "When ON, suppress [HealthCheck] messages in server console",
"themeAccent": "Theme color",
Expand Down
8 changes: 8 additions & 0 deletions src/i18n/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@
"debug": "调试",
"system": "系统",
"help": "帮助",
"primarySection": "主导航",
"cliSection": "CLI",
"debugSection": "调试",
"systemSection": "系统",
"helpSection": "帮助",
"serverDisconnected": "服务器已断开连接",
"serverDisconnectedMsg": "代理服务器已停止,或正在重启。",
"expandSidebar": "展开侧边栏",
Expand Down Expand Up @@ -1718,6 +1723,9 @@
"themeLight": "浅色",
"themeDark": "深色",
"themeSystem": "系统",
"sidebarVisibility": "隐藏侧边栏项目",
"sidebarVisibilityDesc": "可以隐藏任意侧边栏导航项,以减少视觉负担,但不会禁用任何功能",
"sidebarVisibilityHint": "当某个侧边栏分组中的所有项目都被隐藏时,该分组会自动隐藏",
"hideHealthLogs": "隐藏健康检查日志",
"hideHealthLogsDesc": "开启后,将抑制服务器控制台中的 [HealthCheck] 消息",
"themeAccent": "主题颜色",
Expand Down
1 change: 1 addition & 0 deletions src/lib/db/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export async function getSettings() {
cloudEnabled: false,
stickyRoundRobinLimit: 3,
requireLogin: true,
hiddenSidebarItems: [],
};
for (const row of rows) {
const record = toRecord(row);
Expand Down
Loading