A new README for the combined project with setup from the Flashpoint Ultimate torrent. New feature docs for streaming, proxy sign-in and game saves; deployment, upgrade and streaming READMEs updated for the single compose file; the project renamed throughout.
17 KiB
Zustand Stores
This document describes the Zustand stores used for client-side state management in the Dotcom Arcade frontend.
Overview
The application uses three main Zustand stores for different aspects of UI state:
- useAuthStore - Authentication state, JWT tokens, user permissions
- useThemeStore - Theme mode (light/dark/system), primary color
- useUIStore - Sidebar visibility, view modes, card sizes
All stores use the persist middleware to save state to localStorage (or
sessionStorage for guest mode).
useAuthStore
Manages authentication state, user information, and JWT tokens.
Location
D:\Repositories\Personal\flashpoint-web\frontend\src\store\auth.ts
State
interface AuthState {
user: User | null;
accessToken: string | null; // Memory-only, not persisted
isAuthenticated: boolean;
isGuest: boolean;
isMaintenanceMode: boolean;
// Actions
setAuth: (user: User, tokens: AuthTokens) => void;
setGuestMode: () => void;
clearAuth: () => void;
updateAccessToken: (token: string) => void;
updateUser: (user: User) => void;
// Permission helpers
hasPermission: (permission: string) => boolean;
hasRole: (role: string) => boolean;
hasAnyPermission: (permissions: string[]) => boolean;
hasAllPermissions: (permissions: string[]) => boolean;
}
Important:
accessTokenis stored in memory only (not persisted to localStorage)refreshTokenis stored in an HTTP-only cookie (handled automatically by the browser)- Only
user,isAuthenticated,isGuest, andisMaintenanceModeare persisted to localStorage (viapartialize)
Usage
import { useAuthStore } from '@/store/auth';
function MyComponent() {
// Select specific state
const user = useAuthStore((state) => state.user);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
// Use multiple state values
const { isGuest, hasPermission } = useAuthStore();
// Check permissions
if (hasPermission('games.play')) {
// Render play button
}
}
Actions
setAuth
Sets authenticated user and access token after successful login:
const { setAuth } = useAuthStore();
const handleLogin = async (credentials) => {
const response = await authApi.login(credentials);
setAuth(response.user, {
accessToken: response.accessToken,
expiresIn: response.expiresIn,
});
// Refresh token is automatically stored in HTTP-only cookie by backend
};
Note: Only the access token is passed to setAuth(). The backend
automatically sets the refresh token in an HTTP-only cookie during login, which
the browser will include in all subsequent requests.
setGuestMode
Enables guest mode with limited permissions:
const { setGuestMode } = useAuthStore();
setGuestMode();
// Creates a guest user with permissions:
// ['games.read', 'playlists.read', 'games.play']
clearAuth
Logs out the user and clears all auth state:
const { clearAuth } = useAuthStore();
const handleLogout = () => {
clearAuth();
navigate('/login');
};
updateAccessToken
Updates the access token when refreshed via the axios interceptor:
const { updateAccessToken } = useAuthStore();
// Called by axios interceptor on 401 after successful token refresh
updateAccessToken(newAccessToken);
This is called by the response interceptor when a token refresh succeeds. The refresh token is automatically sent via HTTP-only cookie, and the new access token is stored in memory only.
Permission Helpers
hasPermission
Checks if user has a specific permission:
const { hasPermission } = useAuthStore();
if (hasPermission('users.manage')) {
// Show admin panel
}
hasRole
Checks if user has a specific role:
const { hasRole } = useAuthStore();
if (hasRole('admin')) {
// Show admin features
}
hasAnyPermission
Checks if user has any of the specified permissions:
const { hasAnyPermission } = useAuthStore();
if (hasAnyPermission(['users.read', 'roles.read', 'activities.read'])) {
// Show management section
}
hasAllPermissions
Checks if user has all specified permissions:
const { hasAllPermissions } = useAuthStore();
if (hasAllPermissions(['users.read', 'users.write', 'users.delete'])) {
// Show full user management
}
Persistence Strategy
The auth store uses selective persistence with the partialize middleware:
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
isGuest: state.isGuest,
isMaintenanceMode: state.isMaintenanceMode,
// accessToken is intentionally NOT persisted (memory-only)
});
storage: createJSONStorage(() => ({
getItem: (name) => {
// Check sessionStorage first for guest sessions
const sessionValue = sessionStorage.getItem(name);
if (sessionValue) {
const parsed = JSON.parse(sessionValue);
if (parsed?.state?.isGuest) {
return sessionValue;
}
}
// Fall back to localStorage for authenticated sessions
return localStorage.getItem(name);
},
setItem: (name, value) => {
const parsed = JSON.parse(value);
if (parsed?.state?.isGuest) {
// Guest sessions go to sessionStorage only
sessionStorage.setItem(name, value);
} else {
// Authenticated sessions go to localStorage
localStorage.setItem(name, value);
sessionStorage.removeItem(name);
}
},
removeItem: (name) => {
localStorage.removeItem(name);
sessionStorage.removeItem(name);
},
}));
Token Storage Strategy:
- Access Token: Memory-only in Zustand store, never persisted
- Refresh Token: HTTP-only cookie, sent automatically by browser
- User Info: Persisted to localStorage (restored on page reload)
- Guest Sessions: Stored in sessionStorage only, cleared on browser close
Benefits:
- XSS attacks cannot steal access token (memory-only)
- CSRF protection via HTTP-only cookies (auto-sent by browser)
- Guest sessions cleared on browser close (sessionStorage)
- Authenticated sessions persist across browser restarts (localStorage)
- On page reload: if
isAuthenticatedbutaccessTokenis null, the app calls/auth/refreshto recover the session using the persisted refresh token cookie
Session Recovery on Page Reload
The app implements session recovery using the persisted refresh token cookie:
// In AuthContext or root App component
useEffect(() => {
const { isAuthenticated, accessToken } = useAuthStore.getState();
// If authenticated but no access token (page reload), refresh from cookie
if (isAuthenticated && !accessToken) {
authApi
.refreshToken()
.then((tokens) => {
useAuthStore.getState().updateAccessToken(tokens.accessToken);
})
.catch(() => {
// Refresh failed, clear auth
useAuthStore.getState().clearAuth();
});
}
}, []);
After successful login, the auth store automatically loads theme settings from the server:
setAuth: (user: User, tokens: AuthTokens) => {
set({
user,
accessToken: tokens.accessToken,
isAuthenticated: true,
isGuest: false,
});
// Load theme settings from server
const themeStore = useThemeStore.getState();
themeStore.loadThemeFromServer().catch((error) => {
console.debug('Failed to load theme settings after login:', error);
});
};
How it works:
- On login: backend sets refresh token in HTTP-only cookie, user info and
isAuthenticatedare persisted to localStorage - On page reload: Zustand rehydrates from localStorage, checks if
isAuthenticated && !accessToken - If true: calls
/auth/refresh(cookie sent automatically), recovers access token - If false or refresh fails: clears auth state and shows login screen
useThemeStore
Manages theme mode (light/dark/system) and primary color selection.
Location
D:\Repositories\Personal\flashpoint-web\frontend\src\store\theme.ts
State
interface ThemeState {
mode: ThemeMode; // 'light' | 'dark' | 'system'
primaryColor: PrimaryColor; // 21 color options
isLoading: boolean;
setMode: (mode: ThemeMode) => void;
setPrimaryColor: (color: PrimaryColor) => void;
loadThemeFromServer: () => Promise<void>;
syncThemeToServer: () => Promise<void>;
}
Primary Colors
21 color options available:
export type PrimaryColor =
| 'blue' // Default
| 'slate'
| 'gray'
| 'zinc'
| 'neutral'
| 'stone'
| 'red'
| 'orange'
| 'amber'
| 'yellow'
| 'lime'
| 'green'
| 'emerald'
| 'teal'
| 'cyan'
| 'sky'
| 'indigo'
| 'violet'
| 'purple'
| 'fuchsia'
| 'pink'
| 'rose';
Each color has light and dark mode variants stored as HSL values:
export const colorPalette: Record<
PrimaryColor,
{ light: string; dark: string; label: string }
> = {
blue: {
light: '221.2 83.2% 53.3%',
dark: '217.2 91.2% 59.8%',
label: 'Blue',
},
red: { light: '0 72.2% 50.6%', dark: '0 72.2% 60.6%', label: 'Red' },
// ... 19 more colors
};
Usage
import { useThemeStore } from '@/store/theme';
function ThemePicker() {
const { mode, setMode } = useThemeStore();
return (
<select value={mode} onChange={(e) => setMode(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
);
}
Actions
setMode
Changes theme mode and applies to document:
const { setMode } = useThemeStore();
setMode('dark'); // Immediately applies dark theme
Automatically:
- Updates CSS classes on
<html>element - Reapplies primary color for new theme
- Syncs to server (if authenticated)
setPrimaryColor
Changes primary color and applies to CSS variables:
const { setPrimaryColor } = useThemeStore();
setPrimaryColor('emerald'); // Applies emerald color theme
Automatically:
- Updates
--primaryand--ringCSS variables - Syncs to server (if authenticated)
loadThemeFromServer
Loads user's saved theme settings from server:
const { loadThemeFromServer } = useThemeStore();
await loadThemeFromServer();
Called automatically:
- After successful login
- On app rehydration (if authenticated)
syncThemeToServer
Saves current theme settings to server:
const { syncThemeToServer } = useThemeStore();
await syncThemeToServer();
Called automatically:
- When mode changes
- When primary color changes
- Silent failure if not authenticated
Theme Application
Theme is applied by modifying CSS classes and custom properties:
const applyTheme = (mode: ThemeMode) => {
const root = document.documentElement;
// Remove existing theme classes
root.classList.remove('light', 'dark');
// Determine actual theme (resolve 'system')
const actualTheme: 'light' | 'dark' = mode === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: mode;
// Apply theme class
root.classList.add(actualTheme);
};
const applyPrimaryColor = (color: PrimaryColor, currentMode: ThemeMode) => {
const root = document.documentElement;
const actualTheme = /* determine actual theme */;
const colorValue = colorPalette[color][actualTheme];
// Apply CSS variables
root.style.setProperty('--primary', colorValue);
root.style.setProperty('--ring', colorValue);
};
System Theme Listener
Listens for system theme changes when in 'system' mode:
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
const state = useThemeStore.getState();
if (state.mode === 'system') {
applyTheme('system');
applyPrimaryColor(state.primaryColor, 'system');
}
});
Server Data Validation
The theme store validates data received from the server before applying it:
// Validates mode against allowed values
if (!['light', 'dark', 'system'].includes(serverMode)) {
// Reject invalid mode, keep current
}
// Validates primaryColor against known palette
if (!Object.keys(colorPalette).includes(serverColor)) {
// Reject invalid color, keep current
}
This prevents crashes from malformed server responses or corrupted settings.
Persistence
Theme state is persisted to localStorage:
persist(
(set, get) => ({
/* ... */
}),
{
name: 'flashpoint-theme-settings',
partialize: (state) => ({
mode: state.mode,
primaryColor: state.primaryColor,
}),
onRehydrateStorage: () => (state) => {
if (state) {
// Apply theme after rehydration
applyTheme(state.mode);
applyPrimaryColor(state.primaryColor, state.mode);
// Load from server if authenticated
state.loadThemeFromServer().catch((error) => {
console.debug('Failed to load theme from server:', error);
});
}
},
}
);
useUIStore
Manages UI state like sidebar visibility, view modes, and display preferences.
Location
D:\Repositories\Personal\flashpoint-web\frontend\src\store\ui.ts
State
interface UIState {
sidebarOpen: boolean;
sidebarCollapsed: boolean;
viewMode: ViewMode; // 'grid' | 'list'
selectedGameId: string | null;
cardSize: CardSize; // 'small' | 'medium' | 'large'
listColumns: ListColumns; // 1 | 2 | 3 | 4
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
toggleSidebarCollapsed: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
setViewMode: (mode: ViewMode) => void;
setSelectedGame: (id: string | null) => void;
setCardSize: (size: CardSize) => void;
setListColumns: (columns: ListColumns) => void;
}
Usage
import { useUIStore } from '@/store/ui';
function GameBrowseLayout() {
const viewMode = useUIStore((state) => state.viewMode);
const cardSize = useUIStore((state) => state.cardSize);
const setViewMode = useUIStore((state) => state.setViewMode);
return (
<div>
<button onClick={() => setViewMode('grid')}>Grid View</button>
<button onClick={() => setViewMode('list')}>List View</button>
{viewMode === 'grid' ? (
<GameGrid cardSize={cardSize} />
) : (
<GameList />
)}
</div>
);
}
Actions
Sidebar Actions
const {
sidebarOpen,
toggleSidebar,
setSidebarOpen,
sidebarCollapsed,
toggleSidebarCollapsed,
setSidebarCollapsed,
} = useUIStore();
// Toggle sidebar open/closed (mobile)
toggleSidebar();
// Set specific state
setSidebarOpen(true);
// Toggle collapsed width (desktop)
toggleSidebarCollapsed();
View Mode Actions
const { viewMode, setViewMode } = useUIStore();
setViewMode('grid'); // Switch to grid view
setViewMode('list'); // Switch to list view
Card Size Actions
const { cardSize, setCardSize } = useUIStore();
setCardSize('small'); // Smaller cards
setCardSize('medium'); // Medium cards (default)
setCardSize('large'); // Larger cards
List Columns Actions
const { listColumns, setListColumns } = useUIStore();
setListColumns(1); // Single column
setListColumns(2); // Two columns
setListColumns(3); // Three columns
setListColumns(4); // Four columns
Default Values
{
sidebarOpen: window.innerWidth >= 1024, // Open on desktop, closed on mobile
sidebarCollapsed: false,
viewMode: 'grid',
selectedGameId: null,
cardSize: 'medium',
listColumns: 1
}
Persistence
UI state is persisted to localStorage:
persist(
(set) => ({
/* ... */
}),
{
name: 'flashpoint-ui-settings',
partialize: (state) => ({
sidebarOpen: state.sidebarOpen,
sidebarCollapsed: state.sidebarCollapsed,
cardSize: state.cardSize,
viewMode: state.viewMode,
listColumns: state.listColumns,
// selectedGameId is intentionally not persisted
}),
}
);
Best Practices
1. Selective State Subscription
Only subscribe to the state you need:
// Good - only re-renders when mode changes
const mode = useThemeStore((state) => state.mode);
// Bad - re-renders on any theme store change
const themeStore = useThemeStore();
2. Access Store Outside Components
Use getState() for non-reactive access:
// In axios interceptor
const token = useAuthStore.getState().accessToken;
3. Batch Updates
Zustand automatically batches state updates:
setAuth(user, tokens); // Single update, single re-render
4. DevTools Integration
Enable Redux DevTools for debugging:
import { devtools } from 'zustand/middleware';
export const useAuthStore = create<AuthState>()(
devtools(persist(/* ... */), { name: 'Auth Store' })
);