add theme switch

This commit is contained in:
Km.Van
2026-07-10 21:28:46 +08:00
parent 93a6aef58f
commit c106d7559e
20 changed files with 236 additions and 440 deletions
+82 -80
View File
@@ -2,96 +2,98 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<meta content="ie=edge" http-equiv="X-UA-Compatible">
<meta content="webkit" name="renderer">
<title>Dev mode</title>
<script>"use strict";
(() => {
window.GLOBAL_CONFIG = {
IS_DEV: true,
};
try {
// 1. 尝试读取用户存储的偏好
const storedTheme = localStorage.getItem("theme");
let theme = storedTheme; // 如果有存储,直接用
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<meta content="ie=edge" http-equiv="X-UA-Compatible">
<meta content="webkit" name="renderer">
<title>Dev mode</title>
<script>"use strict";
(() => {
window.GLOBAL_CONFIG = {
IS_DEV: true,
THEME: "",
};
try {
// 1. 尝试读取用户存储的偏好
const storedTheme = localStorage.getItem("x-theme");
let theme = storedTheme; // 如果有存储,直接用
// 2. 如果没有存储,则检测系统偏好
if (!storedTheme) {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
theme = prefersDark ? "dark" : "light";
}
// 3. 将主题应用到根元素
document.documentElement.setAttribute("data-theme", theme);
// 4. (可选)如果想让后续 JS 也能读到,可以同步存入 localStorage,但建议让用户主动选择时才存,避免覆盖“未选择”状态
// 如果不想存储系统偏好,则不执行 localStorage.setItem
} catch (e) {
console.error(e);
}
})();
</script>
<script src="/main.tsx" type="module"></script>
<style>
:root {
--x-init-fg: hsl(0 0% 10%);
--x-init-body-fg: hsl(0 0% 10%);
--x-init-body-bg: hsl(0 0% 90%);
--x-init-loading-bg: hsl(0 0% 90%);
--x-init-loading-fg: hsl(0 0% 10%);
// 2. 如果没有存储,则检测系统偏好
if (!storedTheme) {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
theme = prefersDark ? "dark" : "light";
window.THEME = theme;
}
[data-theme="dark"] {
--x-init-fg: hsl(0 0% 90%);
--x-init-body-fg: hsl(0 0% 90%);
--x-init-body-bg: hsl(0 0% 0%);
--x-init-loading-bg: hsl(0 0% 0%);
--x-init-loading-fg: hsl(0 0% 90%);
}
// 3. 将主题应用到根元素
document.documentElement.setAttribute("data-theme", theme);
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// 4. (可选)如果想让后续 JS 也能读到,可以同步存入 localStorage,但建议让用户主动选择时才存,避免覆盖“未选择”状态
// 如果不想存储系统偏好,则不执行 localStorage.setItem
} catch (e) {
console.error(e);
}
})();
</script>
<script src="/main.tsx" type="module"></script>
<style>
:root {
--x-init-fg: hsl(0 0% 10%);
--x-init-body-fg: hsl(0 0% 10%);
--x-init-body-bg: hsl(0 0% 90%);
--x-init-loading-bg: hsl(0 0% 90%);
--x-init-loading-fg: hsl(0 0% 10%);
}
body {
gap: var(--x-init-gutter);
margin: 0;
background: var(--x-init-body-bg);
padding: 0;
color: var(--x-init-body-fg);
line-height: 1.5;
}
[data-theme="dark"] {
--x-init-fg: hsl(0 0% 90%);
--x-init-body-fg: hsl(0 0% 90%);
--x-init-body-bg: hsl(0 0% 0%);
--x-init-loading-bg: hsl(0 0% 0%);
--x-init-loading-fg: hsl(0 0% 90%);
}
#loading {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5em;
height: 100svh;
font-family: monospace;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
#loading::before {
animation: spin 1s linear infinite;
box-sizing: border-box;
border: 1px solid var(--x-init-loading-bg);
border-top-color: var(--x-init-loading-fg);
border-radius: 50%;
width: 16px;
height: 16px;
content: "";
}
</style>
body {
gap: var(--x-init-gutter);
margin: 0;
background: var(--x-init-body-bg);
padding: 0;
color: var(--x-init-body-fg);
line-height: 1.5;
}
#loading {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5em;
height: 100svh;
font-family: monospace;
}
#loading::before {
animation: spin 1s linear infinite;
box-sizing: border-box;
border: 1px solid var(--x-init-loading-bg);
border-top-color: var(--x-init-loading-fg);
border-radius: 50%;
width: 16px;
height: 16px;
content: "";
}
</style>
</head>
<body>
<div id="loading">Loading...</div>
<div id="loading">Loading...</div>
</body>
</html>
@@ -1,4 +1,4 @@
import "@/Components/ColorScheme/components/config.scss";
import "@/Components/Theme/components/config.scss";
import { type FC, useEffect, useRef, useState } from "react";
import { serverFetch } from "@/Components/Fetch/server-fetch.ts";
import { Footer } from "@/Components/Footer/components/index.tsx";
@@ -11,6 +11,7 @@ import { Modules } from "@/Components/Module/components/index.tsx";
import { Nav } from "@/Components/Nav/components/index.tsx";
import { usePollStore } from "@/Components/Poll/components/store.ts";
import { OK } from "@/Components/Rest/http-status.ts";
import { Theme } from "@/Components/Theme/components/index.tsx";
import { useToastStore } from "@/Components/Toast/components/store.ts";
import { useUpdaterStore } from "@/Components/Updater/components/store.ts";
import { useInterval } from "@/Components/Utils/components/use-interval.ts";
@@ -23,11 +24,7 @@ export const Bootstrap: FC = () => {
const isFetchingRef = useRef(false);
const openToast = useToastStore((s) => s.open);
const isUpdating = useUpdaterStore((s) => s.isUpdating);
// 轮询间隔控制
const pollDelay = isUpdating ? null : TIMER;
// 核心数据分发逻辑
const fetchPollData = async () => {
if (isUpdating || isFetchingRef.current) {
return;
@@ -52,12 +49,10 @@ export const Bootstrap: FC = () => {
}
};
// 优化点:进入页面立刻执行一次,避免 useInterval 产生 2 秒的白屏等待时间
useEffect(() => {
fetchPollData();
}, []);
// 启动后续定时轮询
useInterval(async () => {
await fetchPollData();
}, pollDelay);
@@ -69,6 +64,7 @@ export const Bootstrap: FC = () => {
return (
<>
<Header />
<Theme />
<Modules />
<Footer />
<Nav />
@@ -1,25 +0,0 @@
.main {
display: flex;
justify-content: center;
margin: 0 0 calc(var(--x-gutter) * 2) 0;
}
.link {
position: relative;
flex: 0 0 calc(var(--x-gutter) * 2);
height: var(--x-gutter);
&:first-child {
border-top-left-radius: var(--x-radius);
border-bottom-left-radius: var(--x-radius);
}
&:last-child {
border-top-right-radius: var(--x-radius);
border-bottom-right-radius: var(--x-radius);
}
& + & {
margin-left: 1px;
}
&:hover {
transform: scale3d(1.5, 1.5, 1);
z-index: 1;
}
}
@@ -1,19 +0,0 @@
// import { observer } from 'mobx-react-lite';
// import type { FC } from 'react';
// import { ColorSchemeStore } from '../stores';
// import { colorSchemes } from '../stores/color-schemes';
// import styles from './index.module.scss';
// export const ColorScheme: FC = observer(() => (
// <div className={styles.main}>
// {Object.entries(colorSchemes).map(([schemeId, { name, color }]) => (
// <a
// className={styles.link}
// data-active={schemeId === ColorSchemeStore.schemeId || undefined}
// key={schemeId}
// onClick={() => ColorSchemeStore.setSchemeId(schemeId)}
// style={{ background: color }}
// title={name}
// />
// ))}
// </div>
// ));
-5
View File
@@ -1,5 +0,0 @@
class Main {
readonly id = 'colorScheme';
readonly storageId = 'schemeId';
}
export const ColorSchemeConstants = new Main();
@@ -1,103 +0,0 @@
import { darken, lighten, rgba } from 'polished';
import { gettext } from '@/Components/Language/index.ts';
import type { ColorSchemeProps } from '../typings';const light = '#ccc';
const dark = '#000';
const topDarkBottomLight = 'linear-gradient(#000, #111)';
export const colorSchemeDark: ColorSchemeProps = {
name: gettext('Dark'),
isDark: true,
color: `linear-gradient(${rgba(light, 0.3)}, ${rgba(dark, 0.9)})`,
fg: dark,
bg: light,
'selection.fg': light,
'selection.bg': rgba(dark, 0.95),
'html.bg': dark,
'body.fg': light,
'body.bg': dark,
'a.fg': light,
'app.border': dark,
'app.fg': light,
'app.bg': '#222',
'title.fg': light,
'title.bg': dark,
'title.boxShadow': `0 1px 0 ${rgba(light, 0.1)}`,
'sysLoad.fg': light,
'sysLoad.bg': dark,
'card.border': rgba('#000', 0.5),
'card.fg': light,
'card.bg': '#333',
'card.hover.bg': `linear-gradient(to right, transparent, ${rgba(
'#000',
0.5
)}, transparent)`,
'card.boxShadow': 'inset 0 0 0 1px #000',
'card.legend.fg': light,
'card.legend.bg': topDarkBottomLight,
'card.des.fg': light,
'card.des.bg': rgba('#000', 0.3),
'card.title.fg': light,
'progress.fg': light,
'progress.bg': topDarkBottomLight,
'progress.value.fg': light,
'progress.value.bg': '#0c0',
'progress.value.after.bg': `linear-gradient(${[
rgba('#fff', 0.2),
'transparent',
].join(',')})`,
'progress.value.before.bg': `linear-gradient(to right, ${[
rgba('#fff', 0.1),
rgba('#fff', 0.2),
rgba('#fff', 0.1),
].join(',')})`,
'network.stats.upload': lighten(0.2, '#c24b00'),
'network.stats.download': lighten(0.2, '#007400'),
'network.node.fg': light,
'network.node.bg': '#252525',
'network.node.border': dark,
'network.node.row.bg': `linear-gradient(to right, ${[
'transparent',
rgba('#000', 0.5),
'transparent',
].join(',')})`,
'ping.button.fg': light,
'ping.button.bg': dark,
'ping.result.fg': light,
'ping.result.bg': dark,
'status.success.fg': light,
'status.success.bg': `linear-gradient(${darken(0.25, '#00e800')}, ${darken(
0.2,
'#00e800'
)})`,
'status.error.fg': light,
'status.error.bg': `linear-gradient(${darken(0.45, '#b9b9b9')}, ${darken(
0.4,
'#b9b9b9'
)})`,
'search.fg': light,
'search.bg': rgba(dark, 0.1),
'search.hover.fg': light,
'search.hover.bg': rgba(dark, 0.3),
'benchmark.ruby.fg': dark,
'benchmark.ruby.bg': rgba(dark, 0.1),
'footer.fg': light,
'footer.bg': dark,
'nav.fg': light,
'nav.bg': dark,
'nav.hover.fg': light,
'nav.hover.bg': `linear-gradient(${[
lighten(0.15, dark),
lighten(0.05, dark),
].join(',')})`,
'nav.active.fg': light,
'nav.active.bg': `linear-gradient(${[
lighten(0.3, dark),
lighten(0.1, dark),
].join(',')})`,
'nav.border': rgba(light, 0.1),
'starMe.fg': darken(0.1, light),
'starMe.bg': dark,
'starMe.hover.fg': light,
'starMe.hover.bg': dark,
'toast.fg': light,
'toast.bg': dark,
};
@@ -1,93 +0,0 @@
import { darken, lighten, rgba } from 'polished';
import { gettext } from '@/Components/Language/index.ts';
import type { ColorSchemeProps } from '../typings';const light = '#f8f8f8';
const dark = '#333';
const topDarkBottomLight = `linear-gradient(#282828, ${lighten(0.05, dark)})`;
export const colorSchemeDefault: ColorSchemeProps = {
name: gettext('Default'),
isDark: false,
color: `linear-gradient(${rgba(dark, 0.9)}, ${rgba(light, 0.5)})`,
fg: dark,
bg: light,
'selection.fg': light,
'selection.bg': rgba(dark, 0.95),
'html.bg': dark,
'body.fg': dark,
'body.bg': dark,
'a.fg': dark,
'app.border': dark,
'app.fg': dark,
'app.bg': light,
'title.fg': light,
'title.bg': dark,
'title.boxShadow': '0 1px 0 #000',
'sysLoad.fg': light,
'sysLoad.bg': dark,
'card.border': rgba(dark, 0.1),
'card.fg': dark,
'card.bg': rgba(dark, 0.03),
'card.hover.bg': `linear-gradient(to right, transparent, ${rgba(
'#000',
0.1
)}, transparent)`,
'card.boxShadow': [
`0 -1px 0 ${rgba(dark, 0.3)}`,
'inset 0 1px 0 #fff',
`inset 0 -1px 0 ${rgba(dark, 0.3)}`,
'0 1px 0 #fff',
].join(','),
'card.legend.fg': light,
'card.legend.bg': topDarkBottomLight,
'card.des.fg': dark,
'card.des.bg': rgba(dark, 0.1),
'card.title.fg': dark,
'progress.fg': light,
'progress.bg': topDarkBottomLight,
'progress.value.fg': light,
'progress.value.bg': '#0c0',
'progress.value.after.bg': `linear-gradient(${[
rgba('#fff', 0.45),
'transparent',
].join(',')})`,
'progress.value.before.bg': `linear-gradient(to right, ${[
rgba('#fff', 0.1),
rgba('#fff', 0.95),
rgba('#fff', 0.1),
].join(',')})`,
'network.stats.upload': '#c24b00',
'network.stats.download': '#007400',
'network.node.fg': dark,
'network.node.bg': '#373c381a',
'network.node.border': '#373c381a',
'network.node.row.bg':
'linear-gradient(to right, transparent, #ffffffe6, transparent)',
'ping.button.fg': light,
'ping.button.bg': dark,
'ping.result.fg': light,
'ping.result.bg': dark,
'status.success.fg': light,
'status.success.bg': `linear-gradient(${darken(0.15, '#00e800')}, #00e800)`,
'status.error.fg': light,
'status.error.bg': `linear-gradient(${darken(0.25, '#b9b9b9')}, #b9b9b9)`,
'search.fg': dark,
'search.bg': rgba(dark, 0.1),
'search.hover.fg': light,
'search.hover.bg': rgba(dark, 0.3),
'benchmark.ruby.fg': dark,
'benchmark.ruby.bg': rgba(dark, 0.1),
'footer.fg': light,
'footer.bg': dark,
'nav.fg': light,
'nav.bg': dark,
'nav.hover.fg': light,
'nav.hover.bg': `linear-gradient(${rgba(light, 0.25)}, ${rgba(light, 0.1)})`,
'nav.active.fg': dark,
'nav.active.bg': `linear-gradient(${light}, ${darken(0.15, light)})`,
'nav.border': rgba(light, 0.1),
'starMe.fg': darken(0.1, light),
'starMe.bg': dark,
'starMe.hover.fg': light,
'starMe.hover.bg': dark,
'toast.fg': light,
'toast.bg': dark,
};
@@ -1,7 +0,0 @@
import type { ColorSchemesProps } from '../typings';
import { colorSchemeDark } from './color-scheme-dark';
import { colorSchemeDefault } from './color-scheme-default';
export const colorSchemes: ColorSchemesProps = {
default: colorSchemeDefault,
dark: colorSchemeDark,
};
@@ -1,26 +0,0 @@
import { configure, makeAutoObservable } from 'mobx';
import { ColorSchemeConstants } from '../constants';
import { colorSchemes } from './color-schemes';configure({
enforceActions: 'observed',
});
const { storageId } = ColorSchemeConstants;
class Main {
schemeId: string = this.getStorageSchemeId();
constructor() {
makeAutoObservable(this);
}
setSchemeId = (schemeId: string) => {
this.schemeId = schemeId;
this.setStorageSchemeId(schemeId);
};
get scheme() {
return colorSchemes?.[this.schemeId] ?? colorSchemes.default;
}
private getStorageSchemeId(): string {
return localStorage.getItem(storageId) || 'default';
}
private setStorageSchemeId = (schemeId: string) => {
localStorage.setItem(storageId, schemeId);
};
}
export const ColorSchemeStore = new Main();
@@ -1,75 +0,0 @@
export interface ColorSchemeProps {
name: string;
isDark: boolean;
color: string;
fg: string;
bg: string;
'selection.fg': string;
'selection.bg': string;
'html.bg': string;
'body.fg': string;
'body.bg': string;
'a.fg': string;
'app.border': string;
'app.fg': string;
'app.bg': string;
'title.fg': string;
'title.bg': string;
'title.boxShadow': string;
'sysLoad.fg': string;
'sysLoad.bg': string;
'card.border': string;
'card.fg': string;
'card.bg': string;
'card.hover.bg': string;
'card.boxShadow': string;
'card.legend.fg': string;
'card.legend.bg': string;
'card.des.fg': string;
'card.des.bg': string;
'card.title.fg': string;
'progress.fg': string;
'progress.bg': string;
'progress.value.fg': string;
'progress.value.bg': string;
'progress.value.after.bg': string;
'progress.value.before.bg': string;
'network.stats.upload': string;
'network.stats.download': string;
'network.node.fg': string;
'network.node.bg': string;
'network.node.border': string;
'network.node.row.bg': string;
'ping.button.fg': string;
'ping.button.bg': string;
'ping.result.fg': string;
'ping.result.bg': string;
'status.success.fg': string;
'status.success.bg': string;
'status.error.fg': string;
'status.error.bg': string;
'search.fg': string;
'search.bg': string;
'search.hover.fg': string;
'search.hover.bg': string;
'benchmark.ruby.fg': string;
'benchmark.ruby.bg': string;
'footer.fg': string;
'footer.bg': string;
'nav.fg': string;
'nav.bg': string;
'nav.hover.fg': string;
'nav.hover.bg': string;
'nav.active.fg': string;
'nav.active.bg': string;
'nav.border': string;
'starMe.fg': string;
'starMe.bg': string;
'starMe.hover.fg': string;
'starMe.hover.bg': string;
'toast.fg': string;
'toast.bg': string;
}
export interface ColorSchemesProps {
[name: string]: ColorSchemeProps;
}
@@ -0,0 +1,14 @@
import { gettext } from "@/Components/Language/index.ts";
export const themes = [
{
color: { a: 0.9, c: 0, h: 0, l: 30 },
id: "dark",
name: gettext("LiangYe"),
},
{
color: { a: 0.9, c: 0, h: 0, l: 80 },
id: "light",
name: gettext("XiGuang"),
},
] as const;
@@ -0,0 +1,45 @@
.main {
display: flex;
justify-content: center;
}
.button {
all: unset;
cursor: pointer;
background: var(--x-theme-button-bg);
padding: 0 var(--x-gutter);
color: oklch(0% 0 0 / 0.8);
// light
&:hover {
filter: brightness(1.1);
}
&:active {
filter: brightness(0.9);
}
&[data-active] {
transform: scale(1.25);
z-index: 1;
}
&:first-child {
border-top-left-radius: 10rem;
border-bottom-left-radius: 10rem;
}
&:last-child {
border-top-right-radius: 10rem;
border-bottom-right-radius: 10rem;
}
// dark
&[data-is-dark] {
color: oklch(100% 0 0 / 0.8);
&:hover {
filter: brightness(1.5);
}
&:active {
filter: brightness(0.9);
}
&[data-active] {
// box-shadow: 0 0 0 2px oklch(100% 0 0 / 0.8);
pointer-events: none;
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import { type FC, useCallback, useState } from "react";
import {
isBrightOklch,
toOklchString,
} from "@/Components/Utils/components/oklch/index.ts";
import { WindowConfig } from "@/Components/WindowConfig/components/index.ts";
import { themes } from "./constants.ts";
import styles from "./index.module.scss";
export const Theme: FC = () => {
const [theme, setTheme] = useState<string>(WindowConfig.THEME);
const handleActiveTheme = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
const { id } = e.currentTarget.dataset;
if (!id) {
return;
}
setTheme(id);
document.documentElement.setAttribute("data-theme", id);
localStorage.setItem("x-theme", id);
},
[],
);
return (
<div className={styles.main}>
{themes.map(({ id, name, color }) => (
<button
className={styles.button}
data-active={id === theme || undefined}
data-id={id}
data-is-dark={!isBrightOklch(color) || undefined}
key={id}
onClick={handleActiveTheme}
style={{
"--x-theme-button-bg": toOklchString(color),
} as React.CSSProperties}
title={name}
type="button"
>
{name}
</button>
))}
</div>
);
};
@@ -0,0 +1,24 @@
import type { OklchObject } from "./types.ts";
export function toNormalizedOklch(oklch: OklchObject): OklchObject {
const { l, c, h, a } = oklch;
const normalizedL = l > 1 ? l : l * 100;
return {
a,
c,
h,
l: normalizedL,
};
}
export function toOklchString(oklch: OklchObject): string {
const { l, c, h, a } = toNormalizedOklch(oklch);
return `oklch(${l}% ${c} ${h} / ${a})`;
}
export function isBrightOklch(oklch: OklchObject, threshold = 65): boolean {
const { l, a } = toNormalizedOklch(oklch);
if (a < 0.3) {
return true;
}
return l >= threshold;
}
@@ -0,0 +1,19 @@
import z from "zod";
type Percentage = `${number}%`;
type Decimals = `${number}`; // 用于 Chroma (0-0.4) 或 Alpha (0-1)
type Hue = `${number}`; // Hue 角度 (0-360)
export type OklchColor =
| `oklch(${Percentage} ${Decimals} ${Hue})`
| `oklch(${Percentage} ${Decimals} ${Hue} / ${Percentage})`
| `oklch(${Percentage} ${Decimals} ${Hue} / ${Decimals})`;
export const OklchObjectSchema = z.strictObject({
a: z.number().min(0).max(1),
c: z.number().min(0).max(0.4),
h: z.number().min(0).max(360),
l: z.number().min(0).max(100),
});
export type OklchObject = z.infer<typeof OklchObjectSchema>;
@@ -6,4 +6,7 @@ export const WindowConfig = {
IS_DEV: Boolean(
(window as unknown as WindowProps)?.GLOBAL_CONFIG?.IS_DEV ?? false
),
THEME: String(
(window as unknown as WindowProps)?.GLOBAL_CONFIG?.THEME ?? "light"
),
} as const satisfies WindowConfigProps;
@@ -1,6 +1,7 @@
export type WindowConfigProps = {
IS_DEV: boolean;
AUTHORIZATION: string;
THEME: string;
};
export type WindowProps = {
GLOBAL_CONFIG: WindowConfigProps;