feat: implement theme selection and system integration

This commit is contained in:
sagitchu
2026-03-20 22:47:49 +08:00
parent 9f0670f4d0
commit bd4e1f66cb
17 changed files with 1834 additions and 105 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
**Tag:** 2.1.9-beta8
## OVERVIEW
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
+3 -3
View File
@@ -675,9 +675,9 @@ func (NodeMetric) TableName() string { return "node_metric" }
type TunnelMetric struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"`
NodeID int64 `gorm:"column:node_id;not null;index:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"`
Timestamp int64 `gorm:"not null;index:idx_tunnel_metric_tunnel_time,priority:3;index:idx_tunnel_metric_time" json:"timestamp"`
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"`
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"`
Timestamp int64 `gorm:"not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:3;index:idx_tunnel_metric_time" json:"timestamp"`
BytesIn int64 `gorm:"column:bytes_in" json:"bytesIn"`
BytesOut int64 `gorm:"column:bytes_out" json:"bytesOut"`
Connections int64 `gorm:"column:connections" json:"connections"`
+59
View File
@@ -0,0 +1,59 @@
# 059 - 主题系统设计(v2 — 完整可扩展架构)
## 概述
设计一个高度可扩展的主题包架构,允许第三方作者通过代码提交的方式创建主题,覆盖前端所有元素——从 CSS 变量到组件实现、布局结构、甚至整个页面。
## 架构
```
src/themes/
├── types.ts # ThemePackage 接口定义
├── registry.ts # 主题注册表 + CSS 注入引擎
├── context.tsx # React Context + Provider + Hooks
├── index.ts # 公共 API barrel
├── loader.ts # 主题加载器(注册所有内置主题)
├── README.md # 主题开发指南
│
├── default/ # 默认主题(参考实现)
│ └── index.ts
│
├── example-cyberpunk/ # 示例主题(赛博朋克)
│ ├── index.ts
│ └── components/
│ └── button.tsx # 组件覆盖示范
│
└── <your-theme>/ # 第三方主题
├── index.ts
├── components/
├── layouts/
├── pages/
└── assets/
```
## 覆盖层级
| 层级 | 字段 | 说明 |
|------|------|------|
| CSS 变量 | `tokens.light` / `tokens.dark` | 80+ 个设计 token(颜色、字体、圆角) |
| 原始 CSS | `css` | 注入自定义 CSS(动画、字体、阴影等) |
| 组件替换 | `components` | 替换任意 UI 组件(30+ 个可替换组件键) |
| 布局替换 | `layouts` | 替换 4 种布局(Admin / H5 / H5Simple / Default) |
| 页面替换 | `pages` | 替换 14 个页面路由实现 |
| 生命周期 | `onActivate` / `onDeactivate` | 主题启用/停用回调 |
## 任务清单
- [x] **T1**: 创建 `src/themes/types.ts` — ThemePackage 接口 + 所有可覆盖键定义
- [x] **T2**: 创建 `src/themes/registry.ts` — 主题注册/激活/停用/CSS 注入引擎
- [x] **T3**: 创建 `src/themes/context.tsx` — React Context + ThemeProvider + hooks
- [x] **T4**: 创建 `src/themes/index.ts` — 公共 API barrel
- [x] **T5**: 创建 `src/themes/loader.ts` — 自动加载所有内置主题
- [x] **T6**: 创建 `src/themes/default/` — 默认主题参考实现
- [x] **T7**: 创建 `src/themes/example-cyberpunk/` — 完整示例主题(含组件覆盖 + CSS + 生命周期)
- [x] **T8**: 重构 `use-theme.tsx` — 向后兼容包装
- [x] **T9**: 重构 `theme-provider.tsx` — 集成新主题系统
- [x] **T10**: 编写 `README.md` — 主题开发完整指南
- [x] **T11**: TypeScript 编译验证通过
- [ ] **T12**: (后续) 设置页面集成主题选择器 UI
- [ ] **T13**: (后续) 将现有组件导入逐步迁移到 `useThemedComponent` 模式
+14 -43
View File
@@ -1,51 +1,22 @@
import React, { useEffect } from "react";
/**
* ThemeProvider — app-level wrapper
* =================================
* Loads all registered themes and wraps children with the theme context.
* Import the loader to ensure all built-in themes are registered before
* the provider mounts.
*/
import { useTheme } from "@/shadcn-bridge/heroui/use-theme";
import React from "react";
// Side-effect: registers all built-in themes
import "@/themes/loader";
import { ThemeProvider as ThemeContextProvider } from "@/themes/context";
interface ThemeProviderProps {
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { theme, setTheme } = useTheme();
useEffect(() => {
// 确保主题与HTML class同步
const updateThemeClass = (currentTheme: string) => {
if (currentTheme === "dark") {
document.documentElement.classList.add("dark");
document.documentElement.style.colorScheme = "dark";
} else {
document.documentElement.classList.remove("dark");
document.documentElement.style.colorScheme = "light";
}
};
// 始终跟随系统主题
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
if (systemTheme !== theme) {
setTheme(systemTheme);
}
// 监听主题变化
updateThemeClass(theme);
// 监听系统主题变化
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleThemeChange = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? "dark" : "light";
setTheme(newTheme);
};
mediaQuery.addEventListener("change", handleThemeChange);
return () => mediaQuery.removeEventListener("change", handleThemeChange);
}, [theme, setTheme]);
return <>{children}</>;
return <ThemeContextProvider>{children}</ThemeContextProvider>;
};
@@ -0,0 +1,179 @@
/**
* ThemeSettings — theme picker card for the Settings page
* ========================================================
* Shows:
* • Mode toggle (light / dark / system)
* • Grid of registered themes with preview dots
* • "Reset to default" option
*/
import React from "react";
import toast from "react-hot-toast";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { useThemeContext } from "@/themes/context";
import type { ThemeMode } from "@/themes/registry";
// ─── Constants ──────────────────────────────────────────────────────────────
const MODE_OPTIONS: Array<{ value: ThemeMode; label: string; icon: string }> = [
{ value: "light", label: "亮色", icon: "☀️" },
{ value: "dark", label: "暗色", icon: "🌙" },
{ value: "system", label: "跟随系统", icon: "🖥️" },
];
// ─── Component ──────────────────────────────────────────────────────────────
export const ThemeSettings: React.FC = () => {
const {
themes,
activeThemeId,
mode,
effectiveMode,
switchTheme,
resetTheme,
setMode,
} = useThemeContext();
const handleModeChange = (m: ThemeMode) => {
setMode(m);
const label = m === "light" ? "亮色" : m === "dark" ? "暗色" : "跟随系统";
toast.success(`已切换为${label}模式`);
};
const handleThemeSelect = (id: string) => {
switchTheme(id);
const theme = themes.find((t) => t.id === id);
toast.success(`已切换主题「${theme?.name ?? id}」`);
};
const handleReset = () => {
resetTheme();
toast.success("已恢复默认主题");
};
return (
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-5">
主题设置
</h2>
{/* ── Mode toggle ────────────────────────────────────── */}
<div className="mb-6">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
外观模式
</p>
<div className="inline-flex rounded-lg border border-gray-200 dark:border-gray-600 p-1 gap-1">
{MODE_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
mode === opt.value
? "bg-primary text-white shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700/50"
}`}
type="button"
onClick={() => handleModeChange(opt.value)}
>
<span className="mr-1.5">{opt.icon}</span>
{opt.label}
</button>
))}
</div>
</div>
{/* ── Theme grid ─────────────────────────────────────── */}
<div className="mb-4">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
选择主题
<span className="ml-2 text-xs text-gray-400 dark:text-gray-500 font-normal">
共 {themes.length} 个可用主题
</span>
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{themes.map((theme) => {
const isActive = activeThemeId === theme.id;
// Pick the right token set for preview
const previewTokens =
effectiveMode === "dark" && theme.tokens?.dark
? theme.tokens.dark
: theme.tokens?.light;
const primary = previewTokens?.["--primary"] ?? "#2563eb";
const secondary = previewTokens?.["--secondary"] ?? "#6366f1";
const success = previewTokens?.["--success"] ?? "#16a34a";
const danger = previewTokens?.["--danger"] ?? "#dc2626";
const bg = previewTokens?.["--background"] ?? "#ffffff";
return (
<button
key={theme.id}
className={`relative rounded-xl text-left transition-all duration-200 border-2 overflow-hidden ${
isActive
? "border-primary shadow-lg shadow-primary/15 scale-[1.01]"
: "border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 hover:shadow-md"
}`}
type="button"
onClick={() => handleThemeSelect(theme.id)}
>
{/* Colour strip preview */}
<div className="flex h-8">
<div className="flex-1" style={{ background: primary }} />
<div className="flex-1" style={{ background: secondary }} />
<div className="flex-1" style={{ background: success }} />
<div className="flex-1" style={{ background: danger }} />
<div className="flex-1" style={{ background: bg, borderLeft: "1px solid rgba(0,0,0,0.06)" }} />
</div>
{/* Info */}
<div className="p-3">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-gray-900 dark:text-white truncate">
{theme.name}
</p>
{isActive && (
<span className="shrink-0 px-1.5 py-0.5 rounded text-[10px] font-bold bg-primary/15 text-primary">
当前
</span>
)}
</div>
{theme.description && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{theme.description}
</p>
)}
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">
{theme.author} · v{theme.version}
</p>
</div>
{/* Active indicator dot */}
{isActive && (
<span className="absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-primary shadow-sm shadow-primary/50 animate-pulse" />
)}
</button>
);
})}
</div>
</div>
{/* ── Reset ──────────────────────────────────────────── */}
{activeThemeId && activeThemeId !== "default" && (
<div className="pt-2">
<Button
className="text-gray-500 dark:text-gray-400"
size="sm"
variant="light"
onPress={handleReset}
>
↩ 恢复默认主题
</Button>
</div>
)}
</CardBody>
</Card>
);
};
-7
View File
@@ -1407,13 +1407,6 @@ export default function NodePage() {
if (!nodeList || nodeList.length === 0) return [];
const sortedByDb = [...nodeList].sort((a, b) => {
const expiryDiff =
getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -
getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;
if (expiryDiff !== 0) {
return expiryDiff;
}
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
+2
View File
@@ -10,6 +10,7 @@ import { Switch } from "@/shadcn-bridge/heroui/switch";
import { reinitializeBaseURL } from "@/api/network";
import { getConfigByName, updateConfig } from "@/api";
import { BackIcon } from "@/components/icons";
import { ThemeSettings } from "@/components/theme-settings";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
@@ -181,6 +182,7 @@ export const SettingsPage = () => {
{/* 内容区域 */}
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="space-y-6">
<ThemeSettings />
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
@@ -1,62 +1,43 @@
import * as React from "react";
/**
* useTheme — backwards-compatible hook
* =====================================
* Wraps the new theme system's context to provide the same API that the
* rest of the codebase already expects: `{ theme, setTheme }`.
*
* For full theme system access, use `useThemeContext` from "@/themes".
*/
type ThemeMode = "light" | "dark";
import { useSyncExternalStore, useCallback } from "react";
const STORAGE_KEY = "flvx:theme";
import {
subscribe,
getSavedMode,
getEffectiveMode,
saveMode,
reapplyActiveTheme,
type ThemeMode,
} from "@/themes/registry";
function resolveInitialTheme(): ThemeMode {
if (typeof window === "undefined") {
return "light";
}
const fromStorage = window.localStorage.getItem(STORAGE_KEY);
if (fromStorage === "dark" || fromStorage === "light") {
return fromStorage;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
let currentTheme: ThemeMode = resolveInitialTheme();
const listeners = new Set<(theme: ThemeMode) => void>();
function broadcast(theme: ThemeMode) {
currentTheme = theme;
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, theme);
}
listeners.forEach((listener) => {
listener(theme);
// Monotonic counter for snapshot identity
let _rev = 0;
const _sub = (cb: () => void) =>
subscribe(() => {
_rev++;
cb();
});
}
const _snap = () => _rev;
export function useTheme() {
const [theme, setThemeState] = React.useState<ThemeMode>(currentTheme);
useSyncExternalStore(_sub, _snap);
React.useEffect(() => {
const listener = (nextTheme: ThemeMode) => {
setThemeState(nextTheme);
};
const theme = getEffectiveMode();
const mode = getSavedMode();
listeners.add(listener);
return () => {
listeners.delete(listener);
};
const setTheme = useCallback((next: string) => {
if (next !== "dark" && next !== "light" && next !== "system") return;
saveMode(next as ThemeMode);
reapplyActiveTheme();
}, []);
const setTheme = React.useCallback((nextTheme: string) => {
if (nextTheme !== "dark" && nextTheme !== "light") {
return;
}
broadcast(nextTheme);
}, []);
return {
setTheme,
theme,
};
return { theme, mode, setTheme };
}
+382
View File
@@ -0,0 +1,382 @@
# FLVX 主题开发指南
## 概述
FLVX 主题系统允许你完全自定义前端的外观和行为。一个主题可以覆盖:
| 覆盖层级 | 说明 | 难度 |
|----------|------|------|
| **CSS 变量** | 修改颜色、字体、圆角等设计 token | ⭐ 简单 |
| **原始 CSS** | 注入自定义 CSS(动画、字体、阴影等) | ⭐⭐ 中等 |
| **组件替换** | 替换任意 UI 组件(按钮、卡片、输入框等) | ⭐⭐⭐ 高级 |
| **布局替换** | 替换整个页面布局结构 | ⭐⭐⭐ 高级 |
| **页面替换** | 替换整个页面实现 | ⭐⭐⭐⭐ 专家 |
## 快速开始
### 1. 创建主题文件夹
```
src/themes/my-theme/
├── index.ts ← 必须:主题入口,导出 ThemePackage
├── components/ ← 可选:组件覆盖
│ ├── index.ts
│ └── button.tsx
├── layouts/ ← 可选:布局覆盖
│ └── admin.tsx
├── pages/ ← 可选:页面覆盖
│ └── login.tsx
├── assets/ ← 可选:图片、字体等资源
└── styles.css ← 可选:额外样式文件
```
### 2. 编写主题入口 `index.ts`
```typescript
import type { ThemePackage } from "../types";
const myTheme: ThemePackage = {
id: "my-theme", // 唯一标识(kebab-case)
name: "我的主题", // 显示名称
author: "Your Name", // 作者
version: "1.0.0", // 版本号
description: "一个自定义主题",
// CSS 变量覆盖
tokens: {
light: {
"--primary": "#ff6600",
"--primary-foreground": "#ffffff",
"--background": "#fafafa",
},
dark: {
"--primary": "#ff8833",
"--background": "#1a1a2e",
},
},
};
export default myTheme;
```
### 3. 注册主题
打开 `src/themes/loader.ts`,添加两行:
```typescript
import myTheme from "./my-theme";
registerTheme(myTheme);
```
完成!主题已可用。
---
## 详细指南
### CSS 变量覆盖
所有可用的 CSS 变量定义在 `src/themes/types.ts` 的 `ThemeTokens` 接口中。常用的:
```typescript
tokens: {
light: {
// 基础色
"--background": "#ffffff", // 页面背景
"--foreground": "#000000", // 文字颜色
"--border": "#e5e7eb", // 边框颜色
"--content1": "#ffffff", // 卡片背景
// 品牌色
"--primary": "#2563eb", // 主色
"--primary-foreground": "#fff", // 主色上的文字
"--secondary": "#6366f1", // 辅色
// 状态色
"--danger": "#dc2626",
"--success": "#16a34a",
"--warning": "#d97706",
// 每种品牌色都有 50-900 共 10 级色阶
"--primary-50": "#eff6ff", // 最浅
"--primary-500": "#3b82f6", // 中间
"--primary-900": "#1e3a8a", // 最深
// 字体
"--font-sans": '"Inter", sans-serif',
"--font-mono": '"Fira Code", monospace',
// 圆角
"--radius": "0.5rem",
},
dark: {
// 暗色模式下的覆盖...
},
}
```
> **提示**: 你不需要定义所有变量,只定义你想修改的,其余沿用默认值。
### 原始 CSS 注入
`css` 字段可以注入任意 CSS。主题激活时会插入一个 `<style>` 标签,停用时自动移除。
```typescript
const theme: ThemePackage = {
// ...
css: `
/* 自定义字体 */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC&display=swap');
body {
font-family: 'Noto Sans SC', sans-serif;
}
/* 自定义动画 */
@keyframes my-fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* 给所有卡片加阴影 */
.rounded-xl, .rounded-lg {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
/* 自定义滚动条 */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 4px;
}
`,
};
```
### 组件替换
可以替换任意 UI 组件。替换组件**必须接受与原组件相同的 props**。
#### 可替换的组件列表
| 组件键名 | 原始位置 | 说明 |
|----------|----------|------|
| `Button` | `shadcn-bridge/heroui/button` | 按钮 |
| `Card`, `CardHeader`, `CardBody`, `CardFooter` | `shadcn-bridge/heroui/card` | 卡片 |
| `Input` | `shadcn-bridge/heroui/input` | 输入框 |
| `Select`, `SelectItem` | `shadcn-bridge/heroui/select` | 下拉选择 |
| `Switch` | `shadcn-bridge/heroui/switch` | 开关 |
| `Checkbox` | `shadcn-bridge/heroui/checkbox` | 复选框 |
| `Chip` | `shadcn-bridge/heroui/chip` | 标签/芯片 |
| `Modal`, `ModalContent`, `ModalHeader`, `ModalBody`, `ModalFooter` | `shadcn-bridge/heroui/modal` | 模态框 |
| `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableCell`, `TableColumn` | `shadcn-bridge/heroui/table` | 表格 |
| `Tabs`, `Tab` | `shadcn-bridge/heroui/tabs` | 标签页 |
| `Progress` | `shadcn-bridge/heroui/progress` | 进度条 |
| `Spinner` | `shadcn-bridge/heroui/spinner` | 加载指示器 |
| `Divider` | `shadcn-bridge/heroui/divider` | 分割线 |
| `Link` | `shadcn-bridge/heroui/link` | 链接 |
| `Dropdown`, `DropdownTrigger`, `DropdownMenu`, `DropdownItem` | `shadcn-bridge/heroui/dropdown` | 下拉菜单 |
| `Navbar`, `NavbarContent`, `NavbarItem` | `shadcn-bridge/heroui/navbar` | 导航栏 |
| `Radio`, `RadioGroup` | `shadcn-bridge/heroui/radio` | 单选框 |
| `Accordion`, `AccordionItem` | `shadcn-bridge/heroui/accordion` | 手风琴 |
| `DatePicker` | `shadcn-bridge/heroui/date-picker` | 日期选择器 |
| `Alert` | `shadcn-bridge/heroui/alert` | 警告提示 |
| `SearchBar` | `components/search-bar` | 搜索栏 |
| `BrandLogo` | `components/brand-logo` | 品牌 Logo |
| `VersionFooter` | `components/version-footer` | 版本页脚 |
#### 组件替换示例
**方式一:包装原组件**(推荐,保证兼容性)
```typescript
// src/themes/my-theme/components/button.tsx
import React from "react";
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button";
export const MyButton: React.FC<ButtonProps> = (props) => {
return (
<OriginalButton
{...props}
className={`${props.className || ""} my-custom-class`}
style={{
...props.style,
borderRadius: "9999px", // 全圆角
}}
/>
);
};
```
**方式二:完全重写组件**
```typescript
// src/themes/my-theme/components/button.tsx
import React from "react";
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
export const MyButton: React.FC<ButtonProps> = ({
children,
color = "default",
variant = "solid",
size = "md",
isLoading,
isDisabled,
onPress,
className,
...rest
}) => {
return (
<button
className={`my-totally-custom-button ${className || ""}`}
disabled={isDisabled || isLoading}
onClick={() => onPress?.()}
{...rest}
>
{isLoading && <span className="spinner" />}
{children}
</button>
);
};
```
然后在主题入口中注册:
```typescript
// src/themes/my-theme/index.ts
import { MyButton } from "./components/button";
const theme: ThemePackage = {
// ...
components: {
Button: MyButton,
},
};
```
### 布局替换
可以替换 4 种布局:
| 布局键名 | 说明 |
|----------|------|
| `AdminLayout` | 管理后台主布局(侧边栏 + 顶栏) |
| `H5Layout` | 移动端布局(底部导航) |
| `H5SimpleLayout` | 移动端简洁布局(无底部导航) |
| `DefaultLayout` | 默认布局(登录页等) |
```typescript
// src/themes/my-theme/layouts/admin.tsx
import React from "react";
const MyAdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<div className="my-admin-layout">
<header className="my-header">
{/* 自定义顶栏 */}
</header>
<aside className="my-sidebar">
{/* 自定义侧边栏 */}
</aside>
<main className="my-content">
{children}
</main>
</div>
);
};
export default MyAdminLayout;
```
### 页面替换
可以替换任意页面路由的实现:
| 页面键名 | 路由 |
|----------|------|
| `LoginPage` | `/` |
| `DashboardPage` | `/dashboard` |
| `MonitorPage` | `/monitor` |
| `ForwardPage` | `/forward` |
| `TunnelPage` | `/tunnel` |
| `NodePage` | `/node` |
| `UserPage` | `/user` |
| `GroupPage` | `/group` |
| `ProfilePage` | `/profile` |
| `LimitPage` | `/limit` |
| `ConfigPage` | `/config` |
| `PanelSharingPage` | `/panel-sharing` |
| `SettingsPage` | `/settings` |
### 生命周期钩子
```typescript
const theme: ThemePackage = {
// ...
onActivate: () => {
// 主题被激活时执行
// 例如:加载外部字体、注入全局属性
console.log("Theme activated!");
},
onDeactivate: () => {
// 主题被停用时执行
// 例如:清理全局属性
console.log("Theme deactivated!");
},
};
```
---
## 主题提交流程
1. Fork 本仓库
2. 在 `src/themes/` 下创建你的主题文件夹
3. 在 `src/themes/loader.ts` 中注册
4. 提交 Pull Request
### 命名规范
- 文件夹名:`kebab-case`(如 `my-awesome-theme`)
- 主题 `id`:与文件夹名一致
- 主题 `name`:简短中文名
### 代码规范
- TypeScript 严格模式
- 组件替换必须保证 props 兼容性
- 不得修改 `src/themes/types.ts`(影响其他主题)
- 不得修改 `src/themes/registry.ts`(影响核心逻辑)
- 仅修改你自己的主题文件夹 + `loader.ts` 中的注册
---
## 文件结构参考
```
src/themes/
├── types.ts # 主题接口定义 (勿改)
├── registry.ts # 主题注册表 (勿改)
├── context.tsx # React Context (勿改)
├── index.ts # 公共 API (勿改)
├── loader.ts # 主题加载器 (仅在此添加注册)
│
├── default/ # 默认主题 (参考实现)
│ └── index.ts
│
├── example-cyberpunk/ # 示例主题 (可复制修改)
│ ├── index.ts
│ └── components/
│ └── button.tsx
│
└── your-theme/ # 你的主题
├── index.ts
├── components/
│ ├── button.tsx
│ └── card.tsx
├── layouts/
│ └── admin.tsx
└── assets/
└── logo.svg
```
+194
View File
@@ -0,0 +1,194 @@
/**
* Theme Context + Provider (React integration)
* =============================================
* Wraps the registry in a React context so that the entire component tree
* re-renders when the active theme changes.
*/
import React, {
createContext,
useContext,
useEffect,
useSyncExternalStore,
useCallback,
useMemo,
} from "react";
import type { ThemePackage, ComponentKey, LayoutKey, PageKey } from "./types";
import {
subscribe,
getActiveTheme,
getActiveThemeId,
getRegisteredThemes,
activateTheme,
deactivateTheme,
reapplyActiveTheme,
resolveComponent,
resolveLayout,
resolvePage,
getSavedMode,
saveMode,
getEffectiveMode,
initThemeSystem,
registerTheme,
unregisterTheme,
type ThemeMode,
} from "./registry";
// ─── context value ───────────────────────────────────────────────────────────
interface ThemeContextValue {
/** Currently active theme package (null = default). */
activeTheme: ThemePackage | null;
activeThemeId: string | null;
/** All registered themes. */
themes: ThemePackage[];
/** Current mode preference. */
mode: ThemeMode;
/** Resolved effective mode (never "system"). */
effectiveMode: "light" | "dark";
/** Switch the active theme. */
switchTheme: (id: string) => void;
/** Reset to no custom theme (use defaults). */
resetTheme: () => void;
/** Change mode preference. */
setMode: (mode: ThemeMode) => void;
/** Register a new theme at runtime. */
register: (pkg: ThemePackage) => void;
/** Unregister a theme by id. */
unregister: (id: string) => void;
/** Resolve a component (returns themed override or fallback). */
component: <P = any>(key: ComponentKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
/** Resolve a layout. */
layout: (key: LayoutKey, fallback: React.ComponentType<{ children: React.ReactNode }>) => React.ComponentType<{ children: React.ReactNode }>;
/** Resolve a page. */
page: <P = any>(key: PageKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
// ─── snapshot for useSyncExternalStore ────────────────────────────────────────
// We use a monotonic counter to create new snapshot references when the
// registry notifies.
let snapshotCounter = 0;
function getSnapshot() {
return snapshotCounter;
}
const originalSubscribe = (onStoreChange: () => void) => {
const unsub = subscribe(() => {
snapshotCounter++;
onStoreChange();
});
return unsub;
};
// ─── provider ────────────────────────────────────────────────────────────────
interface ThemeProviderProps {
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
// Re-render whenever register changes
useSyncExternalStore(originalSubscribe, getSnapshot);
// Initialise on mount
useEffect(() => {
initThemeSystem();
}, []);
// Listen for system mode changes
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => reapplyActiveTheme();
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const switchTheme = useCallback((id: string) => activateTheme(id), []);
const resetTheme = useCallback(() => {
deactivateTheme();
reapplyActiveTheme();
localStorage.removeItem("flvx:active-theme");
}, []);
const setMode = useCallback((m: ThemeMode) => {
saveMode(m);
reapplyActiveTheme();
}, []);
const register = useCallback((pkg: ThemePackage) => registerTheme(pkg), []);
const unregister = useCallback((id: string) => unregisterTheme(id), []);
const value = useMemo<ThemeContextValue>(() => ({
activeTheme: getActiveTheme(),
activeThemeId: getActiveThemeId(),
themes: getRegisteredThemes(),
mode: getSavedMode(),
effectiveMode: getEffectiveMode(),
switchTheme,
resetTheme,
setMode,
register,
unregister,
component: resolveComponent,
layout: resolveLayout,
page: resolvePage,
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [snapshotCounter, switchTheme, resetTheme, setMode, register, unregister]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
// ─── hooks ───────────────────────────────────────────────────────────────────
/** Access the full theme context. */
export function useThemeContext(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useThemeContext must be used within <ThemeProvider>");
return ctx;
}
/**
* Convenience: resolve a single themed component.
*
* ```tsx
* import { useThemedComponent } from "@/themes/context";
* import { Button as DefaultButton } from "@/shadcn-bridge/heroui/button";
*
* function MyPage() {
* const Button = useThemedComponent("Button", DefaultButton);
* return <Button color="primary">Click</Button>;
* }
* ```
*/
export function useThemedComponent<P = any>(
key: ComponentKey,
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const ctx = useThemeContext();
return ctx.component(key, fallback);
}
/** Convenience: resolve a themed layout. */
export function useThemedLayout(
key: LayoutKey,
fallback: React.ComponentType<{ children: React.ReactNode }>,
): React.ComponentType<{ children: React.ReactNode }> {
const ctx = useThemeContext();
return ctx.layout(key, fallback);
}
/** Convenience: resolve a themed page. */
export function useThemedPage<P = any>(
key: PageKey,
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const ctx = useThemeContext();
return ctx.page(key, fallback);
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Default Theme
* =============
* This is the built-in "stock" FLVX theme. It doesn't override any
* components — it only declares the CSS tokens that match the colours
* already defined in `globals.css`. This serves as the **reference
* implementation** that theme authors can copy and modify.
*
* When this theme is active, the frontend looks identical to an
* unmodified FLVX install.
*/
import type { ThemePackage } from "../types";
const defaultTheme: ThemePackage = {
id: "default",
name: "默认主题",
author: "FLVX Team",
version: "1.0.0",
description: "FLVX 内置默认蓝色主题",
tokens: {
light: {
"--background": "#f6f7fb",
"--foreground": "#111827",
"--border": "#e5e7eb",
"--input": "#d1d5db",
"--ring": "#93c5fd",
"--content1": "#ffffff",
"--divider": "#e5e7eb",
"--default-50": "#f9fafb",
"--default-100": "#f3f4f6",
"--default-200": "#e5e7eb",
"--default-300": "#d1d5db",
"--default-400": "#9ca3af",
"--default-500": "#6b7280",
"--default-600": "#4b5563",
"--default-700": "#374151",
"--default-800": "#1f2937",
"--default-900": "#111827",
"--primary": "#2563eb",
"--primary-foreground": "#ffffff",
"--primary-50": "#eff6ff",
"--primary-100": "#dbeafe",
"--primary-200": "#bfdbfe",
"--primary-300": "#93c5fd",
"--primary-400": "#60a5fa",
"--primary-500": "#3b82f6",
"--primary-600": "#2563eb",
"--primary-700": "#1d4ed8",
"--primary-800": "#1e40af",
"--primary-900": "#1e3a8a",
"--secondary": "#6366f1",
"--secondary-foreground": "#ffffff",
"--secondary-50": "#eef2ff",
"--secondary-100": "#e0e7ff",
"--secondary-200": "#c7d2fe",
"--secondary-300": "#a5b4fc",
"--secondary-400": "#818cf8",
"--secondary-500": "#6366f1",
"--secondary-600": "#4f46e5",
"--secondary-700": "#4338ca",
"--secondary-800": "#3730a3",
"--secondary-900": "#312e81",
"--danger": "#dc2626",
"--danger-50": "#fef2f2",
"--danger-100": "#fee2e2",
"--danger-200": "#fecaca",
"--danger-300": "#fca5a5",
"--danger-400": "#f87171",
"--danger-500": "#ef4444",
"--danger-600": "#dc2626",
"--danger-700": "#b91c1c",
"--danger-800": "#991b1b",
"--danger-900": "#7f1d1d",
"--success": "#16a34a",
"--success-50": "#f0fdf4",
"--success-100": "#dcfce7",
"--success-200": "#bbf7d0",
"--success-300": "#86efac",
"--success-400": "#4ade80",
"--success-500": "#22c55e",
"--success-600": "#16a34a",
"--success-700": "#15803d",
"--success-800": "#166534",
"--success-900": "#14532d",
"--warning": "#d97706",
"--warning-50": "#fffbeb",
"--warning-100": "#fef3c7",
"--warning-200": "#fde68a",
"--warning-300": "#fcd34d",
"--warning-400": "#fbbf24",
"--warning-500": "#f59e0b",
"--warning-600": "#d97706",
"--warning-700": "#b45309",
"--warning-800": "#92400e",
"--warning-900": "#78350f",
},
dark: {
"--background": "#0b1020",
"--foreground": "#f3f4f6",
"--border": "#334155",
"--input": "#475569",
"--ring": "#60a5fa",
"--content1": "#111827",
"--divider": "#334155",
"--default-50": "#0f172a",
"--default-100": "#1e293b",
"--default-200": "#334155",
"--default-300": "#475569",
"--default-400": "#64748b",
"--default-500": "#94a3b8",
"--default-600": "#cbd5e1",
"--default-700": "#e2e8f0",
"--default-800": "#f1f5f9",
"--default-900": "#f8fafc",
"--primary": "#3b82f6",
"--secondary": "#818cf8",
"--danger": "#ef4444",
"--success": "#22c55e",
"--warning": "#f59e0b",
},
},
// No component/layout/page overrides — uses all defaults.
};
export default defaultTheme;
@@ -0,0 +1,55 @@
/**
* Cyberpunk Button — Component Override Example
* ==============================================
* Demonstrates how to override a built-in component.
*
* Rules:
* 1. Accept the SAME props as the original component.
* 2. Import the original's props type for compatibility.
* 3. You CAN wrap the original component and add extra behaviour,
* or you can build a completely new component from scratch.
*/
import React from "react";
// Import the original Button's props interface for compatibility
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
// Optionally import the original to wrap it
import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button";
/**
* CyberpunkButton wraps the original Button and adds a neon glow effect.
* It passes all props through, so it's a full drop-in replacement.
*/
export const CyberpunkButton: React.FC<ButtonProps> = (props) => {
const { className = "", style, color, ...rest } = props;
// Add neon glow based on color
const glowColor =
color === "danger"
? "rgba(255, 51, 102, 0.5)"
: color === "success"
? "rgba(0, 255, 136, 0.5)"
: color === "warning"
? "rgba(255, 170, 0, 0.5)"
: color === "secondary"
? "rgba(0, 255, 255, 0.5)"
: "rgba(255, 0, 255, 0.5)";
const glowStyle: React.CSSProperties = {
...style,
boxShadow: `0 0 8px ${glowColor}, 0 0 16px ${glowColor}`,
transition: "box-shadow 0.3s ease, transform 0.15s ease",
textTransform: "uppercase" as const,
letterSpacing: "0.05em",
};
return (
<OriginalButton
className={`${className} cyberpunk-btn`}
color={color}
style={glowStyle}
{...rest}
/>
);
};
@@ -0,0 +1,133 @@
/**
* Example Theme: Cyberpunk
* ========================
* A neon-cyberpunk dark theme that demonstrates ALL override capabilities:
* ✅ CSS tokens (full dark palette)
* ✅ Raw CSS (neon glow effects, custom animations, font-face)
* ✅ Component override (custom Button with glow)
* ✅ Lifecycle hooks
*
* Theme authors: copy this entire folder and modify it to create your own
* theme. See README.md for the full guide.
*/
import type { ThemePackage } from "../types";
import { CyberpunkButton } from "./components/button";
const cyberpunkTheme: ThemePackage = {
id: "cyberpunk",
name: "赛博朋克",
author: "FLVX Community",
version: "1.0.0",
description: "霓虹灯风格的赛博朋克暗色主题",
// ── CSS Tokens ────────────────────────────────────────────────────────────
tokens: {
light: {
// This theme is dark-only, so the light tokens just fall through to dark
"--background": "#0a0a1a",
"--foreground": "#e0e0ff",
"--border": "#2a2a4a",
"--input": "#1a1a3a",
"--ring": "#ff00ff",
"--content1": "#12122a",
"--divider": "#2a2a4a",
"--primary": "#ff00ff",
"--primary-foreground": "#ffffff",
"--secondary": "#00ffff",
"--secondary-foreground": "#000000",
"--danger": "#ff3366",
"--success": "#00ff88",
"--warning": "#ffaa00",
},
dark: {
"--background": "#0a0a1a",
"--foreground": "#e0e0ff",
"--border": "#2a2a4a",
"--input": "#1a1a3a",
"--ring": "#ff00ff",
"--content1": "#12122a",
"--divider": "#2a2a4a",
"--default-50": "#0d0d20",
"--default-100": "#14142e",
"--default-200": "#1e1e3c",
"--default-300": "#2a2a4a",
"--default-400": "#4a4a6a",
"--default-500": "#7a7a9a",
"--default-600": "#9a9aba",
"--default-700": "#babada",
"--default-800": "#dadaf0",
"--default-900": "#f0f0ff",
"--primary": "#ff00ff",
"--primary-foreground": "#ffffff",
"--primary-50": "#1a001a",
"--primary-100": "#330033",
"--primary-200": "#660066",
"--primary-300": "#990099",
"--primary-400": "#cc00cc",
"--primary-500": "#ff00ff",
"--primary-600": "#ff33ff",
"--primary-700": "#ff66ff",
"--primary-800": "#ff99ff",
"--primary-900": "#ffccff",
"--secondary": "#00ffff",
"--secondary-foreground": "#000000",
"--danger": "#ff3366",
"--success": "#00ff88",
"--warning": "#ffaa00",
},
},
// ── Raw CSS (glow effects, animations, fonts) ─────────────────────────────
css: `
/* Cyberpunk neon glow on primary buttons */
[data-flvx-theme="cyberpunk"] .bg-primary,
.bg-primary {
box-shadow: 0 0 12px rgba(255, 0, 255, 0.4),
0 0 24px rgba(255, 0, 255, 0.15);
}
/* Neon border glow on cards */
[data-flvx-theme="cyberpunk"] [class*="border"] {
border-color: rgba(255, 0, 255, 0.15);
}
/* Scanline overlay animation */
@keyframes flvx-scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0a0a1a; }
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #ff00ff, #00ffff);
border-radius: 3px;
}
`,
// ── Component Overrides ───────────────────────────────────────────────────
components: {
Button: CyberpunkButton,
},
// ── Lifecycle ─────────────────────────────────────────────────────────────
onActivate: () => {
// Force dark mode for this theme
document.documentElement.classList.add("dark");
document.documentElement.style.colorScheme = "dark";
// Mark the body for theme-specific CSS selectors
document.body.setAttribute("data-flvx-theme", "cyberpunk");
},
onDeactivate: () => {
document.body.removeAttribute("data-flvx-theme");
},
};
export default cyberpunkTheme;
+45
View File
@@ -0,0 +1,45 @@
/**
* @module @/themes
* Public API for the FLVX theme system.
*
* Usage:
* import { ThemeProvider, useThemeContext, registerTheme } from "@/themes";
*/
export type {
ThemePackage,
ThemeTokens,
ComponentKey,
LayoutKey,
PageKey,
} from "./types";
export {
registerTheme,
unregisterTheme,
getRegisteredThemes,
getTheme,
getActiveThemeId,
getActiveTheme,
activateTheme,
deactivateTheme,
reapplyActiveTheme,
initThemeSystem,
resolveComponent,
resolveLayout,
resolvePage,
getSavedMode,
saveMode,
getEffectiveMode,
subscribe,
} from "./registry";
export type { ThemeMode } from "./registry";
export {
ThemeProvider,
useThemeContext,
useThemedComponent,
useThemedLayout,
useThemedPage,
} from "./context";
+36
View File
@@ -0,0 +1,36 @@
/**
* Theme Loader — auto-registers all built-in themes
* ==================================================
* Import this module once at app startup (in provider.tsx or App.tsx).
*
* To add a new theme:
* 1. Create a folder under `src/themes/` (e.g. `src/themes/my-theme/`)
* 2. Export a `ThemePackage` as the default export from `index.ts`
* 3. Import and register it below
*/
import { registerTheme } from "./registry";
// ── Built-in themes ──────────────────────────────────────────────────────────
import defaultTheme from "./default";
import cyberpunkTheme from "./example-cyberpunk";
// Register all themes
registerTheme(defaultTheme);
registerTheme(cyberpunkTheme);
/*
* ── ADDING YOUR OWN THEME ──────────────────────────────────────────────────
*
* 1. Create your theme folder:
* src/themes/my-awesome-theme/
* ├── index.ts ← exports ThemePackage
* ├── components/ ← optional component overrides
* └── ...
*
* 2. Import and register here:
* import myTheme from "./my-awesome-theme";
* registerTheme(myTheme);
*
* That's it! The theme will appear in the theme picker.
*/
+263
View File
@@ -0,0 +1,263 @@
/**
* Theme Registry
* ==============
* Manages the set of installed themes and the currently active theme.
* Handles CSS variable injection, `<style>` element management, and
* lifecycle callbacks.
*
* This module is framework-agnostic (no React dependency). The React
* integration lives in `./context.tsx`.
*/
import type { ThemePackage, ThemeTokens, ComponentKey, LayoutKey, PageKey } from "./types";
// ─── internal state ──────────────────────────────────────────────────────────
const installed = new Map<string, ThemePackage>();
let activeId: string | null = null;
let injectedStyleEl: HTMLStyleElement | null = null;
const STORAGE_KEY = "flvx:active-theme";
const MODE_KEY = "flvx:theme"; // backwards-compat with old use-theme
type ChangeListener = () => void;
const changeListeners = new Set<ChangeListener>();
function notify() {
changeListeners.forEach((fn) => fn());
}
// ─── public API ──────────────────────────────────────────────────────────────
/**
* Register a theme package. Call this for every theme you want available.
* Registering a theme with an existing id replaces the previous one.
*/
export function registerTheme(pkg: ThemePackage): void {
installed.set(pkg.id, pkg);
notify();
}
/** Unregister a theme by id. */
export function unregisterTheme(id: string): void {
if (activeId === id) deactivateTheme();
installed.delete(id);
notify();
}
/** Get all registered themes. */
export function getRegisteredThemes(): ThemePackage[] {
return Array.from(installed.values());
}
/** Get a specific theme by id. */
export function getTheme(id: string): ThemePackage | undefined {
return installed.get(id);
}
/** Get the id of the currently active theme (or null). */
export function getActiveThemeId(): string | null {
return activeId;
}
/** Get the currently active ThemePackage (or null). */
export function getActiveTheme(): ThemePackage | null {
return activeId ? installed.get(activeId) ?? null : null;
}
// ─── theme mode ──────────────────────────────────────────────────────────────
export type ThemeMode = "light" | "dark" | "system";
export function resolveSystemMode(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function getSavedMode(): ThemeMode {
if (typeof window === "undefined") return "system";
const raw = localStorage.getItem(MODE_KEY);
if (raw === "light" || raw === "dark" || raw === "system") return raw;
return "system";
}
export function saveMode(mode: ThemeMode): void {
localStorage.setItem(MODE_KEY, mode);
notify();
}
export function getEffectiveMode(): "light" | "dark" {
const mode = getSavedMode();
return mode === "system" ? resolveSystemMode() : mode;
}
// ─── activation ──────────────────────────────────────────────────────────────
/**
* Activate a theme by id. This:
* 1. Calls `onDeactivate` on the previous theme.
* 2. Injects CSS tokens onto `document.documentElement`.
* 3. Injects the theme's `css` string into a `<style>` element.
* 4. Calls `onActivate` on the new theme.
* 5. Persists the choice to localStorage.
*/
export function activateTheme(id: string): void {
const pkg = installed.get(id);
if (!pkg) {
console.warn(`[FLVX themes] Theme "${id}" is not registered.`);
return;
}
// Deactivate previous
deactivateTheme();
activeId = id;
// Inject tokens
const mode = getEffectiveMode();
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
if (tokens) injectTokens(tokens);
// Inject custom CSS
if (pkg.css) {
injectedStyleEl = document.createElement("style");
injectedStyleEl.setAttribute("data-flvx-theme", id);
injectedStyleEl.textContent = pkg.css;
document.head.appendChild(injectedStyleEl);
}
// Update dark class
const root = document.documentElement;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
// Lifecycle
pkg.onActivate?.();
// Persist
localStorage.setItem(STORAGE_KEY, id);
notify();
}
/** Deactivate the current theme, reverting all overrides. */
export function deactivateTheme(): void {
const prev = activeId ? installed.get(activeId) : null;
prev?.onDeactivate?.();
// Remove injected tokens
clearInjectedTokens();
// Remove injected style element
if (injectedStyleEl) {
injectedStyleEl.remove();
injectedStyleEl = null;
}
activeId = null;
}
/** Re-apply the active theme (e.g. after mode changes). */
export function reapplyActiveTheme(): void {
if (activeId) {
const id = activeId;
// quick re-inject without full lifecycle
const pkg = installed.get(id);
if (!pkg) return;
clearInjectedTokens();
const mode = getEffectiveMode();
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
if (tokens) injectTokens(tokens);
const root = document.documentElement;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
} else {
// No theme active — just set dark class based on mode
const mode = getEffectiveMode();
const root = document.documentElement;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
}
notify();
}
// ─── component resolution ────────────────────────────────────────────────────
/**
* Resolve a component: returns the themed override if present, otherwise
* returns the fallback (default implementation).
*/
export function resolveComponent<P = any>(
key: ComponentKey,
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const pkg = activeId ? installed.get(activeId) : null;
const override = pkg?.components?.[key];
return (override as React.ComponentType<P>) ?? fallback;
}
/** Resolve a layout override. */
export function resolveLayout(
key: LayoutKey,
fallback: React.ComponentType<{ children: React.ReactNode }>,
): React.ComponentType<{ children: React.ReactNode }> {
const pkg = activeId ? installed.get(activeId) : null;
return pkg?.layouts?.[key] ?? fallback;
}
/** Resolve a page override. */
export function resolvePage<P = any>(
key: PageKey,
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const pkg = activeId ? installed.get(activeId) : null;
const override = pkg?.pages?.[key];
return (override as React.ComponentType<P>) ?? fallback;
}
// ─── subscriber API (for React) ──────────────────────────────────────────────
export function subscribe(listener: ChangeListener): () => void {
changeListeners.add(listener);
return () => changeListeners.delete(listener);
}
// ─── initialisation ──────────────────────────────────────────────────────────
/**
* Call once at app boot. Restores the previously active theme from
* localStorage (if the theme is registered).
*/
export function initThemeSystem(): void {
const savedId = localStorage.getItem(STORAGE_KEY);
if (savedId && installed.has(savedId)) {
activateTheme(savedId);
} else {
// Just apply mode
reapplyActiveTheme();
}
}
// ─── internal helpers ────────────────────────────────────────────────────────
const injectedVars: string[] = [];
function injectTokens(tokens: ThemeTokens): void {
const root = document.documentElement;
for (const [varName, value] of Object.entries(tokens)) {
if (value !== undefined) {
root.style.setProperty(varName, value);
injectedVars.push(varName);
}
}
}
function clearInjectedTokens(): void {
const root = document.documentElement;
for (const varName of injectedVars) {
root.style.removeProperty(varName);
}
injectedVars.length = 0;
}
+299
View File
@@ -0,0 +1,299 @@
/**
* FLVX Theme System — Type Definitions
* =====================================
* This file defines the contract that every theme package must implement.
* Theme authors: read README.md first, then implement ThemePackage.
*/
import type React from "react";
import type { ComponentType } from "react";
// ─── CSS Variable Tokens ─────────────────────────────────────────────────────
/**
* Complete set of CSS variable tokens a theme can define.
* All values are valid CSS colour strings (hex, rgb, hsl, etc.).
* A theme does NOT need to provide every token — missing ones fall back
* to the default theme.
*/
export interface ThemeTokens {
/* ── base surfaces ─────────────────────── */
"--background"?: string;
"--foreground"?: string;
"--border"?: string;
"--input"?: string;
"--ring"?: string;
"--content1"?: string;
"--divider"?: string;
/* ── default (neutral) palette ─────────── */
"--default-50"?: string;
"--default-100"?: string;
"--default-200"?: string;
"--default-300"?: string;
"--default-400"?: string;
"--default-500"?: string;
"--default-600"?: string;
"--default-700"?: string;
"--default-800"?: string;
"--default-900"?: string;
/* ── primary ───────────────────────────── */
"--primary"?: string;
"--primary-foreground"?: string;
"--primary-50"?: string;
"--primary-100"?: string;
"--primary-200"?: string;
"--primary-300"?: string;
"--primary-400"?: string;
"--primary-500"?: string;
"--primary-600"?: string;
"--primary-700"?: string;
"--primary-800"?: string;
"--primary-900"?: string;
/* ── secondary ─────────────────────────── */
"--secondary"?: string;
"--secondary-foreground"?: string;
"--secondary-50"?: string;
"--secondary-100"?: string;
"--secondary-200"?: string;
"--secondary-300"?: string;
"--secondary-400"?: string;
"--secondary-500"?: string;
"--secondary-600"?: string;
"--secondary-700"?: string;
"--secondary-800"?: string;
"--secondary-900"?: string;
/* ── danger ────────────────────────────── */
"--danger"?: string;
"--danger-50"?: string;
"--danger-100"?: string;
"--danger-200"?: string;
"--danger-300"?: string;
"--danger-400"?: string;
"--danger-500"?: string;
"--danger-600"?: string;
"--danger-700"?: string;
"--danger-800"?: string;
"--danger-900"?: string;
/* ── success ───────────────────────────── */
"--success"?: string;
"--success-50"?: string;
"--success-100"?: string;
"--success-200"?: string;
"--success-300"?: string;
"--success-400"?: string;
"--success-500"?: string;
"--success-600"?: string;
"--success-700"?: string;
"--success-800"?: string;
"--success-900"?: string;
/* ── warning ───────────────────────────── */
"--warning"?: string;
"--warning-50"?: string;
"--warning-100"?: string;
"--warning-200"?: string;
"--warning-300"?: string;
"--warning-400"?: string;
"--warning-500"?: string;
"--warning-600"?: string;
"--warning-700"?: string;
"--warning-800"?: string;
"--warning-900"?: string;
/* ── typography ─────────────────────────── */
"--font-sans"?: string;
"--font-mono"?: string;
/* ── geometry ───────────────────────────── */
"--radius"?: string;
"--radius-sm"?: string;
"--radius-lg"?: string;
/** Escape hatch: any extra CSS variable */
[key: `--${string}`]: string | undefined;
}
// ─── Component Keys ──────────────────────────────────────────────────────────
/**
* All overridable component keys. These exactly correspond to the exports
* from `src/shadcn-bridge/heroui/*` and `src/components/*`.
*
* A theme only needs to override the components it wants to change.
* Every other component falls through to the default implementation.
*/
export type ComponentKey =
// shadcn-bridge/heroui primitives
| "Button"
| "Card"
| "CardHeader"
| "CardBody"
| "CardFooter"
| "Input"
| "Select"
| "SelectItem"
| "Switch"
| "Checkbox"
| "Chip"
| "Modal"
| "ModalContent"
| "ModalHeader"
| "ModalBody"
| "ModalFooter"
| "Table"
| "TableHeader"
| "TableBody"
| "TableRow"
| "TableCell"
| "TableColumn"
| "Tabs"
| "Tab"
| "Progress"
| "Spinner"
| "Divider"
| "Link"
| "Dropdown"
| "DropdownTrigger"
| "DropdownMenu"
| "DropdownItem"
| "Navbar"
| "NavbarContent"
| "NavbarItem"
| "Radio"
| "RadioGroup"
| "Accordion"
| "AccordionItem"
| "DatePicker"
| "Alert"
// app-level components
| "SearchBar"
| "BrandLogo"
| "VersionFooter"
| "PageWrapper"
| "PageState"
| "BatchActionResultModal";
/**
* All overridable layout keys.
* Layouts receive `{ children: React.ReactNode }` as props.
*/
export type LayoutKey = "AdminLayout" | "H5Layout" | "H5SimpleLayout" | "DefaultLayout";
/**
* All overridable page keys.
* Pages are rendered as route components — they receive no props from the
* router (params come from React Router hooks).
*/
export type PageKey =
| "LoginPage"
| "DashboardPage"
| "MonitorPage"
| "ForwardPage"
| "TunnelPage"
| "NodePage"
| "UserPage"
| "GroupPage"
| "ProfilePage"
| "LimitPage"
| "ConfigPage"
| "PanelSharingPage"
| "SettingsPage"
| "ChangePasswordPage";
// ─── Theme Package ───────────────────────────────────────────────────────────
/**
* The main interface a theme must export as its default export.
*
* Minimal theme (colours only):
* ```ts
* const theme: ThemePackage = {
* id: "my-theme",
* name: "My Theme",
* author: "Me",
* version: "1.0.0",
* tokens: { light: { "--primary": "#ff6600" } },
* };
* export default theme;
* ```
*
* Full theme (components + layouts + pages):
* ```ts
* import MyButton from "./components/button";
* import MyAdminLayout from "./layouts/admin";
* const theme: ThemePackage = {
* id: "my-theme",
* ...
* tokens: { ... },
* components: { Button: MyButton },
* layouts: { AdminLayout: MyAdminLayout },
* pages: { DashboardPage: MyDashboard },
* css: `body { font-family: "Comic Sans MS" !important; }`,
* onActivate: () => console.log("Activated!"),
* };
* ```
*/
export interface ThemePackage {
/** Unique identifier (kebab-case, e.g. "midnight-purple"). */
id: string;
/** Human-readable display name. */
name: string;
/** Author name or GitHub handle. */
author: string;
/** SemVer version string. */
version: string;
/** Short description shown in theme picker. */
description?: string;
/** Absolute or relative URL to a preview screenshot. */
preview?: string;
// ── Styling ────────────────────────────────────────────────────────────────
/**
* CSS variable token overrides. Provide `light`, `dark`, or both.
* Only the variables you specify will be overridden; all others keep
* the default values from `globals.css`.
*/
tokens?: {
light?: ThemeTokens;
dark?: ThemeTokens;
};
/**
* Raw CSS string injected into a `<style>` element when this theme is
* active. Use this for custom selectors, animations, font-faces, etc.
* The style element is removed when the theme is deactivated.
*/
css?: string;
// ── Component / Layout / Page Overrides ────────────────────────────────────
/**
* Map of component overrides. The replacement component MUST accept the
* same props interface as the original. Import types from
* `@/shadcn-bridge/heroui/*` for reference.
*/
components?: Partial<Record<ComponentKey, ComponentType<any>>>;
/**
* Map of layout overrides. Each layout receives `{ children }`.
*/
layouts?: Partial<Record<LayoutKey, ComponentType<{ children: React.ReactNode }>>>;
/**
* Map of page overrides. Each page is a full route-level component.
*/
pages?: Partial<Record<PageKey, ComponentType<any>>>;
// ── Lifecycle ──────────────────────────────────────────────────────────────
/** Called when this theme becomes the active theme. */
onActivate?: () => void;
/** Called when this theme is being replaced by another. */
onDeactivate?: () => void;
}