Add selectable color themes with a global appearance switcher
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Moon, Palette, Sun, Square, Radius } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { COLOR_THEMES, useUIStore, type ColorTheme, type Radius as RadiusValue, type Theme } from '@/stores/uiStore';
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { t } = useTranslation();
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
const setTheme = useUIStore((s) => s.setTheme);
|
||||
const colorTheme = useUIStore((s) => s.colorTheme);
|
||||
const setColorTheme = useUIStore((s) => s.setColorTheme);
|
||||
const radius = useUIStore((s) => s.radius);
|
||||
const setRadius = useUIStore((s) => s.setRadius);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={t('appearance.label', 'Appearance')}>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel>{t('appearance.mode', 'Mode')}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={theme} onValueChange={(value) => setTheme(value as Theme)}>
|
||||
<DropdownMenuRadioItem value="light">
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
{t('appearance.light', 'Light')}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark">
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
{t('appearance.dark', 'Dark')}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t('appearance.colorTheme', 'Color theme')}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={colorTheme} onValueChange={(value) => setColorTheme(value as ColorTheme)}>
|
||||
{COLOR_THEMES.map((entry) => (
|
||||
<DropdownMenuRadioItem key={entry.value} value={entry.value}>
|
||||
<span
|
||||
className="mr-2 h-3.5 w-3.5 shrink-0 rounded-full border border-border"
|
||||
style={{ background: entry.swatch }}
|
||||
/>
|
||||
{t(entry.labelKey, entry.fallback)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t('appearance.corners', 'Corners')}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={radius} onValueChange={(value) => setRadius(value as RadiusValue)}>
|
||||
<DropdownMenuRadioItem value="rounded">
|
||||
<Radius className="mr-2 h-4 w-4" />
|
||||
{t('appearance.rounded', 'Rounded')}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="square">
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
{t('appearance.square', 'Square')}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search, Radius } = LucideIcons;
|
||||
const { User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CommandPalette } from '@/components/common/CommandPalette';
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import Logo from '@/components/common/Logo';
|
||||
import { ThemeSwitcher } from '@/components/common/ThemeSwitcher';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
|
||||
@@ -41,10 +42,6 @@ function getIcon(name: string): LucideIcons.LucideIcon {
|
||||
export function TopBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
const toggleTheme = useUIStore((s) => s.toggleTheme);
|
||||
const radius = useUIStore((s) => s.radius);
|
||||
const toggleRadius = useUIStore((s) => s.toggleRadius);
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||
const accounts = useAuthStore((s) => s.accounts);
|
||||
@@ -125,9 +122,7 @@ export function TopBar() {
|
||||
|
||||
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
|
||||
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||
</Button>
|
||||
<ThemeSwitcher />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -191,13 +186,6 @@ export function TopBar() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={toggleRadius}>
|
||||
<Radius className="mr-2 h-4 w-4" />
|
||||
{t('squareCorners', 'Square corners')}
|
||||
{radius === 'square' && <Check className="ml-auto h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
|
||||
+16
-2
@@ -1,5 +1,21 @@
|
||||
{
|
||||
"accounts": "Accounts",
|
||||
"appearance": {
|
||||
"colorTheme": "Color theme",
|
||||
"corners": "Corners",
|
||||
"dark": "Dark",
|
||||
"label": "Appearance",
|
||||
"light": "Light",
|
||||
"mode": "Mode",
|
||||
"rounded": "Rounded",
|
||||
"square": "Square",
|
||||
"theme": {
|
||||
"forest": "Forest",
|
||||
"ocean": "Ocean",
|
||||
"stalwart": "Stalwart",
|
||||
"violet": "Violet"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"actionFailed": "Action failed.",
|
||||
"backToActions": "Back to actions",
|
||||
@@ -337,8 +353,6 @@
|
||||
"periodValue": "Period value"
|
||||
},
|
||||
"sections": "Sections",
|
||||
"squareCorners": "Square corners",
|
||||
"toggleTheme": "Toggle theme",
|
||||
"tracing": {
|
||||
"addFilter": "Add filter",
|
||||
"bufferFull": "(buffer full)",
|
||||
|
||||
+124
@@ -96,6 +96,130 @@
|
||||
--radius: 0px;
|
||||
}
|
||||
|
||||
/* Color themes. Each theme re-hues the neutral tokens at the same lightness
|
||||
and chroma as the default palette (contrast is preserved) and replaces
|
||||
primary/ring with the theme color. The default "Stalwart" theme is the
|
||||
bare :root/.dark above and needs no data-theme attribute. Dark theme
|
||||
blocks must stay after their light counterpart: both selectors have the
|
||||
same specificity, so source order decides. */
|
||||
|
||||
:root[data-theme='ocean'] {
|
||||
--foreground: oklch(0.145 0.02 264);
|
||||
--content-background: oklch(0.965 0.008 264);
|
||||
--primary: oklch(0.546 0.215 262.9);
|
||||
--primary-foreground: oklch(0.985 0.002 264);
|
||||
--secondary: oklch(0.955 0.012 264);
|
||||
--secondary-foreground: oklch(0.3 0.035 264);
|
||||
--muted: oklch(0.935 0.01 264);
|
||||
--muted-foreground: oklch(0.556 0.022 264);
|
||||
--accent: oklch(0.94 0.018 264);
|
||||
--accent-foreground: oklch(0.3 0.035 264);
|
||||
--border: oklch(0.912 0.012 264);
|
||||
--input: oklch(0.912 0.012 264);
|
||||
--ring: oklch(0.546 0.215 262.9);
|
||||
--chart-1: 221 83% 53%;
|
||||
}
|
||||
|
||||
.dark[data-theme='ocean'] {
|
||||
--background: oklch(0.152 0.014 264);
|
||||
--foreground: oklch(0.96 0.008 264);
|
||||
--card: oklch(0.235 0.02 264);
|
||||
--card-foreground: oklch(0.96 0.008 264);
|
||||
--popover: oklch(0.208 0.018 264);
|
||||
--popover-foreground: oklch(0.96 0.008 264);
|
||||
--content-background: oklch(0.122 0.012 264);
|
||||
--primary: oklch(0.623 0.214 259.8);
|
||||
--primary-foreground: oklch(0.985 0.002 264);
|
||||
--secondary: oklch(0.28 0.022 264);
|
||||
--secondary-foreground: oklch(0.96 0.008 264);
|
||||
--muted: oklch(0.28 0.022 264);
|
||||
--muted-foreground: oklch(0.715 0.022 264);
|
||||
--accent: oklch(0.305 0.028 264);
|
||||
--accent-foreground: oklch(0.96 0.008 264);
|
||||
--border: oklch(0.29 0.024 264);
|
||||
--input: oklch(0.29 0.024 264);
|
||||
--ring: oklch(0.623 0.214 259.8);
|
||||
--chart-1: 217 91% 60%;
|
||||
}
|
||||
|
||||
:root[data-theme='forest'] {
|
||||
--foreground: oklch(0.145 0.02 150);
|
||||
--content-background: oklch(0.965 0.008 150);
|
||||
--primary: oklch(0.527 0.137 150.1);
|
||||
--primary-foreground: oklch(0.985 0.004 150);
|
||||
--secondary: oklch(0.955 0.012 150);
|
||||
--secondary-foreground: oklch(0.3 0.035 150);
|
||||
--muted: oklch(0.935 0.01 150);
|
||||
--muted-foreground: oklch(0.556 0.022 150);
|
||||
--accent: oklch(0.94 0.018 150);
|
||||
--accent-foreground: oklch(0.3 0.035 150);
|
||||
--border: oklch(0.912 0.012 150);
|
||||
--input: oklch(0.912 0.012 150);
|
||||
--ring: oklch(0.527 0.137 150.1);
|
||||
--chart-1: 142 71% 45%;
|
||||
}
|
||||
|
||||
.dark[data-theme='forest'] {
|
||||
--background: oklch(0.152 0.012 150);
|
||||
--foreground: oklch(0.96 0.008 150);
|
||||
--card: oklch(0.235 0.018 150);
|
||||
--card-foreground: oklch(0.96 0.008 150);
|
||||
--popover: oklch(0.208 0.016 150);
|
||||
--popover-foreground: oklch(0.96 0.008 150);
|
||||
--content-background: oklch(0.122 0.01 150);
|
||||
--primary: oklch(0.723 0.219 149.6);
|
||||
--primary-foreground: oklch(0.2 0.03 150);
|
||||
--secondary: oklch(0.28 0.02 150);
|
||||
--secondary-foreground: oklch(0.96 0.008 150);
|
||||
--muted: oklch(0.28 0.02 150);
|
||||
--muted-foreground: oklch(0.715 0.02 150);
|
||||
--accent: oklch(0.305 0.024 150);
|
||||
--accent-foreground: oklch(0.96 0.008 150);
|
||||
--border: oklch(0.29 0.022 150);
|
||||
--input: oklch(0.29 0.022 150);
|
||||
--ring: oklch(0.723 0.219 149.6);
|
||||
--chart-1: 142 69% 58%;
|
||||
}
|
||||
|
||||
:root[data-theme='violet'] {
|
||||
--foreground: oklch(0.145 0.02 290);
|
||||
--content-background: oklch(0.965 0.008 290);
|
||||
--primary: oklch(0.541 0.281 293);
|
||||
--primary-foreground: oklch(0.985 0.002 290);
|
||||
--secondary: oklch(0.955 0.012 290);
|
||||
--secondary-foreground: oklch(0.3 0.04 290);
|
||||
--muted: oklch(0.935 0.01 290);
|
||||
--muted-foreground: oklch(0.556 0.025 290);
|
||||
--accent: oklch(0.94 0.02 290);
|
||||
--accent-foreground: oklch(0.3 0.04 290);
|
||||
--border: oklch(0.912 0.014 290);
|
||||
--input: oklch(0.912 0.014 290);
|
||||
--ring: oklch(0.541 0.281 293);
|
||||
--chart-1: 262 83% 58%;
|
||||
}
|
||||
|
||||
.dark[data-theme='violet'] {
|
||||
--background: oklch(0.152 0.014 290);
|
||||
--foreground: oklch(0.96 0.008 290);
|
||||
--card: oklch(0.235 0.02 290);
|
||||
--card-foreground: oklch(0.96 0.008 290);
|
||||
--popover: oklch(0.208 0.018 290);
|
||||
--popover-foreground: oklch(0.96 0.008 290);
|
||||
--content-background: oklch(0.122 0.012 290);
|
||||
--primary: oklch(0.702 0.183 293.5);
|
||||
--primary-foreground: oklch(0.21 0.03 293);
|
||||
--secondary: oklch(0.28 0.024 290);
|
||||
--secondary-foreground: oklch(0.96 0.008 290);
|
||||
--muted: oklch(0.28 0.024 290);
|
||||
--muted-foreground: oklch(0.715 0.024 290);
|
||||
--accent: oklch(0.305 0.03 290);
|
||||
--accent-foreground: oklch(0.96 0.008 290);
|
||||
--border: oklch(0.29 0.026 290);
|
||||
--input: oklch(0.29 0.026 290);
|
||||
--ring: oklch(0.702 0.183 293.5);
|
||||
--chart-1: 263 70% 66%;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
+10
-9
@@ -20,18 +20,19 @@ import { getBasePath } from './lib/basePath';
|
||||
(() => {
|
||||
try {
|
||||
const persisted = localStorage.getItem('stalwart-ui');
|
||||
if (persisted) {
|
||||
const parsed = JSON.parse(persisted);
|
||||
const theme = parsed?.state?.theme;
|
||||
if (theme === 'dark' || theme === 'light') {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
return;
|
||||
}
|
||||
const state = persisted ? (JSON.parse(persisted)?.state ?? null) : null;
|
||||
const theme = state?.theme;
|
||||
const dark = theme === 'dark' || (theme !== 'light' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
const colorTheme = state?.colorTheme;
|
||||
if (colorTheme === 'ocean' || colorTheme === 'forest' || colorTheme === 'violet') {
|
||||
document.documentElement.dataset.theme = colorTheme;
|
||||
}
|
||||
if (state?.radius === 'square') {
|
||||
document.documentElement.dataset.radius = 'square';
|
||||
}
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.classList.toggle('dark', !!prefersDark);
|
||||
})();
|
||||
|
||||
const basePath = getBasePath();
|
||||
|
||||
+32
-14
@@ -7,18 +7,19 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
type Radius = 'rounded' | 'square';
|
||||
export type Theme = 'light' | 'dark';
|
||||
export type ColorTheme = 'stalwart' | 'ocean' | 'forest' | 'violet';
|
||||
export type Radius = 'rounded' | 'square';
|
||||
|
||||
interface UIState {
|
||||
theme: Theme;
|
||||
colorTheme: ColorTheme;
|
||||
radius: Radius;
|
||||
sidebarOpen: boolean;
|
||||
activeSection: string;
|
||||
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleRadius: () => void;
|
||||
setColorTheme: (colorTheme: ColorTheme) => void;
|
||||
setRadius: (radius: Radius) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
@@ -33,34 +34,49 @@ function applyThemeClass(theme: Theme) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyColorThemeAttribute(colorTheme: ColorTheme) {
|
||||
// The default theme is defined directly on :root/.dark, so it needs no attribute.
|
||||
if (colorTheme === 'stalwart') {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
} else {
|
||||
document.documentElement.dataset.theme = colorTheme;
|
||||
}
|
||||
}
|
||||
|
||||
function applyRadiusAttribute(radius: Radius) {
|
||||
document.documentElement.dataset.radius = radius;
|
||||
}
|
||||
|
||||
export const COLOR_THEMES: { value: ColorTheme; labelKey: string; fallback: string; swatch: string }[] = [
|
||||
{
|
||||
value: 'stalwart',
|
||||
labelKey: 'appearance.theme.stalwart',
|
||||
fallback: 'Stalwart',
|
||||
swatch: 'linear-gradient(135deg, oklch(0.205 0.017 285.823) 50%, oklch(0.92 0.007 285.823) 50%)',
|
||||
},
|
||||
{ value: 'ocean', labelKey: 'appearance.theme.ocean', fallback: 'Ocean', swatch: 'oklch(0.546 0.215 262.9)' },
|
||||
{ value: 'forest', labelKey: 'appearance.theme.forest', fallback: 'Forest', swatch: 'oklch(0.527 0.137 150.1)' },
|
||||
{ value: 'violet', labelKey: 'appearance.theme.violet', fallback: 'Violet', swatch: 'oklch(0.541 0.281 293)' },
|
||||
];
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme:
|
||||
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
|
||||
colorTheme: 'stalwart',
|
||||
radius: 'rounded',
|
||||
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
|
||||
activeSection: '',
|
||||
|
||||
toggleTheme: () => {
|
||||
const next = get().theme === 'light' ? 'dark' : 'light';
|
||||
applyThemeClass(next);
|
||||
set({ theme: next });
|
||||
},
|
||||
|
||||
setTheme: (theme) => {
|
||||
applyThemeClass(theme);
|
||||
set({ theme });
|
||||
},
|
||||
|
||||
toggleRadius: () => {
|
||||
const next = get().radius === 'rounded' ? 'square' : 'rounded';
|
||||
applyRadiusAttribute(next);
|
||||
set({ radius: next });
|
||||
setColorTheme: (colorTheme) => {
|
||||
applyColorThemeAttribute(colorTheme);
|
||||
set({ colorTheme });
|
||||
},
|
||||
|
||||
setRadius: (radius) => {
|
||||
@@ -84,12 +100,14 @@ export const useUIStore = create<UIState>()(
|
||||
name: 'stalwart-ui',
|
||||
partialize: (state) => ({
|
||||
theme: state.theme,
|
||||
colorTheme: state.colorTheme,
|
||||
radius: state.radius,
|
||||
}),
|
||||
onRehydrateStorage: () => {
|
||||
return (state) => {
|
||||
if (state) {
|
||||
applyThemeClass(state.theme);
|
||||
applyColorThemeAttribute(state.colorTheme ?? 'stalwart');
|
||||
applyRadiusAttribute(state.radius ?? 'rounded');
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user