Add theme system with color presets, fix login page styling, persist auth session

Theme system with 5 color presets (Slate, Emerald, Violet, Amber, Rose)
and light/dark/system mode. User menu in sidebar with theme picker and
sign out. Login page uses standalone dark branded styling with autofill
override. Auth persists in sessionStorage across refreshes.
This commit is contained in:
Ryan Moon
2026-03-28 08:30:24 -05:00
parent 9abdf6c050
commit 7c64a928e1
10 changed files with 691 additions and 145 deletions

View File

@@ -6,6 +6,14 @@
<title>Forte Admin</title>
</head>
<body>
<script>
// Apply mode before React renders to prevent flash
(function() {
var mode = localStorage.getItem('forte-mode') || 'system';
var isDark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
})();
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -1,94 +1,29 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@variant dark (&:is(.dark, .dark *));
@theme {
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(240 10% 3.9%);
/* Defaults — overridden at runtime by theme store */
--color-background: hsl(210 20% 97%);
--color-foreground: hsl(222 47% 11%);
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(240 10% 3.9%);
--color-card-foreground: hsl(222 47% 11%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(240 10% 3.9%);
--color-primary: hsl(240 5.9% 10%);
--color-primary-foreground: hsl(0 0% 98%);
--color-secondary: hsl(240 4.8% 95.9%);
--color-secondary-foreground: hsl(240 5.9% 10%);
--color-muted: hsl(240 4.8% 95.9%);
--color-muted-foreground: hsl(240 3.8% 46.1%);
--color-accent: hsl(240 4.8% 95.9%);
--color-accent-foreground: hsl(240 5.9% 10%);
--color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(0 0% 98%);
--color-border: hsl(240 5.9% 90%);
--color-input: hsl(240 5.9% 90%);
--color-ring: hsl(240 5.9% 10%);
--color-sidebar: hsl(0 0% 98%);
--color-sidebar-foreground: hsl(240 5.3% 26.1%);
--color-sidebar-primary: hsl(240 5.9% 10%);
--color-sidebar-primary-foreground: hsl(0 0% 98%);
--color-sidebar-accent: hsl(240 4.8% 95.9%);
--color-sidebar-accent-foreground: hsl(240 5.9% 10%);
--color-sidebar-border: hsl(220 13% 91%);
--color-sidebar-ring: hsl(217.2 91.2% 59.8%);
--radius: 0.625rem;
}
.dark {
--color-background: hsl(240 10% 3.9%);
--color-foreground: hsl(0 0% 98%);
--color-card: hsl(240 10% 3.9%);
--color-card-foreground: hsl(0 0% 98%);
--color-popover: hsl(240 10% 3.9%);
--color-popover-foreground: hsl(0 0% 98%);
--color-primary: hsl(0 0% 98%);
--color-primary-foreground: hsl(240 5.9% 10%);
--color-secondary: hsl(240 3.7% 15.9%);
--color-secondary-foreground: hsl(0 0% 98%);
--color-muted: hsl(240 3.7% 15.9%);
--color-muted-foreground: hsl(240 5% 64.9%);
--color-accent: hsl(240 3.7% 15.9%);
--color-accent-foreground: hsl(0 0% 98%);
--color-destructive: hsl(0 62.8% 30.6%);
--color-destructive-foreground: hsl(0 0% 98%);
--color-border: hsl(240 3.7% 15.9%);
--color-input: hsl(240 3.7% 15.9%);
--color-ring: hsl(240 4.9% 83.9%);
--color-sidebar: hsl(240 5.9% 10%);
--color-sidebar-foreground: hsl(240 4.8% 95.9%);
--color-sidebar-primary: hsl(224.3 76.3% 48%);
--color-sidebar-primary-foreground: hsl(0 0% 100%);
--color-sidebar-accent: hsl(240 3.7% 15.9%);
--color-sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--color-sidebar-border: hsl(240 3.7% 15.9%);
--color-sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
}
body {
font-family:
"Inter",
ui-sans-serif,
system-ui,
-apple-system,
sans-serif;
}
:root {
--sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--color-popover-foreground: hsl(222 47% 11%);
--color-primary: hsl(215 25% 27%);
--color-primary-foreground: hsl(210 40% 98%);
--color-secondary: hsl(210 40% 94%);
--color-secondary-foreground: hsl(222 47% 11%);
--color-muted: hsl(210 40% 94%);
--color-muted-foreground: hsl(215 16% 47%);
--color-accent: hsl(210 40% 94%);
--color-accent-foreground: hsl(222 47% 11%);
--color-destructive: hsl(0 72% 51%);
--color-destructive-foreground: hsl(210 40% 98%);
--color-border: hsl(214 32% 89%);
--color-input: hsl(214 32% 89%);
--color-ring: hsl(215 25% 27%);
--radius: 0.5rem;
}
@theme inline {
@@ -101,3 +36,38 @@ body {
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--sidebar: hsl(210 25% 95%);
--sidebar-foreground: hsl(215 16% 47%);
--sidebar-primary: hsl(215 25% 27%);
--sidebar-primary-foreground: hsl(210 40% 98%);
--sidebar-accent: hsl(210 40% 92%);
--sidebar-accent-foreground: hsl(222 47% 11%);
--sidebar-border: hsl(214 32% 89%);
--sidebar-ring: hsl(215 25% 27%);
}
body {
font-family:
"Inter",
ui-sans-serif,
system-ui,
-apple-system,
sans-serif;
}
.login-input {
background-color: #1a2740 !important;
border-color: #2a3a52 !important;
color: #d0d8e0 !important;
}
.login-input:-webkit-autofill,
.login-input:-webkit-autofill:hover,
.login-input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px #1a2740 inset !important;
-webkit-text-fill-color: #d0d8e0 !important;
border-color: #2a3a52 !important;
transition: background-color 5000s ease-in-out 0s;
}

View File

@@ -9,7 +9,7 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary text-primary-foreground hover:bg-primary/90 dark:bg-transparent dark:border dark:border-primary dark:text-primary dark:hover:bg-primary/10",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:

View File

@@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base text-foreground shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
className
)}
{...props}

View File

@@ -31,7 +31,8 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
if (res.status === 401) {
useAuthStore.getState().logout()
window.location.href = '/login'
// Don't use window.location — that causes a full reload and flash
// The router's beforeLoad guard will redirect to /login on next navigation
throw new ApiError('Unauthorized', 401)
}

View File

@@ -0,0 +1,379 @@
export interface ThemeColors {
background: string
foreground: string
card: string
cardForeground: string
popover: string
popoverForeground: string
primary: string
primaryForeground: string
secondary: string
secondaryForeground: string
muted: string
mutedForeground: string
accent: string
accentForeground: string
destructive: string
destructiveForeground: string
border: string
input: string
ring: string
sidebar: string
sidebarForeground: string
sidebarPrimary: string
sidebarPrimaryForeground: string
sidebarAccent: string
sidebarAccentForeground: string
sidebarBorder: string
}
export interface ThemePreset {
name: string
label: string
light: ThemeColors
dark: ThemeColors
}
export const themes: ThemePreset[] = [
{
name: 'slate',
label: 'Slate',
light: {
background: '210 20% 97%',
foreground: '222 47% 11%',
card: '0 0% 100%',
cardForeground: '222 47% 11%',
popover: '0 0% 100%',
popoverForeground: '222 47% 11%',
primary: '215 25% 27%',
primaryForeground: '210 40% 98%',
secondary: '210 40% 94%',
secondaryForeground: '222 47% 11%',
muted: '210 40% 94%',
mutedForeground: '215 16% 47%',
accent: '210 40% 94%',
accentForeground: '222 47% 11%',
destructive: '0 72% 51%',
destructiveForeground: '210 40% 98%',
border: '214 32% 89%',
input: '214 32% 89%',
ring: '215 25% 27%',
sidebar: '210 25% 95%',
sidebarForeground: '215 16% 47%',
sidebarPrimary: '215 25% 27%',
sidebarPrimaryForeground: '210 40% 98%',
sidebarAccent: '210 40% 92%',
sidebarAccentForeground: '222 47% 11%',
sidebarBorder: '214 32% 89%',
},
dark: {
background: '222 30% 8%',
foreground: '210 20% 86%',
card: '222 30% 10%',
cardForeground: '210 20% 86%',
popover: '222 30% 10%',
popoverForeground: '210 20% 86%',
primary: '215 20% 72%',
primaryForeground: '222 30% 8%',
secondary: '217 20% 15%',
secondaryForeground: '210 20% 86%',
muted: '217 20% 15%',
mutedForeground: '215 15% 55%',
accent: '217 20% 15%',
accentForeground: '210 20% 86%',
destructive: '0 55% 48%',
destructiveForeground: '210 20% 92%',
border: '217 20% 17%',
input: '217 20% 17%',
ring: '215 20% 55%',
sidebar: '222 30% 7%',
sidebarForeground: '215 15% 65%',
sidebarPrimary: '215 20% 72%',
sidebarPrimaryForeground: '0 0% 100%',
sidebarAccent: '217 20% 13%',
sidebarAccentForeground: '210 20% 86%',
sidebarBorder: '217 20% 15%',
},
},
{
name: 'emerald',
label: 'Emerald',
light: {
background: '150 20% 97%',
foreground: '150 40% 10%',
card: '0 0% 100%',
cardForeground: '150 40% 10%',
popover: '0 0% 100%',
popoverForeground: '150 40% 10%',
primary: '160 84% 29%',
primaryForeground: '150 20% 98%',
secondary: '150 20% 93%',
secondaryForeground: '150 40% 10%',
muted: '150 20% 93%',
mutedForeground: '150 10% 45%',
accent: '150 20% 93%',
accentForeground: '150 40% 10%',
destructive: '0 72% 51%',
destructiveForeground: '150 20% 98%',
border: '150 15% 88%',
input: '150 15% 88%',
ring: '160 84% 29%',
sidebar: '150 20% 95%',
sidebarForeground: '150 10% 45%',
sidebarPrimary: '160 84% 29%',
sidebarPrimaryForeground: '150 20% 98%',
sidebarAccent: '150 20% 91%',
sidebarAccentForeground: '150 40% 10%',
sidebarBorder: '150 15% 88%',
},
dark: {
background: '155 30% 7%',
foreground: '150 15% 85%',
card: '155 30% 9%',
cardForeground: '150 15% 85%',
popover: '155 30% 9%',
popoverForeground: '150 15% 85%',
primary: '160 72% 42%',
primaryForeground: '155 30% 7%',
secondary: '155 20% 14%',
secondaryForeground: '150 15% 85%',
muted: '155 20% 14%',
mutedForeground: '150 10% 52%',
accent: '155 20% 14%',
accentForeground: '150 15% 85%',
destructive: '0 55% 48%',
destructiveForeground: '150 15% 92%',
border: '155 20% 16%',
input: '155 20% 16%',
ring: '160 72% 42%',
sidebar: '155 30% 6%',
sidebarForeground: '150 10% 62%',
sidebarPrimary: '160 72% 42%',
sidebarPrimaryForeground: '0 0% 100%',
sidebarAccent: '155 20% 12%',
sidebarAccentForeground: '150 15% 85%',
sidebarBorder: '155 20% 14%',
},
},
{
name: 'violet',
label: 'Violet',
light: {
background: '260 15% 97%',
foreground: '260 40% 11%',
card: '0 0% 100%',
cardForeground: '260 40% 11%',
popover: '0 0% 100%',
popoverForeground: '260 40% 11%',
primary: '262 83% 58%',
primaryForeground: '260 15% 98%',
secondary: '260 20% 94%',
secondaryForeground: '260 40% 11%',
muted: '260 20% 94%',
mutedForeground: '260 10% 47%',
accent: '260 20% 94%',
accentForeground: '260 40% 11%',
destructive: '0 72% 51%',
destructiveForeground: '260 15% 98%',
border: '260 15% 89%',
input: '260 15% 89%',
ring: '262 83% 58%',
sidebar: '260 18% 95%',
sidebarForeground: '260 10% 47%',
sidebarPrimary: '262 83% 58%',
sidebarPrimaryForeground: '260 15% 98%',
sidebarAccent: '260 20% 92%',
sidebarAccentForeground: '260 40% 11%',
sidebarBorder: '260 15% 89%',
},
dark: {
background: '260 30% 8%',
foreground: '260 15% 86%',
card: '260 30% 10%',
cardForeground: '260 15% 86%',
popover: '260 30% 10%',
popoverForeground: '260 15% 86%',
primary: '262 83% 65%',
primaryForeground: '260 30% 8%',
secondary: '260 20% 15%',
secondaryForeground: '260 15% 86%',
muted: '260 20% 15%',
mutedForeground: '260 10% 54%',
accent: '260 20% 15%',
accentForeground: '260 15% 86%',
destructive: '0 55% 48%',
destructiveForeground: '260 15% 92%',
border: '260 20% 17%',
input: '260 20% 17%',
ring: '262 83% 65%',
sidebar: '260 30% 7%',
sidebarForeground: '260 10% 64%',
sidebarPrimary: '262 83% 65%',
sidebarPrimaryForeground: '0 0% 100%',
sidebarAccent: '260 20% 13%',
sidebarAccentForeground: '260 15% 86%',
sidebarBorder: '260 20% 15%',
},
},
{
name: 'amber',
label: 'Amber',
light: {
background: '36 20% 97%',
foreground: '28 40% 10%',
card: '0 0% 100%',
cardForeground: '28 40% 10%',
popover: '0 0% 100%',
popoverForeground: '28 40% 10%',
primary: '25 95% 40%',
primaryForeground: '36 20% 98%',
secondary: '36 25% 93%',
secondaryForeground: '28 40% 10%',
muted: '36 25% 93%',
mutedForeground: '28 10% 47%',
accent: '36 25% 93%',
accentForeground: '28 40% 10%',
destructive: '0 72% 51%',
destructiveForeground: '36 20% 98%',
border: '36 20% 88%',
input: '36 20% 88%',
ring: '25 95% 40%',
sidebar: '36 22% 95%',
sidebarForeground: '28 10% 47%',
sidebarPrimary: '25 95% 40%',
sidebarPrimaryForeground: '36 20% 98%',
sidebarAccent: '36 25% 91%',
sidebarAccentForeground: '28 40% 10%',
sidebarBorder: '36 20% 88%',
},
dark: {
background: '28 25% 7%',
foreground: '36 15% 85%',
card: '28 25% 9%',
cardForeground: '36 15% 85%',
popover: '28 25% 9%',
popoverForeground: '36 15% 85%',
primary: '30 90% 52%',
primaryForeground: '28 25% 7%',
secondary: '28 18% 14%',
secondaryForeground: '36 15% 85%',
muted: '28 18% 14%',
mutedForeground: '28 10% 52%',
accent: '28 18% 14%',
accentForeground: '36 15% 85%',
destructive: '0 55% 48%',
destructiveForeground: '36 15% 92%',
border: '28 18% 16%',
input: '28 18% 16%',
ring: '30 90% 52%',
sidebar: '28 25% 6%',
sidebarForeground: '28 10% 62%',
sidebarPrimary: '30 90% 52%',
sidebarPrimaryForeground: '0 0% 100%',
sidebarAccent: '28 18% 12%',
sidebarAccentForeground: '36 15% 85%',
sidebarBorder: '28 18% 14%',
},
},
{
name: 'rose',
label: 'Rose',
light: {
background: '350 15% 97%',
foreground: '350 40% 10%',
card: '0 0% 100%',
cardForeground: '350 40% 10%',
popover: '0 0% 100%',
popoverForeground: '350 40% 10%',
primary: '347 77% 50%',
primaryForeground: '350 15% 98%',
secondary: '350 20% 94%',
secondaryForeground: '350 40% 10%',
muted: '350 20% 94%',
mutedForeground: '350 10% 47%',
accent: '350 20% 94%',
accentForeground: '350 40% 10%',
destructive: '0 72% 51%',
destructiveForeground: '350 15% 98%',
border: '350 15% 89%',
input: '350 15% 89%',
ring: '347 77% 50%',
sidebar: '350 18% 95%',
sidebarForeground: '350 10% 47%',
sidebarPrimary: '347 77% 50%',
sidebarPrimaryForeground: '350 15% 98%',
sidebarAccent: '350 20% 92%',
sidebarAccentForeground: '350 40% 10%',
sidebarBorder: '350 15% 89%',
},
dark: {
background: '350 25% 7%',
foreground: '350 12% 86%',
card: '350 25% 9%',
cardForeground: '350 12% 86%',
popover: '350 25% 9%',
popoverForeground: '350 12% 86%',
primary: '347 77% 58%',
primaryForeground: '350 25% 7%',
secondary: '350 18% 14%',
secondaryForeground: '350 12% 86%',
muted: '350 18% 14%',
mutedForeground: '350 10% 54%',
accent: '350 18% 14%',
accentForeground: '350 12% 86%',
destructive: '0 55% 48%',
destructiveForeground: '350 12% 92%',
border: '350 18% 16%',
input: '350 18% 16%',
ring: '347 77% 58%',
sidebar: '350 25% 6%',
sidebarForeground: '350 10% 62%',
sidebarPrimary: '347 77% 58%',
sidebarPrimaryForeground: '0 0% 100%',
sidebarAccent: '350 18% 12%',
sidebarAccentForeground: '350 12% 86%',
sidebarBorder: '350 18% 14%',
},
},
]
const CSS_VAR_MAP: Record<keyof ThemeColors, string> = {
background: '--color-background',
foreground: '--color-foreground',
card: '--color-card',
cardForeground: '--color-card-foreground',
popover: '--color-popover',
popoverForeground: '--color-popover-foreground',
primary: '--color-primary',
primaryForeground: '--color-primary-foreground',
secondary: '--color-secondary',
secondaryForeground: '--color-secondary-foreground',
muted: '--color-muted',
mutedForeground: '--color-muted-foreground',
accent: '--color-accent',
accentForeground: '--color-accent-foreground',
destructive: '--color-destructive',
destructiveForeground: '--color-destructive-foreground',
border: '--color-border',
input: '--color-input',
ring: '--color-ring',
sidebar: '--sidebar',
sidebarForeground: '--sidebar-foreground',
sidebarPrimary: '--sidebar-primary',
sidebarPrimaryForeground: '--sidebar-primary-foreground',
sidebarAccent: '--sidebar-accent',
sidebarAccentForeground: '--sidebar-accent-foreground',
sidebarBorder: '--sidebar-border',
}
export function applyThemeColors(colors: ThemeColors) {
const root = document.documentElement
for (const [key, cssVar] of Object.entries(CSS_VAR_MAP)) {
const value = colors[key as keyof ThemeColors]
root.style.setProperty(cssVar, `hsl(${value})`)
}
}
export function getThemeByName(name: string): ThemePreset {
return themes.find((t) => t.name === name) ?? themes[0]
}

View File

@@ -1,5 +1,20 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { createFileRoute, Outlet, Link, redirect, useRouter } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth.store'
import { useThemeStore } from '@/stores/theme.store'
import { themes } from '@/lib/themes'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu'
import { Users, Sun, Moon, Monitor, LogOut, User, Palette } from 'lucide-react'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
@@ -11,15 +26,102 @@ export const Route = createFileRoute('/_authenticated')({
component: AuthenticatedLayout,
})
function ModeIcon() {
const mode = useThemeStore((s) => s.mode)
if (mode === 'dark') return <Moon className="h-4 w-4" />
if (mode === 'light') return <Sun className="h-4 w-4" />
return <Monitor className="h-4 w-4" />
}
function AuthenticatedLayout() {
const router = useRouter()
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const { mode, colorTheme, setMode, setColorTheme } = useThemeStore()
function handleLogout() {
logout()
router.navigate({ to: '/login', replace: true })
}
return (
<div className="min-h-screen bg-background text-foreground">
<div className="flex">
<nav className="w-56 border-r border-border bg-sidebar min-h-screen p-4 space-y-2">
<h2 className="text-lg font-semibold text-sidebar-foreground mb-4">Forte</h2>
<a href="/accounts" className="block px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent">
<nav className="w-56 border-r border-border bg-sidebar min-h-screen flex flex-col">
<div className="p-4">
<h2 className="text-lg font-semibold text-sidebar-foreground">Forte</h2>
</div>
<div className="flex-1 px-3 space-y-1">
<Link
to="/accounts"
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
>
<Users className="h-4 w-4" />
Accounts
</a>
</Link>
</div>
<div className="p-3 border-t border-sidebar-border">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2 text-sm text-sidebar-foreground">
<User className="h-4 w-4" />
<span className="truncate">{user?.firstName} {user?.lastName}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuLabel className="font-normal">
<p className="text-sm font-medium">{user?.firstName} {user?.lastName}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ModeIcon />
<span className="ml-2">Mode</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => setMode('light')}>
<Sun className="mr-2 h-4 w-4" />
Light {mode === 'light' && '•'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMode('dark')}>
<Moon className="mr-2 h-4 w-4" />
Dark {mode === 'dark' && '•'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMode('system')}>
<Monitor className="mr-2 h-4 w-4" />
System {mode === 'system' && '•'}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Palette className="h-4 w-4" />
<span className="ml-2">Color</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{themes.map((t) => (
<DropdownMenuItem key={t.name} onClick={() => setColorTheme(t.name)}>
<span
className="mr-2 h-4 w-4 rounded-full border inline-block"
style={{ backgroundColor: `hsl(${t.light.primary})` }}
/>
{t.label} {colorTheme === t.name && '•'}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</nav>
<main className="flex-1 p-6">
<Outlet />

View File

@@ -1,18 +1,20 @@
import { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { createFileRoute, useRouter, redirect } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth.store'
import { login } from '@/api/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export const Route = createFileRoute('/login')({
beforeLoad: () => {
const { token } = useAuthStore.getState()
if (token) {
throw redirect({ to: '/accounts' })
}
},
component: LoginPage,
})
function LoginPage() {
const navigate = useNavigate()
const router = useRouter()
const setAuth = useAuthStore((s) => s.setAuth)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -27,7 +29,8 @@ function LoginPage() {
try {
const res = await login(email, password)
setAuth(res.token, res.user)
navigate({ to: '/accounts' })
await router.invalidate()
await router.navigate({ to: '/accounts', replace: true })
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
@@ -36,44 +39,55 @@ function LoginPage() {
}
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Forte</CardTitle>
<p className="text-sm text-muted-foreground">Sign in to your account</p>
</CardHeader>
<CardContent>
<div
className="flex min-h-screen items-center justify-center"
style={{ background: 'linear-gradient(135deg, #0f1724 0%, #142038 100%)' }}
>
<div
className="w-full max-w-sm rounded-xl border p-8 shadow-2xl"
style={{ backgroundColor: '#131c2e', borderColor: '#1e2d45' }}
>
<div className="text-center mb-8">
<h1 className="text-3xl font-bold" style={{ color: '#d8dfe9' }}>Forte</h1>
<p className="text-sm mt-1" style={{ color: '#6b7a8d' }}>Music Store Management</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>Email</label>
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input"
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
<p className="text-sm" style={{ color: '#e57373' }}>{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
<button
type="submit"
disabled={loading}
className="h-9 w-full rounded-md border text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'transparent', color: '#d0d8e0', borderColor: '#3a4a62' }}
onMouseEnter={(e) => { (e.target as HTMLElement).style.backgroundColor = '#1e2d45' }}
onMouseLeave={(e) => { (e.target as HTMLElement).style.backgroundColor = 'transparent' }}
>
{loading ? 'Signing in...' : 'Sign in'}
</Button>
</button>
</form>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -21,17 +21,39 @@ function decodeJwtPayload(token: string): { id: string; companyId: string; role:
return JSON.parse(atob(payload))
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
user: null,
companyId: null,
function loadSession(): { token: string; user: User; companyId: string } | null {
try {
const raw = sessionStorage.getItem('forte-auth')
if (!raw) return null
return JSON.parse(raw)
} catch {
return null
}
}
function saveSession(token: string, user: User, companyId: string) {
sessionStorage.setItem('forte-auth', JSON.stringify({ token, user, companyId }))
}
function clearSession() {
sessionStorage.removeItem('forte-auth')
}
export const useAuthStore = create<AuthState>((set) => {
const initial = typeof window !== 'undefined' ? loadSession() : null
return {
token: initial?.token ?? null,
user: initial?.user ?? null,
companyId: initial?.companyId ?? null,
setAuth: (token, user) => {
const payload = decodeJwtPayload(token)
saveSession(token, user, payload.companyId)
set({ token, user, companyId: payload.companyId })
},
logout: () => {
clearSession()
set({ token: null, user: null, companyId: null })
},
}))
}})

View File

@@ -0,0 +1,50 @@
import { create } from 'zustand'
import { applyThemeColors, getThemeByName } from '@/lib/themes'
type Mode = 'light' | 'dark' | 'system'
interface ThemeState {
mode: Mode
colorTheme: string
setMode: (mode: Mode) => void
setColorTheme: (name: string) => void
}
function isDarkMode(mode: Mode): boolean {
return mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
}
function apply(mode: Mode, colorTheme: string) {
const dark = isDarkMode(mode)
document.documentElement.classList.toggle('dark', dark)
const theme = getThemeByName(colorTheme)
applyThemeColors(dark ? theme.dark : theme.light)
}
export const useThemeStore = create<ThemeState>((set) => {
const initialMode = (typeof window !== 'undefined' ? localStorage.getItem('forte-mode') as Mode : null) ?? 'system'
const initialColor = (typeof window !== 'undefined' ? localStorage.getItem('forte-color-theme') : null) ?? 'slate'
if (typeof window !== 'undefined') {
apply(initialMode, initialColor)
}
return {
mode: initialMode,
colorTheme: initialColor,
setMode: (mode) => {
localStorage.setItem('forte-mode', mode)
const colorTheme = useThemeStore.getState().colorTheme
apply(mode, colorTheme)
set({ mode })
},
setColorTheme: (name) => {
localStorage.setItem('forte-color-theme', name)
const mode = useThemeStore.getState().mode
apply(mode, name)
set({ colorTheme: name })
},
}
})