diff --git a/package.json b/package.json index b7bc352..2809dd6 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "axios": "^1.5.0", "bcryptjs": "^2.4.3", "clsx": "^2.0.0", + "colord": "^2.9.3", "compose-middleware": "^5.0.1", "compression": "^1.7.4", "dayjs": "^1.11.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a330e93..40a182d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ dependencies: clsx: specifier: ^2.0.0 version: 2.0.0 + colord: + specifier: ^2.9.3 + version: 2.9.3 compose-middleware: specifier: ^5.0.1 version: 5.0.1 @@ -2519,6 +2522,10 @@ packages: color-string: 1.9.1 dev: false + /colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + dev: false + /combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} diff --git a/src/client/components/WebsiteOverview.tsx b/src/client/components/WebsiteOverview.tsx index 3b624f9..2ad4749 100644 --- a/src/client/components/WebsiteOverview.tsx +++ b/src/client/components/WebsiteOverview.tsx @@ -22,6 +22,7 @@ import { import { useEvent } from '../hooks/useEvent'; import { MetricCard } from './MetricCard'; import { formatNumber, formatShortTime } from '../utils/common'; +import { useTheme } from '../hooks/useTheme'; interface WebsiteOverviewProps { workspaceId: string; @@ -212,31 +213,35 @@ export const StatsChart: React.FC<{ data: { x: string; y: number; type: string }[]; unit: DateUnit; }> = React.memo((props) => { - const config: ColumnConfig = useMemo( - () => ({ - data: props.data, - isStack: true, - xField: 'x', - yField: 'y', - seriesField: 'type', - label: { - position: 'middle' as const, - style: { - fill: '#FFFFFF', - opacity: 0.6, - }, - }, - tooltip: { - title: (t) => formatDate(t), - }, - xAxis: { + const { colors } = useTheme(); + + const config = useMemo( + () => + ({ + data: props.data, + isStack: true, + xField: 'x', + yField: 'y', + seriesField: 'type', label: { - autoHide: true, - autoRotate: false, - formatter: (text) => formatDateWithUnit(text, props.unit), + position: 'middle' as const, + style: { + fill: '#FFFFFF', + opacity: 0.6, + }, }, - }, - }), + tooltip: { + title: (t) => formatDate(t), + }, + color: [colors.chart.pv, colors.chart.uv], + xAxis: { + label: { + autoHide: true, + autoRotate: false, + formatter: (text) => formatDateWithUnit(text, props.unit), + }, + }, + } as ColumnConfig), [props.data, props.unit] ); diff --git a/src/client/hooks/useTheme.ts b/src/client/hooks/useTheme.ts new file mode 100644 index 0000000..98dcc20 --- /dev/null +++ b/src/client/hooks/useTheme.ts @@ -0,0 +1,97 @@ +import { useEffect, useMemo } from 'react'; +import { colord } from 'colord'; + +const THEME_CONFIG = 'tianji.theme'; + +const THEME_COLORS = { + light: { + primary: '#2680eb', + gray50: '#ffffff', + gray75: '#fafafa', + gray100: '#f5f5f5', + gray200: '#eaeaea', + gray300: '#e1e1e1', + gray400: '#cacaca', + gray500: '#b3b3b3', + gray600: '#8e8e8e', + gray700: '#6e6e6e', + gray800: '#4b4b4b', + gray900: '#2c2c2c', + }, + dark: { + primary: '#2680eb', + gray50: '#252525', + gray75: '#2f2f2f', + gray100: '#323232', + gray200: '#3e3e3e', + gray300: '#4a4a4a', + gray400: '#5a5a5a', + gray500: '#6e6e6e', + gray600: '#909090', + gray700: '#b9b9b9', + gray800: '#e3e3e3', + gray900: '#ffffff', + }, +}; + +type ValidTheme = keyof typeof THEME_COLORS; + +function isValidTheme(theme: string | null): theme is ValidTheme { + if (!theme) { + return false; + } + return ['light', 'dark'].includes(theme); +} + +export function useTheme() { + const defaultTheme: ValidTheme = + typeof window !== 'undefined' + ? window?.matchMedia('(prefers-color-scheme: dark)')?.matches + ? 'dark' + : 'light' + : 'light'; + const customTheme = window.localStorage.getItem(THEME_CONFIG); + const theme = isValidTheme(customTheme) ? customTheme : defaultTheme; + + const primaryColor = useMemo(() => colord(THEME_COLORS[theme].primary), []); + + const colors = useMemo( + () => ({ + theme: { + ...THEME_COLORS[theme], + }, + chart: { + text: THEME_COLORS[theme].gray700, + line: THEME_COLORS[theme].gray200, + pv: primaryColor.alpha(0.4).toRgbString(), + uv: primaryColor.alpha(0.6).toRgbString(), + }, + map: { + baseColor: THEME_COLORS[theme].primary, + fillColor: THEME_COLORS[theme].gray100, + strokeColor: THEME_COLORS[theme].primary, + hoverColor: THEME_COLORS[theme].primary, + }, + }), + [] + ); + + function saveTheme(value: string) { + window.localStorage.setItem(THEME_CONFIG, value); + } + + useEffect(() => { + document.body.setAttribute('data-theme', theme); + }, [theme]); + + useEffect(() => { + const url = new URL(window?.location?.href); + const theme = url.searchParams.get('theme'); + + if (theme && ['light', 'dark'].includes(theme)) { + saveTheme(theme); + } + }, []); + + return { theme, saveTheme, colors }; +}