feat(portal): auto-switch light/dark theme via prefers-color-scheme with top-right toggle
This commit is contained in:
1 parent
eae4596aa3
commit
4ca86da55f
9 files changed
+190
-150
No files matched your search
@@ -133,32 +133,25 @@ export const CommandPalette: React.FC = () => {
|
|||||||
run: () => copyCmd('ssh mini -t "tmux attach -t agy"', '接力 AGY'),
|
run: () => copyCmd('ssh mini -t "tmux attach -t agy"', '接力 AGY'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'theme-light',
|
id: 'theme-system',
|
||||||
title: '切换主题: 浅色纯净 (Shadcn Light)',
|
title: '主题模式: 跟随设备系统深浅色自动切换',
|
||||||
hint: 'zinc-light',
|
hint: 'system',
|
||||||
type: '主题',
|
type: '主题',
|
||||||
run: () => setTheme('zinc-light'),
|
run: () => setTheme('system'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-light',
|
||||||
|
title: '切换主题: 浅色纯净 (Light)',
|
||||||
|
hint: 'light',
|
||||||
|
type: '主题',
|
||||||
|
run: () => setTheme('light'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'theme-dark',
|
id: 'theme-dark',
|
||||||
title: '切换主题: 深色极客 (Shadcn Dark)',
|
title: '切换主题: 深色极客 (Dark)',
|
||||||
hint: 'zinc-dark',
|
hint: 'dark',
|
||||||
type: '主题',
|
type: '主题',
|
||||||
run: () => setTheme('zinc-dark'),
|
run: () => setTheme('dark'),
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'theme-cat',
|
|
||||||
title: '切换主题: Catppuccin Mocha',
|
|
||||||
hint: 'catppuccin',
|
|
||||||
type: '主题',
|
|
||||||
run: () => setTheme('catppuccin'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'theme-nord',
|
|
||||||
title: '切换主题: Nord 极地霜蓝',
|
|
||||||
hint: 'nord',
|
|
||||||
type: '主题',
|
|
||||||
run: () => setTheme('nord'),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [getEffectiveMode, setShowAgyModal, setTheme, triggerAction, copyCmd]);
|
}, [getEffectiveMode, setShowAgyModal, setTheme, triggerAction, copyCmd]);
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Search, Terminal } from 'lucide-react';
|
import { Moon, Search, Sun, Terminal } from 'lucide-react';
|
||||||
import { usePortalStore } from '../store/usePortalStore';
|
import { usePortalStore } from '../store/usePortalStore';
|
||||||
import type { RouteModeId, ThemeId } from '../types/telemetry';
|
import type { RouteModeId } from '../types/telemetry';
|
||||||
|
|
||||||
const THEMES: Array<{ id: ThemeId; label: string; dot: string }> = [
|
|
||||||
{ id: 'zinc-dark', label: '深色极客', dot: '#18181b' },
|
|
||||||
{ id: 'zinc-light', label: '浅色纯净', dot: '#f8fafc' },
|
|
||||||
{ id: 'catppuccin', label: 'Catppuccin', dot: '#cba6f7' },
|
|
||||||
{ id: 'nord', label: 'Nord', dot: '#88c0d0' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const MODES: Array<{ id: RouteModeId; label: string }> = [
|
const MODES: Array<{ id: RouteModeId; label: string }> = [
|
||||||
{ id: 'auto', label: '智能路由' },
|
{ id: 'auto', label: '智能路由' },
|
||||||
@@ -20,9 +13,11 @@ const MODES: Array<{ id: RouteModeId; label: string }> = [
|
|||||||
export const TopRibbon: React.FC = () => {
|
export const TopRibbon: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
theme,
|
theme,
|
||||||
|
resolvedTheme,
|
||||||
routeMode,
|
routeMode,
|
||||||
telemetry,
|
telemetry,
|
||||||
setTheme,
|
setTheme,
|
||||||
|
toggleTheme,
|
||||||
setRouteMode,
|
setRouteMode,
|
||||||
setShowAgyModal,
|
setShowAgyModal,
|
||||||
setShowCmdPalette,
|
setShowCmdPalette,
|
||||||
@@ -100,7 +95,7 @@ export const TopRibbon: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Search (Cmd+K), Route Switcher, Theme Switcher, Terminal Button */}
|
{/* Right: Search (Cmd+K), Route Switcher, AGY Terminal, Top-Right Theme Switcher */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCmdPalette(true)}
|
onClick={() => setShowCmdPalette(true)}
|
||||||
@@ -109,7 +104,7 @@ export const TopRibbon: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}>
|
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}>
|
||||||
<Search className="w-3.5 h-3.5" />
|
<Search className="w-3.5 h-3.5" />
|
||||||
<span className="hidden sm:inline">搜索或执行指令...</span>
|
<span className="hidden sm:inline">搜索...</span>
|
||||||
</span>
|
</span>
|
||||||
<kbd
|
<kbd
|
||||||
className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted"
|
className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted"
|
||||||
@@ -140,32 +135,6 @@ export const TopRibbon: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs">
|
|
||||||
{THEMES.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
onClick={() => setTheme(t.id)}
|
|
||||||
className="px-2 py-1 rounded-md font-medium transition-all flex items-center gap-1.5 cursor-pointer"
|
|
||||||
style={
|
|
||||||
theme === t.id
|
|
||||||
? {
|
|
||||||
backgroundColor: 'var(--bg-card)',
|
|
||||||
color: 'var(--text-main)',
|
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
|
||||||
}
|
|
||||||
: { color: 'var(--text-muted)' }
|
|
||||||
}
|
|
||||||
title={t.label}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="w-2 h-2 rounded-full border border-black/10"
|
|
||||||
style={{ backgroundColor: t.dot }}
|
|
||||||
/>
|
|
||||||
<span className="hidden lg:inline">{t.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAgyModal(true)}
|
onClick={() => setShowAgyModal(true)}
|
||||||
className="hl-btn-primary px-3 py-1.5 text-xs flex items-center gap-1.5 cursor-pointer"
|
className="hl-btn-primary px-3 py-1.5 text-xs flex items-center gap-1.5 cursor-pointer"
|
||||||
@@ -173,6 +142,42 @@ export const TopRibbon: React.FC = () => {
|
|||||||
<Terminal className="w-3.5 h-3.5" />
|
<Terminal className="w-3.5 h-3.5" />
|
||||||
<span>AGY 终端</span>
|
<span>AGY 终端</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Top-Right Mainstream Theme Switcher (Auto System + Light/Dark 2 Mainstream Themes) */}
|
||||||
|
<div className="flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs">
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="px-2.5 py-1 rounded-md font-medium transition-all flex items-center gap-1.5 cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
||||||
|
}}
|
||||||
|
title="点击切换深色 / 浅色主题"
|
||||||
|
>
|
||||||
|
{resolvedTheme === 'dark' ? (
|
||||||
|
<>
|
||||||
|
<Moon className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">深色</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sun className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">浅色</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{theme !== 'system' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme('system')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] transition-all cursor-pointer"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
title="恢复跟随设备系统深浅色自动切换"
|
||||||
|
>
|
||||||
|
自动
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RouteModeId, TelemetryData, ThemeId } from '../types/telemetry';
|
|||||||
|
|
||||||
interface PortalState {
|
interface PortalState {
|
||||||
theme: ThemeId;
|
theme: ThemeId;
|
||||||
|
resolvedTheme: 'light' | 'dark';
|
||||||
routeMode: RouteModeId;
|
routeMode: RouteModeId;
|
||||||
showAgyModal: boolean;
|
showAgyModal: boolean;
|
||||||
showCmdPalette: boolean;
|
showCmdPalette: boolean;
|
||||||
@@ -10,6 +11,7 @@ interface PortalState {
|
|||||||
actionLoading: boolean;
|
actionLoading: boolean;
|
||||||
telemetry: TelemetryData;
|
telemetry: TelemetryData;
|
||||||
setTheme: (theme: ThemeId) => void;
|
setTheme: (theme: ThemeId) => void;
|
||||||
|
toggleTheme: () => void;
|
||||||
setRouteMode: (mode: RouteModeId) => void;
|
setRouteMode: (mode: RouteModeId) => void;
|
||||||
setShowAgyModal: (open: boolean) => void;
|
setShowAgyModal: (open: boolean) => void;
|
||||||
setShowCmdPalette: (open: boolean) => void;
|
setShowCmdPalette: (open: boolean) => void;
|
||||||
@@ -20,16 +22,31 @@ interface PortalState {
|
|||||||
getEffectiveMode: () => Exclude<RouteModeId, 'auto'>;
|
getEffectiveMode: () => Exclude<RouteModeId, 'auto'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialTheme = (localStorage.getItem('hl_theme') as ThemeId) || 'catppuccin';
|
const getSystemDark = () =>
|
||||||
const initialRouteMode = (localStorage.getItem('hl_route_mode') as RouteModeId) || 'auto';
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
// Apply theme immediately on load
|
const applyDomTheme = (mode: ThemeId): 'light' | 'dark' => {
|
||||||
if (typeof document !== 'undefined') {
|
const isDark = mode === 'system' ? getSystemDark() : mode === 'dark';
|
||||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
const domTheme = isDark ? 'catppuccin' : 'zinc-light';
|
||||||
}
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.setAttribute('data-theme', domTheme);
|
||||||
|
}
|
||||||
|
return isDark ? 'dark' : 'light';
|
||||||
|
};
|
||||||
|
|
||||||
|
const storedTheme = localStorage.getItem('hl_theme_v2') as ThemeId | null;
|
||||||
|
const initialTheme: ThemeId =
|
||||||
|
storedTheme === 'light' || storedTheme === 'dark' || storedTheme === 'system'
|
||||||
|
? storedTheme
|
||||||
|
: 'system';
|
||||||
|
const initialResolved = applyDomTheme(initialTheme);
|
||||||
|
const initialRouteMode = (localStorage.getItem('hl_route_mode') as RouteModeId) || 'auto';
|
||||||
|
|
||||||
export const usePortalStore = create<PortalState>((set, get) => ({
|
export const usePortalStore = create<PortalState>((set, get) => ({
|
||||||
theme: initialTheme,
|
theme: initialTheme,
|
||||||
|
resolvedTheme: initialResolved,
|
||||||
routeMode: initialRouteMode,
|
routeMode: initialRouteMode,
|
||||||
showAgyModal: false,
|
showAgyModal: false,
|
||||||
showCmdPalette: false,
|
showCmdPalette: false,
|
||||||
@@ -79,9 +96,14 @@ export const usePortalStore = create<PortalState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
localStorage.setItem('hl_theme', theme);
|
localStorage.setItem('hl_theme_v2', theme);
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
const resolvedTheme = applyDomTheme(theme);
|
||||||
set({ theme });
|
set({ theme, resolvedTheme });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTheme: () => {
|
||||||
|
const { resolvedTheme, setTheme } = get();
|
||||||
|
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||||
},
|
},
|
||||||
|
|
||||||
setRouteMode: (routeMode) => {
|
setRouteMode: (routeMode) => {
|
||||||
@@ -152,3 +174,13 @@ export const usePortalStore = create<PortalState>((set, get) => ({
|
|||||||
return 'edge443';
|
return 'edge443';
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Listen to device OS dark/light mode changes automatically
|
||||||
|
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
const state = usePortalStore.getState();
|
||||||
|
if (state.theme === 'system') {
|
||||||
|
state.setTheme('system');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ThemeId = 'zinc-dark' | 'zinc-light' | 'catppuccin' | 'nord';
|
export type ThemeId = 'system' | 'light' | 'dark';
|
||||||
export type RouteModeId = 'auto' | 'edge443' | 'home10443' | 'lan';
|
export type RouteModeId = 'auto' | 'edge443' | 'home10443' | 'lan';
|
||||||
|
|
||||||
export interface TelemetryData {
|
export interface TelemetryData {
|
||||||
|
|||||||
Reference in new issue
Block a user