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 { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import * as LucideIcons from 'lucide-react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { CommandPalette } from '@/components/common/CommandPalette';
|
import { CommandPalette } from '@/components/common/CommandPalette';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import Logo from '@/components/common/Logo';
|
import Logo from '@/components/common/Logo';
|
||||||
|
import { ThemeSwitcher } from '@/components/common/ThemeSwitcher';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||||
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
|
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
|
||||||
@@ -41,10 +42,6 @@ function getIcon(name: string): LucideIcons.LucideIcon {
|
|||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
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 toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||||
const accounts = useAuthStore((s) => s.accounts);
|
const accounts = useAuthStore((s) => s.accounts);
|
||||||
@@ -125,9 +122,7 @@ export function TopBar() {
|
|||||||
|
|
||||||
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
|
<ThemeSwitcher />
|
||||||
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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
|
<DropdownMenuItem
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
+16
-2
@@ -1,5 +1,21 @@
|
|||||||
{
|
{
|
||||||
"accounts": "Accounts",
|
"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": {
|
"actions": {
|
||||||
"actionFailed": "Action failed.",
|
"actionFailed": "Action failed.",
|
||||||
"backToActions": "Back to actions",
|
"backToActions": "Back to actions",
|
||||||
@@ -337,8 +353,6 @@
|
|||||||
"periodValue": "Period value"
|
"periodValue": "Period value"
|
||||||
},
|
},
|
||||||
"sections": "Sections",
|
"sections": "Sections",
|
||||||
"squareCorners": "Square corners",
|
|
||||||
"toggleTheme": "Toggle theme",
|
|
||||||
"tracing": {
|
"tracing": {
|
||||||
"addFilter": "Add filter",
|
"addFilter": "Add filter",
|
||||||
"bufferFull": "(buffer full)",
|
"bufferFull": "(buffer full)",
|
||||||
|
|||||||
+124
@@ -96,6 +96,130 @@
|
|||||||
--radius: 0px;
|
--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 {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
|||||||
+9
-8
@@ -20,18 +20,19 @@ import { getBasePath } from './lib/basePath';
|
|||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
const persisted = localStorage.getItem('stalwart-ui');
|
const persisted = localStorage.getItem('stalwart-ui');
|
||||||
if (persisted) {
|
const state = persisted ? (JSON.parse(persisted)?.state ?? null) : null;
|
||||||
const parsed = JSON.parse(persisted);
|
const theme = state?.theme;
|
||||||
const theme = parsed?.state?.theme;
|
const dark = theme === 'dark' || (theme !== 'light' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches);
|
||||||
if (theme === 'dark' || theme === 'light') {
|
document.documentElement.classList.toggle('dark', dark);
|
||||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
const colorTheme = state?.colorTheme;
|
||||||
return;
|
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
|
// eslint-disable-next-line no-empty
|
||||||
} catch {}
|
} catch {}
|
||||||
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
||||||
document.documentElement.classList.toggle('dark', !!prefersDark);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const basePath = getBasePath();
|
const basePath = getBasePath();
|
||||||
|
|||||||
+32
-14
@@ -7,18 +7,19 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
export type Theme = 'light' | 'dark';
|
||||||
type Radius = 'rounded' | 'square';
|
export type ColorTheme = 'stalwart' | 'ocean' | 'forest' | 'violet';
|
||||||
|
export type Radius = 'rounded' | 'square';
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
colorTheme: ColorTheme;
|
||||||
radius: Radius;
|
radius: Radius;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
activeSection: string;
|
activeSection: string;
|
||||||
|
|
||||||
toggleTheme: () => void;
|
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
toggleRadius: () => void;
|
setColorTheme: (colorTheme: ColorTheme) => void;
|
||||||
setRadius: (radius: Radius) => void;
|
setRadius: (radius: Radius) => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
setSidebarOpen: (open: boolean) => 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) {
|
function applyRadiusAttribute(radius: Radius) {
|
||||||
document.documentElement.dataset.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>()(
|
export const useUIStore = create<UIState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
theme:
|
theme:
|
||||||
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
|
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
|
||||||
|
colorTheme: 'stalwart',
|
||||||
radius: 'rounded',
|
radius: 'rounded',
|
||||||
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
|
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
|
||||||
activeSection: '',
|
activeSection: '',
|
||||||
|
|
||||||
toggleTheme: () => {
|
|
||||||
const next = get().theme === 'light' ? 'dark' : 'light';
|
|
||||||
applyThemeClass(next);
|
|
||||||
set({ theme: next });
|
|
||||||
},
|
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
applyThemeClass(theme);
|
applyThemeClass(theme);
|
||||||
set({ theme });
|
set({ theme });
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleRadius: () => {
|
setColorTheme: (colorTheme) => {
|
||||||
const next = get().radius === 'rounded' ? 'square' : 'rounded';
|
applyColorThemeAttribute(colorTheme);
|
||||||
applyRadiusAttribute(next);
|
set({ colorTheme });
|
||||||
set({ radius: next });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setRadius: (radius) => {
|
setRadius: (radius) => {
|
||||||
@@ -84,12 +100,14 @@ export const useUIStore = create<UIState>()(
|
|||||||
name: 'stalwart-ui',
|
name: 'stalwart-ui',
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
|
colorTheme: state.colorTheme,
|
||||||
radius: state.radius,
|
radius: state.radius,
|
||||||
}),
|
}),
|
||||||
onRehydrateStorage: () => {
|
onRehydrateStorage: () => {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
if (state) {
|
if (state) {
|
||||||
applyThemeClass(state.theme);
|
applyThemeClass(state.theme);
|
||||||
|
applyColorThemeAttribute(state.colorTheme ?? 'stalwart');
|
||||||
applyRadiusAttribute(state.radius ?? 'rounded');
|
applyRadiusAttribute(state.radius ?? 'rounded');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user