feat(settings): add appearance tab and whitelabeling features

- Add dedicated 'appearance' tab to settings navigation
- Move AppearanceTab from general to dedicated tab
- Add whitelabeling fields to Zod schema (customLogoUrl, customLogoBase64)
- Add whitelabeling UI section with:
  - App name customization
  - Custom logo URL input
  - Logo file upload with preview
  - Reset to default functionality
- Add i18n keys for whitelabeling features

This allows users to customize the application name and logo for white-labeling purposes.
This commit is contained in:
oyi77
2026-04-01 03:41:47 +07:00
parent 70a4d38d04
commit ac10d25f5f
4 changed files with 118 additions and 8 deletions

View File

@@ -282,6 +282,109 @@ export default function AppearanceTab() {
/>
</div>
</div>
<div className="pt-4 border-t border-border">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
badge
</span>
</div>
<div>
<h4 className="font-semibold">{t("whitelabeling")}</h4>
<p className="text-sm text-text-muted">{t("whitelabelingDesc")}</p>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{t("appName")}</p>
<p className="text-sm text-text-muted">{t("appNameDesc")}</p>
</div>
<input
type="text"
value={settings.instanceName || "OmniRoute"}
onChange={(e) => updateSetting("instanceName", e.target.value)}
placeholder="OmniRoute"
maxLength={100}
className="h-10 px-3 rounded-lg bg-surface border border-border text-sm text-text-main focus:outline-none focus:border-primary w-48"
/>
</div>
<div className="flex flex-col gap-2">
<div>
<p className="font-medium">{t("customLogo")}</p>
<p className="text-sm text-text-muted">{t("customLogoDesc")}</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={settings.customLogoUrl || ""}
onChange={(e) => updateSetting("customLogoUrl", e.target.value)}
className="flex-1 h-10 px-3 rounded-lg bg-surface border border-border text-sm text-text-main focus:outline-none focus:border-primary"
placeholder="https://example.com/logo.png"
maxLength={2000}
/>
{(settings.customLogoUrl || settings.customLogoBase64) && (
<img
src={settings.customLogoBase64 || settings.customLogoUrl}
alt="Logo preview"
className="h-10 w-10 rounded border border-border object-contain bg-surface"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<p className="font-medium">{t("uploadLogo")}</p>
<div className="flex items-center gap-2">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 500 * 1024) {
alert("Logo file must be less than 500KB");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target?.result as string;
updateSetting("customLogoBase64", base64);
};
reader.readAsDataURL(file);
}
}}
className="text-sm text-text-muted"
/>
<Button
variant="secondary"
onClick={() => {
updateSetting("customLogoUrl", "");
updateSetting("customLogoBase64", "");
}}
>
{t("resetLogo")}
</Button>
</div>
{(settings.customLogoBase64 || settings.customLogoUrl) && (
<div className="mt-2 p-3 bg-black/5 dark:bg-white/5 rounded-lg">
<p className="text-xs text-text-muted mb-2">{t("logoPreview")}</p>
<img
src={settings.customLogoBase64 || settings.customLogoUrl}
alt="Logo preview"
className="h-12 w-auto max-w-full rounded"
/>
</div>
)}
</div>
</div>
</div>
</div>
</Card>
);

View File

@@ -23,6 +23,7 @@ import ResilienceTab from "./components/ResilienceTab";
const tabs = [
{ id: "general", labelKey: "general", icon: "settings" },
{ id: "appearance", labelKey: "appearance", icon: "palette" },
{ id: "ai", labelKey: "ai", icon: "smart_toy" },
{ id: "security", labelKey: "security", icon: "shield" },
{ id: "routing", labelKey: "routing", icon: "route" },
@@ -75,14 +76,9 @@ export default function SettingsPage() {
role="tabpanel"
aria-label={t(tabs.find((t2) => t2.id === activeTab)?.labelKey || "general")}
>
{activeTab === "general" && (
<>
<div className="flex flex-col gap-6">
<SystemStorageTab />
<AppearanceTab />
</div>
</>
)}
{activeTab === "general" && <SystemStorageTab />}
{activeTab === "appearance" && <AppearanceTab />}
{activeTab === "ai" && (
<div className="flex flex-col gap-6">

View File

@@ -1789,6 +1789,15 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"whitelabeling": "Branding",
"whitelabelingDesc": "Customize the application name and logo",
"appName": "Application Name",
"appNameDesc": "Display name shown in sidebar and browser tab",
"customLogo": "Custom Logo URL",
"customLogoDesc": "URL to your custom logo image",
"uploadLogo": "Upload Logo",
"resetLogo": "Reset to Default",
"logoPreview": "Preview",
"promptCache": "Prompt Cache",
"flushCache": "Flush Cache",
"flushing": "Flushing…",

View File

@@ -16,6 +16,8 @@ export const updateSettingsSchema = z.object({
requireLogin: z.boolean().optional(),
enableSocks5Proxy: z.boolean().optional(),
instanceName: z.string().max(100).optional(),
customLogoUrl: z.string().max(2000).optional(),
customLogoBase64: z.string().max(100000).optional(),
corsOrigins: z.string().max(500).optional(),
cloudUrl: z.string().max(500).optional(),
baseUrl: z.string().max(500).optional(),