From b1636ea3f5422cb3b6baeb342a6fd0e3150a441c Mon Sep 17 00:00:00 2001 From: overtrue Date: Mon, 16 Mar 2026 23:11:04 +0800 Subject: [PATCH] fix: theme --- .gitignore | 6 +-- app/(auth)/config/page.tsx | 4 +- assets/logo-dark.svg | 8 ++++ components/app-sidebar.tsx | 5 +-- components/auth/heroes/hero-static.tsx | 6 ++- components/auth/login-form.tsx | 4 +- components/theme/image.tsx | 33 +++++++++++++++ components/theme/logo.tsx | 26 ++++++++++++ components/tiers/new-form.tsx | 26 ++++-------- lib/theme/assets.ts | 31 ++++++++++++++ scripts/apply-theme-overrides.mjs | 8 ++++ themes/.active/manifest.json | 21 ---------- themes/example/README.md | 33 --------------- themes/example/assets/logo.svg | 8 ---- .../components/auth/heroes/hero-static.tsx | 42 ------------------- themes/example/locales/en-US.json | 11 ----- themes/example/locales/zh-CN.json | 11 ----- themes/example/manifest.json | 16 ------- .../example/public/backgrounds/scillate.svg | 7 ---- themes/example/public/backgrounds/ttten.svg | 5 --- 20 files changed, 124 insertions(+), 187 deletions(-) create mode 100644 assets/logo-dark.svg create mode 100644 components/theme/image.tsx create mode 100644 components/theme/logo.tsx create mode 100644 lib/theme/assets.ts delete mode 100644 themes/.active/manifest.json delete mode 100644 themes/example/README.md delete mode 100644 themes/example/assets/logo.svg delete mode 100644 themes/example/components/auth/heroes/hero-static.tsx delete mode 100644 themes/example/locales/en-US.json delete mode 100644 themes/example/locales/zh-CN.json delete mode 100644 themes/example/manifest.json delete mode 100644 themes/example/public/backgrounds/scillate.svg delete mode 100644 themes/example/public/backgrounds/ttten.svg diff --git a/.gitignore b/.gitignore index ecc8282..88406be 100644 --- a/.gitignore +++ b/.gitignore @@ -30,14 +30,10 @@ *.pem .theme-runtime -# themes: keep only default and example in repo +# themes: keep only default in repo /themes/* !/themes/default/ !/themes/default/** -!/themes/example/ -!/themes/example/** -!/themes/.active/ -!/themes/.active/** # generated theme locale artifacts /public/themes/ diff --git a/app/(auth)/config/page.tsx b/app/(auth)/config/page.tsx index 402483a..5501e87 100644 --- a/app/(auth)/config/page.tsx +++ b/app/(auth)/config/page.tsx @@ -10,13 +10,13 @@ import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ import { Input } from "@/components/ui/input" import { ThemeSwitcher } from "@/components/theme-switcher" import { LanguageSwitcher } from "@/components/language-switcher" +import { ThemeLogo } from "@/components/theme/logo" import { AuthHeroStatic } from "@/components/auth/heroes/hero-static" import { useMessage } from "@/lib/feedback/message" import { buildRoute, getLoginRoute } from "@/lib/routes" import { configManager } from "@/lib/config" import { checkServerHealth } from "@/lib/config-helpers" import { getThemeManifest } from "@/lib/theme/manifest" -import logoImage from "@/assets/logo.svg" export default function ConfigPage() { return ( @@ -114,7 +114,7 @@ function ConfigPageContent() {
- {theme.brand.name} +

{t("Server Configuration")}

diff --git a/assets/logo-dark.svg b/assets/logo-dark.svg new file mode 100644 index 0000000..5d54270 --- /dev/null +++ b/assets/logo-dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index bac453c..52d037b 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -1,7 +1,6 @@ "use client" import Link from "next/link" -import Image from "next/image" import { usePathname } from "next/navigation" import { useTranslation } from "react-i18next" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" @@ -26,12 +25,12 @@ import { import { RiArrowRightSLine, RiExternalLinkLine } from "@remixicon/react" import { getIconComponent } from "@/lib/icon-map" import navs from "@/config/navs" -import logoImage from "@/assets/logo.svg" import type { NavItem } from "@/types/app-config" import { usePermissions } from "@/hooks/use-permissions" import { SidebarVersion } from "@/components/sidebars/version" import { useDirection } from "@/components/ui/direction" import { getThemeManifest } from "@/lib/theme/manifest" +import { ThemeLogo } from "@/components/theme/logo" const theme = getThemeManifest() const APP_NAME = theme.brand.name @@ -135,7 +134,7 @@ export function AppSidebar() {

) : (
- {APP_NAME} +
)} diff --git a/components/auth/heroes/hero-static.tsx b/components/auth/heroes/hero-static.tsx index 909a5e9..39f87e4 100644 --- a/components/auth/heroes/hero-static.tsx +++ b/components/auth/heroes/hero-static.tsx @@ -3,7 +3,8 @@ import Image from "next/image" import Link from "next/link" import { useTranslation } from "react-i18next" -import logoImage from "@/assets/logo.svg" +import { ThemeLogo } from "@/components/theme/logo" +import { getThemeManifest } from "@/lib/theme/manifest" import { buildRoute } from "@/lib/routes" import { RiArrowRightLongFill } from "@remixicon/react" import { FlipWords } from "@/components/ui/flip-words" @@ -11,11 +12,12 @@ import { FlipWords } from "@/components/ui/flip-words" export function AuthHeroStatic() { const { t, i18n } = useTranslation() const locale = i18n.language?.split("-")[0] ?? "en" + const theme = getThemeManifest() return (
- RustFS +
{t("Rust-based")}
- {theme.brand.name} +
setMethod(v as LoginMethod)} className="flex flex-col gap-4"> diff --git a/components/theme/image.tsx b/components/theme/image.tsx new file mode 100644 index 0000000..ffce129 --- /dev/null +++ b/components/theme/image.tsx @@ -0,0 +1,33 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Image, { type ImageProps } from "next/image" +import { useTheme } from "next-themes" +import { resolveThemeAssetPath, themedPath } from "@/lib/theme/assets" + +type ThemeImageProps = Omit & { + src: string +} + +export function ThemeImage({ src, alt = "", onError, ...props }: ThemeImageProps) { + const { resolvedTheme } = useTheme() + const preferredSrc = useMemo(() => themedPath(src, { dark: resolvedTheme === "dark" }), [resolvedTheme, src]) + const fallbackSrc = useMemo(() => resolveThemeAssetPath(src), [src]) + const [currentSrc, setCurrentSrc] = useState(preferredSrc) + + useEffect(() => { + setCurrentSrc(preferredSrc) + }, [preferredSrc]) + + return ( + {alt} { + setCurrentSrc((current) => (current === fallbackSrc ? current : fallbackSrc)) + onError?.(event) + }} + /> + ) +} diff --git a/components/theme/logo.tsx b/components/theme/logo.tsx new file mode 100644 index 0000000..118afb0 --- /dev/null +++ b/components/theme/logo.tsx @@ -0,0 +1,26 @@ +"use client" + +import Image, { type ImageProps } from "next/image" +import { useTheme } from "next-themes" +import { getThemeManifest } from "@/lib/theme/manifest" +import logoLight from "@/assets/logo.svg" +import logoDark from "@/assets/logo-dark.svg" + +type ThemeLogoProps = Omit & { + alt?: string +} + +export function ThemeLogo({ alt, onError, ...props }: ThemeLogoProps) { + const { resolvedTheme } = useTheme() + const theme = getThemeManifest() + const src = resolvedTheme === "dark" ? logoDark : logoLight + + return ( + {alt + ) +} diff --git a/components/tiers/new-form.tsx b/components/tiers/new-form.tsx index 79b68c9..e8b0371 100644 --- a/components/tiers/new-form.tsx +++ b/components/tiers/new-form.tsx @@ -1,19 +1,17 @@ "use client" import * as React from "react" -import Image from "next/image" import { useTranslation } from "react-i18next" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field" +import { ThemeImage } from "@/components/theme/image" import { useTiers } from "@/hooks/use-tiers" import { useMessage } from "@/lib/feedback/message" -import { buildRoute } from "@/lib/routes" import { cn } from "@/lib/utils" import { getThemeManifest } from "@/lib/theme/manifest" -import logoImage from "@/assets/logo.svg" interface TiersNewFormProps { open: boolean @@ -59,6 +57,10 @@ export function TiersNewForm({ open, onOpenChange, onSuccess }: TiersNewFormProp const selectedOption = TYPE_OPTIONS.find((o) => o.value === type) + const renderTypeIcon = (icon: string) => { + return + } + const resetForm = React.useCallback(() => { setType("") setName("") @@ -154,13 +156,7 @@ export function TiersNewForm({ open, onOpenChange, onSuccess }: TiersNewFormProp className={cn("cursor-pointer border border-border/70 text-left transition hover:border-primary")} >
- + {renderTypeIcon(item.icon)}

{t(item.labelKey)}

{t(item.descKey)}

@@ -177,15 +173,7 @@ export function TiersNewForm({ open, onOpenChange, onSuccess }: TiersNewFormProp className="w-full cursor-pointer border text-left transition hover:border-primary" >
- {selectedOption && ( - - )} + {selectedOption ? renderTypeIcon(selectedOption.icon) : null}

{t("Selected Type")}

{type === "rustfs" ? theme.brand.name : type}

diff --git a/lib/theme/assets.ts b/lib/theme/assets.ts new file mode 100644 index 0000000..0732aac --- /dev/null +++ b/lib/theme/assets.ts @@ -0,0 +1,31 @@ +import { buildRoute } from "@/lib/routes" + +function isAbsoluteUrl(path: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(path) +} + +export function resolveThemeAssetPath(path: string): string { + if (isAbsoluteUrl(path)) return path + return buildRoute(path) +} + +export function withDarkVariant(path: string): string { + if (isAbsoluteUrl(path)) return path + + const [pathname, suffix = ""] = path.split(/(?=[?#])/) + const lastSlashIndex = pathname.lastIndexOf("/") + const fileName = lastSlashIndex >= 0 ? pathname.slice(lastSlashIndex + 1) : pathname + const dirName = lastSlashIndex >= 0 ? pathname.slice(0, lastSlashIndex + 1) : "" + const dotIndex = fileName.lastIndexOf(".") + const baseName = dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName + const ext = dotIndex > 0 ? fileName.slice(dotIndex) : "" + + if (baseName.endsWith("-dark")) return path + + return `${dirName}${baseName}-dark${ext}${suffix}` +} + +export function themedPath(path: string, options?: { dark?: boolean }): string { + const themed = options?.dark ? withDarkVariant(path) : path + return resolveThemeAssetPath(themed) +} diff --git a/scripts/apply-theme-overrides.mjs b/scripts/apply-theme-overrides.mjs index 55b7e3c..1126933 100644 --- a/scripts/apply-theme-overrides.mjs +++ b/scripts/apply-theme-overrides.mjs @@ -158,6 +158,14 @@ for (const mapping of mappings) { copyDirRecursive(path.join(themeRoot, mapping.from), path.join(root, mapping.to)) } +const themeLogoPath = path.join(root, "assets", "logo.svg") +const themeDarkLogoPath = path.join(root, "assets", "logo-dark.svg") +if (fs.existsSync(themeLogoPath) && !fs.existsSync(themeDarkLogoPath)) { + backupBeforeWrite(themeDarkLogoPath) + ensureParentDir(themeDarkLogoPath) + fs.copyFileSync(themeLogoPath, themeDarkLogoPath) +} + const themeLocaleRoot = path.join(themeRoot, "locales") const localeFiles = listLocaleFiles(themeLocaleRoot) const localeIndex = localeFiles.map((file) => file.replace(/\.json$/, "")) diff --git a/themes/.active/manifest.json b/themes/.active/manifest.json deleted file mode 100644 index 9c62c20..0000000 --- a/themes/.active/manifest.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "schemaVersion": 1, - "id": "default", - "name": "Default", - "brand": { - "name": "RustFS", - "shortName": "RustFS", - "description": "RustFS is a distributed file system written in Rust." - }, - "assets": { - "logo": "/themes/default/assets/logo.svg", - "logoCompact": "/themes/default/assets/logo.svg", - "userAvatar": "/img/userAvatar.png" - }, - "links": { - "website": "https://www.rustfs.com", - "documentation": "https://docs.rustfs.com/", - "github": "https://github.com/rustfs/console", - "x": "https://x.com/rustfsofficial" - } -} diff --git a/themes/example/README.md b/themes/example/README.md deleted file mode 100644 index 0ff1298..0000000 --- a/themes/example/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Example Theme Verification - -This example theme validates all customization capabilities: - -1. `manifest`: - - Brand name and links are replaced from `themes/example/manifest.json`. -2. `components` (same-path override): - - `themes/example/components/auth/heroes/hero-static.tsx` - - Overrides `components/auth/heroes/hero-static.tsx`. -3. `assets` (same-path override): - - `themes/example/assets/logo.svg` - - Overrides `assets/logo.svg`. -4. `locales` (key-level merge override): - - `themes/example/locales/en-US.json` - - `themes/example/locales/zh-CN.json` - - Only specified keys are overridden. -5. `public` (same-path override): - - `themes/example/public/backgrounds/scillate.svg` - - `themes/example/public/backgrounds/ttten.svg` - -## Quick Check - -```bash -NEXT_PUBLIC_THEME_ID=example pnpm dev -``` - -Then verify on login/config pages: - -- Logo shows `ACME Console`. -- Hero content is the custom Acme component. -- Background is teal style (from `public/backgrounds/*` override). -- Brand links use `example.com` / `docs.example.com`. -- Texts such as `Login`, `View Documentation` are overridden, while unspecified i18n keys still use default translations. diff --git a/themes/example/assets/logo.svg b/themes/example/assets/logo.svg deleted file mode 100644 index 1d41a84..0000000 --- a/themes/example/assets/logo.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - ACME Console - - diff --git a/themes/example/components/auth/heroes/hero-static.tsx b/themes/example/components/auth/heroes/hero-static.tsx deleted file mode 100644 index 65932fd..0000000 --- a/themes/example/components/auth/heroes/hero-static.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client" - -import Image from "next/image" -import Link from "next/link" -import { useTranslation } from "react-i18next" -import { RiArrowRightLongFill } from "@remixicon/react" -import { FlipWords } from "@/components/ui/flip-words" -import logoImage from "@/assets/logo.svg" -import { buildRoute } from "@/lib/routes" -import { getThemeManifest } from "@/lib/theme/manifest" - -export function AuthHeroStatic() { - const { t } = useTranslation() - const manifest = getThemeManifest() - - return ( -
-
- {manifest.brand.name} -
- {t("Acme theme activated")} - -
{t("Public assets are overridden too")}
-
-
- - {t("Visit website")} - - -
- -
-
- ) -} diff --git a/themes/example/locales/en-US.json b/themes/example/locales/en-US.json deleted file mode 100644 index b00677e..0000000 --- a/themes/example/locales/en-US.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Login": "Sign in to Acme", - "View Documentation": "Open Acme Docs", - "Server Configuration": "Acme Server Settings", - "Visit website": "Visit Acme site", - "Acme theme activated": "Acme theme activated", - "Key-level locale override": "Key-level locale override", - "Component path override": "Component path override", - "Asset override": "Asset override", - "Public assets are overridden too": "Public assets are overridden too" -} diff --git a/themes/example/locales/zh-CN.json b/themes/example/locales/zh-CN.json deleted file mode 100644 index a5167d7..0000000 --- a/themes/example/locales/zh-CN.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Login": "登录到 Acme", - "View Documentation": "查看 Acme 文档", - "Server Configuration": "Acme 服务配置", - "Visit website": "访问 Acme 官网", - "Acme theme activated": "Acme 主题已启用", - "Key-level locale override": "语言包 Key 级覆盖", - "Component path override": "同路径组件覆盖", - "Asset override": "静态资源覆盖", - "Public assets are overridden too": "public 资源也已覆盖" -} diff --git a/themes/example/manifest.json b/themes/example/manifest.json deleted file mode 100644 index 0f64708..0000000 --- a/themes/example/manifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "schemaVersion": 1, - "id": "example", - "name": "Example Customer Theme", - "brand": { - "name": "Acme Storage Console", - "shortName": "Acme", - "description": "Acme branded console theme example." - }, - "links": { - "website": "https://example.com", - "documentation": "https://docs.example.com/console", - "github": "https://github.com/example/console", - "x": "https://x.com/example" - } -} diff --git a/themes/example/public/backgrounds/scillate.svg b/themes/example/public/backgrounds/scillate.svg deleted file mode 100644 index 631ae5e..0000000 --- a/themes/example/public/backgrounds/scillate.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/themes/example/public/backgrounds/ttten.svg b/themes/example/public/backgrounds/ttten.svg deleted file mode 100644 index 8746628..0000000 --- a/themes/example/public/backgrounds/ttten.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - -