fix: theme

This commit is contained in:
overtrue
2026-03-16 23:11:04 +08:00
parent d993a9c799
commit b1636ea3f5
20 changed files with 124 additions and 187 deletions
+1 -5
View File
@@ -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/
+2 -2
View File
@@ -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() {
</div>
<div className="flex w-full flex-col items-center justify-center bg-white dark:border-neutral-700 dark:bg-neutral-900 lg:w-1/2">
<div className="max-w-sm w-full space-y-6 p-4 sm:p-7">
<Image src={logoImage} alt={theme.brand.name} width={112} height={24} className="max-w-28" />
<ThemeLogo width={112} height={24} className="max-w-28" />
<div className="py-6">
<h1 className="block text-2xl font-bold text-gray-800 dark:text-white">{t("Server Configuration")}</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-neutral-400">
+8
View File
@@ -0,0 +1,8 @@
<svg width="1558" height="260" viewBox="0 0 1558 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1288.5 112.905H1159.75V58.4404H1262L1270 0L1074 0V260H1159.75V162.997H1296.95L1288.5 112.905Z" fill="#0196D0"/>
<path d="M1058.62 58.4404V0H789V58.4404H881.133V260H966.885V58.4404H1058.62Z" fill="#0196D0"/>
<path d="M521 179.102V0L454.973 15V161C454.973 181.124 452.084 193.146 443.5 202C434.916 211.257 419.318 214.5 400.5 214.5C381.022 214.5 366.744 210.854 357.5 202C348.916 193.548 346.357 175.721 346.357 156V0L280 15V175.48C280 208.08 290.234 229.412 309.712 241.486C329.19 253.56 358.903 260 400.5 260C440.447 260 470.159 253.56 490.297 241.486C510.766 229.412 521 208.483 521 179.102Z" fill="#0196D0"/>
<path d="M172.84 84.2813C172.84 97.7982 168.249 107.737 158.41 113.303C149.883 118.471 137.092 121.254 120.693 122.049V162.997C129.876 163.792 138.076 166.177 144.307 176.514L184.647 260H265L225.316 180.489C213.181 155.046 201.374 149.48 178.744 143.517C212.197 138.349 241.386 118.471 241.386 73.1499C241.386 53.2722 233.843 30.2141 218.756 17.8899C203.998 5.56575 183.991 0 159.394 0H120.693V48.5015H127.58C142.23 48.5015 153.6 51.4169 161.689 57.2477C169.233 62.8135 172.84 71.5596 172.84 84.2813ZM120.693 122.049C119.163 122.049 117.741 122.049 116.43 122.049H68.5457V48.5015H120.693V0H0V260H70.5137V162.997H110.526C113.806 162.997 117.741 162.997 120.693 162.997V122.049Z" fill="#0196D0"/>
<path d="M774 179.297C774 160.829 766.671 144.669 752.013 131.972C738.127 119.66 712.025 110.169 673.708 103.5C662.136 101.191 651.722 99.6523 643.235 97.3437C586.532 84.6467 594.632 52.7118 650.564 52.7118C680.651 52.7118 709.582 61.946 738.127 66.9478C742.37 67.7174 743.913 68.1021 744.298 68.1021L750.47 12.697C720.383 3.46282 684.895 0 654.036 0C616.619 0 587.689 6.54088 567.245 19.2379C546.801 31.9349 536 57.7137 536 82.3382C536 103.5 543.715 119.66 559.916 131.972C575.731 143.515 604.276 152.749 645.55 160.059C658.279 162.368 668.694 163.907 676.794 166.215C685.023 168.524 691.066 170.704 694.924 172.756C702.253 176.604 706.11 182.375 706.11 188.531C706.11 196.611 701.481 202.767 692.224 207C664.836 220.081 587.689 212.001 556.83 198.15L543.715 247.784C547.186 248.169 552.972 249.323 559.916 250.477C616.619 259.327 690.681 270.869 741.212 238.935C762.814 225.468 774 206.23 774 179.297Z" fill="#0196D0"/>
<path d="M1558 179.568C1558 160.383 1550.42 144.268 1535.67 131.99C1521.32 119.968 1494.34 110.631 1454.74 103.981C1442.38 101.679 1432.01 99.3764 1422.84 97.8416C1422.44 97.8416 1422.04 97.8416 1422.04 97.4579V112.422L1361.04 75.2038L1422.04 38.3692V52.9496C1424.7 52.9496 1427.49 52.9496 1430.41 52.9496C1461.51 52.9496 1491.42 62.5419 1521.32 67.5299C1525.31 67.9136 1526.9 67.9136 1527.3 67.9136L1533.68 12.6619C1502.98 3.83692 1465.9 0 1434 0C1395.33 0 1365.43 6.52277 1345.09 19.5683C1323.16 32.6139 1312 57.9376 1312 82.8776C1312 103.981 1320.37 120.096 1336.72 131.607C1353.46 143.885 1382.97 153.093 1425.23 160.383C1434 161.535 1441.18 162.686 1447.56 164.22L1448.36 150.791L1507.36 190.312L1445.57 224.844L1445.96 212.949C1409.68 215.635 1357.45 209.112 1333.53 197.985L1320.37 247.482C1323.56 248.249 1329.54 248.633 1336.72 250.551C1395.33 259.376 1471.88 270.887 1524.11 238.657C1546.84 225.611 1558 205.659 1558 179.568Z" fill="#0196D0"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+2 -3
View File
@@ -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() {
</div>
) : (
<div className="flex min-w-0 items-baseline gap-2 px-3 py-4">
<Image src={logoImage} alt={APP_NAME} width={64} height={16} className="h-4 w-auto shrink-0" />
<ThemeLogo width={64} height={16} className="h-4 w-auto shrink-0" alt={APP_NAME} />
</div>
)}
</Link>
+4 -2
View File
@@ -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 (
<div className="relative flex h-full w-full flex-col justify-center gap-8 overflow-hidden border-e bg-gray-50 p-16 dark:border-none dark:bg-black">
<div className="z-10 flex max-w-7xl flex-col">
<Image src={logoImage} alt="RustFS" width={112} height={24} className="max-w-28" />
<ThemeLogo width={112} height={24} className="max-w-28" alt={theme.brand.name} />
<div className="my-6 px-0 text-4xl font-semibold text-primary">
<span className={locale !== "zh" ? "pe-1" : undefined}>{t("Rust-based")} </span>
<FlipWords
+2 -2
View File
@@ -10,10 +10,10 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
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 { buildRoute } from "@/lib/routes"
import { getThemeManifest } from "@/lib/theme/manifest"
import logoImage from "@/assets/logo.svg"
import type { OidcProvider } from "@/types/config"
@@ -80,7 +80,7 @@ export function LoginForm({
</div>
<div className="max-w-sm w-full space-y-6 p-4 sm:p-7">
<Image src={logoImage} alt={theme.brand.name} width={112} height={24} className="max-w-28" />
<ThemeLogo width={112} height={24} className="max-w-28" />
<div className="space-y-4">
<Tabs value={method} onValueChange={(v) => setMethod(v as LoginMethod)} className="flex flex-col gap-4">
+33
View File
@@ -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<ImageProps, "src"> & {
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 (
<Image
{...props}
src={currentSrc}
alt={alt}
onError={(event) => {
setCurrentSrc((current) => (current === fallbackSrc ? current : fallbackSrc))
onError?.(event)
}}
/>
)
}
+26
View File
@@ -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<ImageProps, "src" | "alt"> & {
alt?: string
}
export function ThemeLogo({ alt, onError, ...props }: ThemeLogoProps) {
const { resolvedTheme } = useTheme()
const theme = getThemeManifest()
const src = resolvedTheme === "dark" ? logoDark : logoLight
return (
<Image
{...props}
src={src}
alt={alt ?? theme.brand.name}
onError={onError}
/>
)
}
+7 -19
View File
@@ -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 <ThemeImage src={icon} alt="" width={40} height={40} className="size-10 shrink-0 object-contain" />
}
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")}
>
<div className="flex items-center gap-3 p-4">
<Image
src={item.value === "rustfs" ? logoImage : buildRoute(item.icon)}
alt=""
width={40}
height={40}
className="size-10 shrink-0 object-contain"
/>
{renderTypeIcon(item.icon)}
<div>
<p className="text-base font-semibold">{t(item.labelKey)}</p>
<p className="text-sm text-muted-foreground">{t(item.descKey)}</p>
@@ -177,15 +173,7 @@ export function TiersNewForm({ open, onOpenChange, onSuccess }: TiersNewFormProp
className="w-full cursor-pointer border text-left transition hover:border-primary"
>
<div className="flex items-center gap-3 p-4">
{selectedOption && (
<Image
src={selectedOption.value === "rustfs" ? logoImage : buildRoute(selectedOption.icon)}
alt=""
width={40}
height={40}
className="size-10 shrink-0 object-contain"
/>
)}
{selectedOption ? renderTypeIcon(selectedOption.icon) : null}
<div>
<p className="text-sm text-muted-foreground">{t("Selected Type")}</p>
<p className="text-base font-semibold">{type === "rustfs" ? theme.brand.name : type}</p>
+31
View File
@@ -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)
}
+8
View File
@@ -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$/, ""))
-21
View File
@@ -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"
}
}
-33
View File
@@ -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.
-8
View File
@@ -1,8 +0,0 @@
<svg width="260" height="64" viewBox="0 0 260 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="260" height="64" rx="10" fill="#0F766E"/>
<circle cx="32" cy="32" r="14" fill="#99F6E4"/>
<path d="M24 32L30 38L40 26" stroke="#115E59" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<text x="58" y="39" fill="#ECFEFF" font-size="22" font-family="Arial, Helvetica, sans-serif" font-weight="700">
ACME Console
</text>
</svg>

Before

Width:  |  Height:  |  Size: 455 B

@@ -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 (
<div className="relative flex h-full w-full flex-col justify-center gap-8 overflow-hidden border-e bg-teal-50 p-16 dark:border-none dark:bg-teal-950/20">
<div className="z-10 flex max-w-7xl flex-col">
<Image src={logoImage} alt={manifest.brand.name} width={156} height={38} className="h-9 w-auto" />
<div className="my-6 px-0 text-4xl font-semibold text-teal-700 dark:text-teal-300">
<span>{t("Acme theme activated")}</span>
<FlipWords
words={[t("Key-level locale override"), t("Component path override"), t("Asset override")]}
duration={2600}
className="ms-2 text-4xl font-semibold text-teal-700 dark:text-teal-300"
/>
<div className="mt-2 text-teal-900/70 dark:text-teal-100/70">{t("Public assets are overridden too")}</div>
</div>
</div>
<Link
href={manifest.links.website ?? "https://example.com"}
className="z-10 inline-flex w-max items-center gap-2 rounded-full border border-teal-600 bg-white/80 p-2 px-5 leading-none text-teal-700 dark:bg-teal-900/40 dark:text-teal-200"
>
<span>{t("Visit website")}</span>
<RiArrowRightLongFill className="me-2 rtl:-scale-x-100" />
</Link>
<div className="absolute inset-0 z-0 h-full">
<Image src={buildRoute("/backgrounds/ttten.svg")} alt="" fill className="object-cover opacity-45" />
</div>
</div>
)
}
-11
View File
@@ -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"
}
-11
View File
@@ -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 资源也已覆盖"
}
-16
View File
@@ -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"
}
}
@@ -1,7 +0,0 @@
<svg width="1600" height="900" viewBox="0 0 1600 900" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1600" height="900" fill="#042F2E"/>
<circle cx="220" cy="160" r="220" fill="#0F766E" fill-opacity="0.55"/>
<circle cx="1420" cy="120" r="260" fill="#14B8A6" fill-opacity="0.3"/>
<circle cx="1360" cy="760" r="320" fill="#0D9488" fill-opacity="0.35"/>
<circle cx="260" cy="760" r="260" fill="#2DD4BF" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 453 B

@@ -1,5 +0,0 @@
<svg width="1600" height="900" viewBox="0 0 1600 900" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1600" height="900" fill="#0F172A"/>
<path d="M0 680C220 580 380 760 560 700C740 640 860 420 1080 470C1300 520 1420 760 1600 700V900H0V680Z" fill="#0F766E" fill-opacity="0.6"/>
<path d="M0 760C220 700 350 820 530 790C710 760 860 590 1080 620C1300 650 1450 820 1600 790V900H0V760Z" fill="#14B8A6" fill-opacity="0.45"/>
</svg>

Before

Width:  |  Height:  |  Size: 445 B