diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f3ee83f..a15f40b 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -6,6 +6,7 @@ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import * as LucideIcons from 'lucide-react'; const { ChevronDown, Lock } = LucideIcons; import { cn } from '@/lib/utils'; @@ -321,6 +322,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU export function Sidebar() { const navigate = useNavigate(); const location = useLocation(); + const { t } = useTranslation(); const activeSection = useUIStore((s) => s.activeSection); const setActiveSection = useUIStore((s) => s.setActiveSection); const sidebarOpen = useUIStore((s) => s.sidebarOpen); @@ -369,6 +371,9 @@ export function Sidebar() { if (first) navigate(`/${target.name}/${first}`); }; + const isAppearanceActive = + location.pathname === '/Appearance' || location.pathname.startsWith('/Appearance/'); + return ( <>
+
+ +
+ {layouts.length > 1 && (
diff --git a/src/features/appearance/AppearancePage.tsx b/src/features/appearance/AppearancePage.tsx new file mode 100644 index 0000000..e83e938 --- /dev/null +++ b/src/features/appearance/AppearancePage.tsx @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, Moon, Sun } from 'lucide-react'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { COLOR_THEMES, useUIStore } from '@/stores/uiStore'; + +export function AppearancePage() { + 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 ( +
+
+

{t('appearance.label', 'Appearance')}

+

+ {t('appearance.description', 'Customize how the interface looks and feels.')} +

+
+ + + + {t('appearance.mode', 'Mode')} + {t('appearance.modeDescription', 'Switch between light and dark mode.')} + + + setTheme('light')} + label={t('appearance.light', 'Light')} + > + + + setTheme('dark')} label={t('appearance.dark', 'Dark')}> + + + + + + + + {t('appearance.colorTheme', 'Color theme')} + + {t('appearance.colorThemeDescription', 'Pick the accent color used across the interface.')} + + + + {COLOR_THEMES.map((entry) => ( + setColorTheme(entry.value)} + label={t(entry.labelKey, entry.fallback)} + > + + + ))} + + + + + + {t('appearance.corners', 'Corners')} + + {t('appearance.cornersDescription', 'Choose between rounded and square corners. Applies to every theme.')} + + + + setRadius('rounded')} + label={t('appearance.rounded', 'Rounded')} + > + + + setRadius('square')} + label={t('appearance.square', 'Square')} + > + + + + +
+ ); +} + +function OptionCard({ + selected, + onClick, + label, + children, +}: { + selected: boolean; + onClick: () => void; + label: string; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/src/i18n/en.json b/src/i18n/en.json index d95925a..e515ab0 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2,11 +2,15 @@ "accounts": "Accounts", "appearance": { "colorTheme": "Color theme", + "colorThemeDescription": "Pick the accent color used across the interface.", "corners": "Corners", + "cornersDescription": "Choose between rounded and square corners. Applies to every theme.", "dark": "Dark", + "description": "Customize how the interface looks and feels.", "label": "Appearance", "light": "Light", "mode": "Mode", + "modeDescription": "Switch between light and dark mode.", "rounded": "Rounded", "square": "Square", "switchToDark": "Switch to dark mode", diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index dfae5a3..c972b9b 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -21,6 +21,7 @@ import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { friendlyName } from '@/hooks/useGlobalSearch'; +import { AppearancePage } from '@/features/appearance/AppearancePage'; import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, @@ -81,6 +82,7 @@ export default function AdminPanel() { const setSession = useAuthStore((s) => s.setSession); const accessToken = useAuthStore((s) => s.accessToken); const setActiveSection = useUIStore((s) => s.setActiveSection); + const activeSection = useUIStore((s) => s.activeSection); const sidebarOpen = useUIStore((s) => s.sidebarOpen); const { canViewObject } = usePermissions(); @@ -92,6 +94,7 @@ export default function AdminPanel() { // Tab title mirrors the sidebar label (search index) so it always matches // what the user sees in the navigation, e.g. "MTA-STS ยท Settings". const pageTitle = useMemo(() => { + if (section === 'Appearance') return t('appearance.label', 'Appearance'); if (!section) return t('dashboard.title', 'Dashboard'); if (!viewName) return section; const entry = @@ -207,6 +210,15 @@ export default function AdminPanel() { } } + if (section === 'Appearance') { + // Appearance is a client-side page outside the schema layouts: keep the + // sidebar bound to a real section while it is open. + if (!layouts.some((l) => l.name === activeSection)) { + setActiveSection(layouts[0]?.name ?? ''); + } + return; + } + if (section) { setActiveSection(section); return; @@ -222,6 +234,7 @@ export default function AdminPanel() { }, [ section, viewName, + activeSection, schema, setActiveSection, navigate, @@ -284,7 +297,11 @@ export default function AdminPanel() {
- + {section === 'Appearance' ? ( + + ) : ( + + )}