feat: add logo image and RTL support styles
- Added logo image to assets. - Implemented RTL support styles in rtlSupport.scss for various components including breadcrumb, datatable, and tree node toggle button. chore: configure environment files for production, staging, and development - Created environment.prod.ts for production settings. - Created environment.staging.ts for staging settings. - Created environment.ts for local development settings. feat: define custom theme preset - Added presets.ts to define a custom theme preset using PrimeUIX with specific component styles and semantic colors. chore: set up proxy configuration for local development
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
import { Signal, WritableSignal, computed, signal } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { Maybe } from '../models';
|
||||
|
||||
/**
|
||||
* Base interface for all state objects
|
||||
*/
|
||||
export interface BaseState<T = any> {
|
||||
loading: boolean;
|
||||
error: Maybe<string>;
|
||||
lastId?: string;
|
||||
items?: T;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for paginated lists
|
||||
*/
|
||||
export interface PaginatedState<T = any> extends BaseState {
|
||||
items: T[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for entity states (CRUD operations)
|
||||
*/
|
||||
export interface EntityState<T = any> extends BaseState {
|
||||
entities: Record<string | number, T>;
|
||||
selectedId: Maybe<string | number>;
|
||||
ids: (string | number)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading states for different operations
|
||||
*/
|
||||
export interface LoadingState {
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
updating: boolean;
|
||||
deleting: boolean;
|
||||
fetching: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic action interface
|
||||
*/
|
||||
export interface Action<T = any> {
|
||||
type: string;
|
||||
payload?: T;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* State change event
|
||||
*/
|
||||
export interface StateChange<T = any> {
|
||||
previousState: T;
|
||||
currentState: T;
|
||||
action: Action;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base store class with common functionality
|
||||
*/
|
||||
export abstract class BaseStore<T extends BaseState> {
|
||||
protected readonly _state: WritableSignal<T>;
|
||||
protected readonly _stateSubject: BehaviorSubject<T>;
|
||||
|
||||
// Public readonly signals
|
||||
readonly state: Signal<T>;
|
||||
readonly loading: Signal<boolean>;
|
||||
readonly error: Signal<Maybe<string>>;
|
||||
|
||||
// Observable for RxJS compatibility
|
||||
readonly state$: Observable<T>;
|
||||
|
||||
constructor(initialState: T) {
|
||||
this._state = signal(initialState);
|
||||
this._stateSubject = new BehaviorSubject(initialState);
|
||||
|
||||
// Create computed signals
|
||||
this.state = this._state.asReadonly();
|
||||
this.loading = computed(() => this._state().loading);
|
||||
this.error = computed(() => this._state().error);
|
||||
|
||||
// Observable for RxJS compatibility
|
||||
this.state$ = this._stateSubject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the entire state
|
||||
*/
|
||||
protected setState(newState: T): void {
|
||||
this._state.set(newState);
|
||||
this._stateSubject.next(newState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially update the state
|
||||
*/
|
||||
protected patchState(partialState: Partial<T>): void {
|
||||
const currentState = this._state();
|
||||
const newState = {
|
||||
...currentState,
|
||||
...partialState,
|
||||
};
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set loading state
|
||||
*/
|
||||
protected setLoading(loading: boolean, error: Maybe<string> = null): void {
|
||||
this.patchState({
|
||||
loading,
|
||||
error,
|
||||
} as Partial<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set error state
|
||||
*/
|
||||
protected setError(error: string): void {
|
||||
this.patchState({
|
||||
loading: false,
|
||||
error,
|
||||
} as Partial<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
protected clearError(): void {
|
||||
this.patchState({
|
||||
error: null,
|
||||
} as Partial<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state to initial values
|
||||
*/
|
||||
abstract reset(): void;
|
||||
|
||||
/**
|
||||
* Get current state snapshot
|
||||
*/
|
||||
getCurrentState(): T {
|
||||
return this._state();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity store with CRUD operations
|
||||
*/
|
||||
export abstract class EntityStore<
|
||||
T,
|
||||
TState extends EntityState<T> = EntityState<T>,
|
||||
> extends BaseStore<TState> {
|
||||
// Computed selectors
|
||||
readonly entities = computed(() => this._state().entities);
|
||||
readonly selectedId = computed(() => this._state().selectedId);
|
||||
readonly ids = computed(() => this._state().ids);
|
||||
readonly selectedEntity = computed(() => {
|
||||
const state = this._state();
|
||||
return state.selectedId ? state.entities[state.selectedId] : null;
|
||||
});
|
||||
readonly entitiesArray = computed(() => {
|
||||
const state = this._state();
|
||||
return state.ids.map((id) => state.entities[id]).filter(Boolean);
|
||||
});
|
||||
|
||||
/**
|
||||
* Add or update entities
|
||||
*/
|
||||
protected upsertEntities(entities: T[], getId: (entity: T) => string | number): void {
|
||||
const currentState = this._state();
|
||||
const newEntities = { ...currentState.entities };
|
||||
const newIds = [...currentState.ids];
|
||||
|
||||
entities.forEach((entity) => {
|
||||
const id = getId(entity);
|
||||
newEntities[id] = entity;
|
||||
if (!newIds.includes(id)) {
|
||||
newIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
this.patchState({
|
||||
entities: newEntities,
|
||||
ids: newIds,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove entity
|
||||
*/
|
||||
protected removeEntity(id: string | number): void {
|
||||
const currentState = this._state();
|
||||
const newEntities = { ...currentState.entities };
|
||||
const newIds = currentState.ids.filter((entityId) => entityId !== id);
|
||||
|
||||
delete newEntities[id];
|
||||
|
||||
this.patchState({
|
||||
entities: newEntities,
|
||||
ids: newIds,
|
||||
selectedId: currentState.selectedId === id ? null : currentState.selectedId,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select entity
|
||||
*/
|
||||
selectEntity(id: Maybe<string | number>): void {
|
||||
this.patchState({
|
||||
selectedId: id,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all entities
|
||||
*/
|
||||
protected clearEntities(): void {
|
||||
this.patchState({
|
||||
entities: {},
|
||||
ids: [],
|
||||
selectedId: null,
|
||||
} as unknown as Partial<TState>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated store for lists with pagination
|
||||
*/
|
||||
export abstract class PaginatedStore<
|
||||
T,
|
||||
TState extends PaginatedState<T> = PaginatedState<T>,
|
||||
> extends BaseStore<TState> {
|
||||
// Computed selectors
|
||||
readonly items = computed(() => this._state().items);
|
||||
readonly totalCount = computed(() => this._state().totalCount);
|
||||
readonly currentPage = computed(() => this._state().currentPage);
|
||||
readonly pageSize = computed(() => this._state().pageSize);
|
||||
readonly hasMore = computed(() => this._state().hasMore);
|
||||
readonly totalPages = computed(() => {
|
||||
const state = this._state();
|
||||
return Math.ceil(state.totalCount / state.pageSize);
|
||||
});
|
||||
|
||||
/**
|
||||
* Set items for current page
|
||||
*/
|
||||
protected setItems(items: T[], totalCount: number, currentPage: number): void {
|
||||
const state = this._state();
|
||||
this.patchState({
|
||||
items,
|
||||
totalCount,
|
||||
currentPage,
|
||||
hasMore: currentPage * state.pageSize < totalCount,
|
||||
loading: false,
|
||||
error: null,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append items (for infinite scroll)
|
||||
*/
|
||||
protected appendItems(items: T[], totalCount: number): void {
|
||||
const state = this._state();
|
||||
const allItems = [...state.items, ...items];
|
||||
const newPage = state.currentPage + 1;
|
||||
|
||||
this.patchState({
|
||||
items: allItems,
|
||||
totalCount,
|
||||
currentPage: newPage,
|
||||
hasMore: allItems.length < totalCount,
|
||||
loading: false,
|
||||
error: null,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set page size
|
||||
*/
|
||||
setPageSize(pageSize: number): void {
|
||||
this.patchState({
|
||||
pageSize,
|
||||
currentPage: 1,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to page
|
||||
*/
|
||||
setCurrentPage(page: number): void {
|
||||
this.patchState({
|
||||
currentPage: page,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { BaseState, BaseStore } from '../base-store';
|
||||
|
||||
/**
|
||||
* Global application state
|
||||
*/
|
||||
export interface GlobalState extends BaseState {
|
||||
// UI State
|
||||
sidebarCollapsed: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
language: 'fa' | 'en';
|
||||
|
||||
// App State
|
||||
isOnline: boolean;
|
||||
notifications: AppNotification[];
|
||||
breadcrumbs: Breadcrumb[];
|
||||
|
||||
// User preferences
|
||||
preferences: UserPreferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application notification
|
||||
*/
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
read: boolean;
|
||||
actions?: NotificationAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification action
|
||||
*/
|
||||
export interface NotificationAction {
|
||||
label: string;
|
||||
action: () => void;
|
||||
type?: 'primary' | 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb item
|
||||
*/
|
||||
export interface Breadcrumb {
|
||||
label: string;
|
||||
url?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User preferences
|
||||
*/
|
||||
export interface UserPreferences {
|
||||
dashboardLayout: 'grid' | 'list';
|
||||
itemsPerPage: number;
|
||||
autoSave: boolean;
|
||||
followSystemTheme: boolean;
|
||||
enableThemeTransitions: boolean;
|
||||
notifications: {
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
sound: boolean;
|
||||
};
|
||||
accessibility: {
|
||||
highContrast: boolean;
|
||||
fontSize: 'small' | 'medium' | 'large';
|
||||
reducedMotion: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Global state store
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class GlobalStore extends BaseStore<GlobalState> {
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
loading: false,
|
||||
error: null,
|
||||
sidebarCollapsed: false,
|
||||
theme: 'light',
|
||||
language: 'fa',
|
||||
isOnline: navigator.onLine,
|
||||
notifications: [],
|
||||
breadcrumbs: [],
|
||||
preferences: {
|
||||
dashboardLayout: 'grid',
|
||||
itemsPerPage: 10,
|
||||
autoSave: true,
|
||||
followSystemTheme: false,
|
||||
enableThemeTransitions: true,
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
sound: false,
|
||||
},
|
||||
accessibility: {
|
||||
highContrast: false,
|
||||
fontSize: 'medium',
|
||||
reducedMotion: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.initializeState();
|
||||
}
|
||||
|
||||
// Computed selectors
|
||||
readonly sidebarCollapsed = this.computed((state) => state.sidebarCollapsed);
|
||||
readonly theme = this.computed((state) => state.theme);
|
||||
readonly language = this.computed((state) => state.language);
|
||||
readonly isOnline = this.computed((state) => state.isOnline);
|
||||
readonly notifications = this.computed((state) => state.notifications);
|
||||
readonly unreadNotifications = this.computed((state) =>
|
||||
state.notifications.filter((n) => !n.read),
|
||||
);
|
||||
readonly breadcrumbs = this.computed((state) => state.breadcrumbs);
|
||||
readonly preferences = this.computed((state) => state.preferences);
|
||||
|
||||
/**
|
||||
* Initialize state from localStorage and browser events
|
||||
*/
|
||||
private initializeState(): void {
|
||||
// Load preferences from localStorage
|
||||
const savedPreferences = localStorage.getItem('user_preferences');
|
||||
if (savedPreferences) {
|
||||
try {
|
||||
const preferences = JSON.parse(savedPreferences);
|
||||
this.patchState({ preferences });
|
||||
} catch (error) {
|
||||
console.error('Error loading user preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load theme from localStorage
|
||||
const savedTheme = localStorage.getItem('app_theme') as 'light' | 'dark';
|
||||
if (savedTheme) {
|
||||
this.patchState({ theme: savedTheme });
|
||||
}
|
||||
|
||||
// Load sidebar state from localStorage
|
||||
const sidebarCollapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||
this.patchState({ sidebarCollapsed });
|
||||
|
||||
// Listen to online/offline events
|
||||
window.addEventListener('online', () => this.setOnlineStatus(true));
|
||||
window.addEventListener('offline', () => this.setOnlineStatus(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create computed signals
|
||||
*/
|
||||
private computed<K>(selector: (state: GlobalState) => K) {
|
||||
return () => selector(this._state());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle sidebar collapse state
|
||||
*/
|
||||
toggleSidebar(): void {
|
||||
const collapsed = !this._state().sidebarCollapsed;
|
||||
this.patchState({ sidebarCollapsed: collapsed });
|
||||
localStorage.setItem('sidebar_collapsed', collapsed.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sidebar collapse state
|
||||
*/
|
||||
setSidebarCollapsed(collapsed: boolean): void {
|
||||
this.patchState({ sidebarCollapsed: collapsed });
|
||||
localStorage.setItem('sidebar_collapsed', collapsed.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle theme
|
||||
*/
|
||||
toggleTheme(): void {
|
||||
const theme = this._state().theme === 'light' ? 'dark' : 'light';
|
||||
this.setTheme(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set theme
|
||||
*/
|
||||
setTheme(theme: 'light' | 'dark'): void {
|
||||
this.patchState({ theme });
|
||||
localStorage.setItem('app_theme', theme);
|
||||
|
||||
// Apply theme to document
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set language
|
||||
*/
|
||||
setLanguage(language: 'fa' | 'en'): void {
|
||||
this.patchState({ language });
|
||||
localStorage.setItem('app_language', language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set online status
|
||||
*/
|
||||
setOnlineStatus(isOnline: boolean): void {
|
||||
this.patchState({ isOnline });
|
||||
|
||||
if (isOnline) {
|
||||
this.addNotification({
|
||||
type: 'success',
|
||||
title: 'اتصال برقرار شد',
|
||||
message: 'اتصال اینترنت برقرار شد',
|
||||
});
|
||||
} else {
|
||||
this.addNotification({
|
||||
type: 'warning',
|
||||
title: 'قطع اتصال',
|
||||
message: 'اتصال اینترنت قطع شده است',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add notification
|
||||
*/
|
||||
addNotification(notification: Omit<AppNotification, 'id' | 'timestamp' | 'read'>): void {
|
||||
const newNotification: AppNotification = {
|
||||
...notification,
|
||||
id: this.generateId(),
|
||||
timestamp: Date.now(),
|
||||
read: false,
|
||||
};
|
||||
|
||||
const notifications = [newNotification, ...this._state().notifications];
|
||||
this.patchState({ notifications });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*/
|
||||
markNotificationRead(id: string): void {
|
||||
const notifications = this._state().notifications.map((n) =>
|
||||
n.id === id ? { ...n, read: true } : n,
|
||||
);
|
||||
this.patchState({ notifications });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read
|
||||
*/
|
||||
markAllNotificationsRead(): void {
|
||||
const notifications = this._state().notifications.map((n) => ({ ...n, read: true }));
|
||||
this.patchState({ notifications });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove notification
|
||||
*/
|
||||
removeNotification(id: string): void {
|
||||
const notifications = this._state().notifications.filter((n) => n.id !== id);
|
||||
this.patchState({ notifications });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all notifications
|
||||
*/
|
||||
clearNotifications(): void {
|
||||
this.patchState({ notifications: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set breadcrumbs
|
||||
*/
|
||||
setBreadcrumbs(breadcrumbs: Breadcrumb[]): void {
|
||||
this.patchState({ breadcrumbs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user preferences
|
||||
*/
|
||||
updatePreferences(preferences: Partial<UserPreferences>): void {
|
||||
const currentPreferences = this._state().preferences;
|
||||
const newPreferences = { ...currentPreferences, ...preferences };
|
||||
|
||||
this.patchState({ preferences: newPreferences });
|
||||
localStorage.setItem('user_preferences', JSON.stringify(newPreferences));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state to initial values
|
||||
*/
|
||||
reset(): void {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: null,
|
||||
sidebarCollapsed: false,
|
||||
theme: 'light',
|
||||
language: 'fa',
|
||||
isOnline: navigator.onLine,
|
||||
notifications: [],
|
||||
breadcrumbs: [],
|
||||
preferences: {
|
||||
dashboardLayout: 'grid',
|
||||
itemsPerPage: 10,
|
||||
autoSave: true,
|
||||
followSystemTheme: false,
|
||||
enableThemeTransitions: true,
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
sound: false,
|
||||
},
|
||||
accessibility: {
|
||||
highContrast: false,
|
||||
fontSize: 'medium',
|
||||
reducedMotion: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID
|
||||
*/
|
||||
private generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Base store classes and utilities
|
||||
export * from './base-store';
|
||||
|
||||
// Global state management
|
||||
export * from './global/global.store';
|
||||
|
||||
// State effects and utilities
|
||||
export * from './state-effects.service';
|
||||
|
||||
// State management interfaces
|
||||
export type {
|
||||
Action,
|
||||
BaseState,
|
||||
EntityState,
|
||||
LoadingState,
|
||||
PaginatedState,
|
||||
StateChange,
|
||||
} from './base-store';
|
||||
|
||||
export type {
|
||||
AppNotification,
|
||||
Breadcrumb,
|
||||
GlobalState,
|
||||
NotificationAction,
|
||||
UserPreferences,
|
||||
} from './global/global.store';
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { combineLatest, map, shareReplay, startWith } from 'rxjs';
|
||||
import { LOCAL_STORAGE_KEYS } from 'src/assets/constants';
|
||||
import { AuthStore } from '../../modules/auth/state/auth.store';
|
||||
import { GlobalStore } from './global/global.store';
|
||||
|
||||
/**
|
||||
* State effects service for handling cross-store interactions
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StateEffects {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly globalStore = inject(GlobalStore);
|
||||
|
||||
constructor() {
|
||||
this.initializeEffects();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all effects
|
||||
*/
|
||||
private initializeEffects(): void {
|
||||
this.setupAuthEffects();
|
||||
this.setupGlobalEffects();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup authentication-related effects
|
||||
*/
|
||||
private setupAuthEffects(): void {
|
||||
// Update global notifications when auth state changes
|
||||
this.authStore.state$.subscribe((authState) => {
|
||||
// if (authState.isAuthenticated && authState.user) {
|
||||
// this.globalStore.addNotification({
|
||||
// type: 'success',
|
||||
// title: 'خوش آمدید',
|
||||
// message: `${authState.user.firstName} ${authState.user.lastName} عزیز، خوش آمدید`,
|
||||
// });
|
||||
// }
|
||||
});
|
||||
|
||||
// Handle session expiry warnings
|
||||
this.authStore.state$.subscribe((authState) => {
|
||||
if (authState.sessionExpiry) {
|
||||
const timeUntilExpiry = authState.sessionExpiry - Date.now();
|
||||
const minutesUntilExpiry = Math.floor(timeUntilExpiry / 60000);
|
||||
|
||||
// Show warning when 5 minutes remain
|
||||
if (minutesUntilExpiry === 5) {
|
||||
this.globalStore.addNotification({
|
||||
type: 'warning',
|
||||
title: 'هشدار انقضای جلسه',
|
||||
message: 'جلسه شما در 5 دقیقه منقضی میشود',
|
||||
actions: [
|
||||
{
|
||||
label: 'تمدید جلسه',
|
||||
action: () => this.authStore.refreshAuthToken().subscribe(),
|
||||
type: 'primary',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global effects
|
||||
*/
|
||||
private setupGlobalEffects(): void {
|
||||
// Auto-save preferences when they change
|
||||
this.globalStore.state$.subscribe((globalState) => {
|
||||
localStorage.setItem('user_preferences', JSON.stringify(globalState.preferences));
|
||||
});
|
||||
|
||||
// Handle theme changes
|
||||
this.globalStore.state$.subscribe((globalState) => {
|
||||
document.documentElement.setAttribute('data-theme', globalState.theme);
|
||||
});
|
||||
|
||||
// Handle language changes
|
||||
this.globalStore.state$.subscribe((globalState) => {
|
||||
document.documentElement.setAttribute('lang', globalState.language);
|
||||
document.documentElement.setAttribute('dir', globalState.language === 'fa' ? 'rtl' : 'ltr');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State selectors for complex cross-store queries
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StateSelectors {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly globalStore = inject(GlobalStore);
|
||||
|
||||
/**
|
||||
* Combined application state for UI components
|
||||
*/
|
||||
readonly appState$ = combineLatest([this.authStore.state$, this.globalStore.state$]).pipe(
|
||||
map(([authState, globalState]) => ({
|
||||
isAuthenticated: authState.isAuthenticated,
|
||||
user: authState.user,
|
||||
userRole: authState.user?.role,
|
||||
loading: authState.loading || globalState.loading,
|
||||
sidebarCollapsed: globalState.sidebarCollapsed,
|
||||
theme: globalState.theme,
|
||||
language: globalState.language,
|
||||
notifications: globalState.notifications,
|
||||
unreadCount: globalState.notifications.filter((n) => !n.read).length,
|
||||
breadcrumbs: globalState.breadcrumbs,
|
||||
isOnline: globalState.isOnline,
|
||||
})),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
/**
|
||||
* User permissions combined with role-based access
|
||||
*/
|
||||
readonly userAccess$ = this.authStore.state$.pipe(
|
||||
map((authState) => ({
|
||||
isAuthenticated: authState.isAuthenticated,
|
||||
role: authState.user?.role,
|
||||
permissions: authState.permissions,
|
||||
canAccessAdmin: authState.user?.role === 'ADMIN' || authState.user?.role === 'SUPERADMIN',
|
||||
canAccessStudent: authState.user?.role === 'STUDENTS',
|
||||
canAccessGrader: authState.user?.role === 'GRADER',
|
||||
canAccessSchools: authState.user?.role === 'SCHOOL',
|
||||
canAccessSuperAdmin: authState.user?.role === 'SUPERADMIN',
|
||||
})),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
/**
|
||||
* Navigation state for header/sidebar components
|
||||
*/
|
||||
readonly navigationState$ = combineLatest([this.authStore.state$, this.globalStore.state$]).pipe(
|
||||
map(([authState, globalState]) => ({
|
||||
user: authState.user,
|
||||
sidebarCollapsed: globalState.sidebarCollapsed,
|
||||
breadcrumbs: globalState.breadcrumbs,
|
||||
// userInitials: authState.user
|
||||
// ? `${authState.user.firstName[0]}${authState.user.lastName[0]}`
|
||||
// : '',
|
||||
// userFullName: authState.user ? `${authState.user.firstName} ${authState.user.lastName}` : '',
|
||||
})),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
/**
|
||||
* Notification state for notification components
|
||||
*/
|
||||
readonly notificationState$ = this.globalStore.state$.pipe(
|
||||
map((globalState) => ({
|
||||
notifications: globalState.notifications,
|
||||
unreadCount: globalState.notifications.filter((n) => !n.read).length,
|
||||
hasUnread: globalState.notifications.some((n) => !n.read),
|
||||
recentNotifications: globalState.notifications
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, 5),
|
||||
})),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
/**
|
||||
* Loading state across all stores
|
||||
*/
|
||||
readonly loadingState$ = combineLatest([this.authStore.state$, this.globalStore.state$]).pipe(
|
||||
map(([authState, globalState]) => ({
|
||||
isLoading: authState.loading || globalState.loading,
|
||||
authLoading: authState.loading,
|
||||
globalLoading: globalState.loading,
|
||||
})),
|
||||
startWith({ isLoading: false, authLoading: false, globalLoading: false }),
|
||||
shareReplay(1),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* State utilities for common operations
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StateUtils {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly globalStore = inject(GlobalStore);
|
||||
|
||||
/**
|
||||
* Check if user can access route
|
||||
*/
|
||||
canAccessRoute(requiredRoles?: string[], requiredPermissions?: string[]): boolean {
|
||||
const authState = this.authStore.getCurrentState();
|
||||
|
||||
if (!authState.isAuthenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (requiredRoles && requiredRoles.length > 0) {
|
||||
const userRole = authState.user?.role;
|
||||
if (!userRole || !requiredRoles.includes(userRole)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredPermissions && requiredPermissions.length > 0) {
|
||||
const hasAllPermissions = requiredPermissions.every((permission) =>
|
||||
authState.permissions.includes(permission),
|
||||
);
|
||||
if (!hasAllPermissions) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success message
|
||||
*/
|
||||
showSuccess(title: string, message: string): void {
|
||||
this.globalStore.addNotification({
|
||||
type: 'success',
|
||||
title,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
*/
|
||||
showError(title: string, message: string): void {
|
||||
this.globalStore.addNotification({
|
||||
type: 'error',
|
||||
title,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show warning message
|
||||
*/
|
||||
showWarning(title: string, message: string): void {
|
||||
this.globalStore.addNotification({
|
||||
type: 'warning',
|
||||
title,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show info message
|
||||
*/
|
||||
showInfo(title: string, message: string): void {
|
||||
this.globalStore.addNotification({
|
||||
type: 'info',
|
||||
title,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update page breadcrumbs
|
||||
*/
|
||||
setBreadcrumbs(breadcrumbs: Array<{ label: string; url?: string; icon?: string }>): void {
|
||||
this.globalStore.setBreadcrumbs(breadcrumbs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user display name
|
||||
*/
|
||||
getUserDisplayName(): string {
|
||||
const user = this.authStore.getCurrentState().user;
|
||||
return '';
|
||||
// return user ? `${user.firstName} ${user.lastName}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user initials
|
||||
*/
|
||||
getUserInitials(): string {
|
||||
const user = this.authStore.getCurrentState().user;
|
||||
return '';
|
||||
// return user ? `${user.firstName[0]}${user.lastName[0]}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is online
|
||||
*/
|
||||
isOnline(): boolean {
|
||||
return this.globalStore.getCurrentState().isOnline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current theme
|
||||
*/
|
||||
getCurrentTheme(): 'light' | 'dark' {
|
||||
return this.globalStore.getCurrentState().theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current language
|
||||
*/
|
||||
getCurrentLanguage(): 'fa' | 'en' {
|
||||
return this.globalStore.getCurrentState().language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all application state (useful for logout)
|
||||
*/
|
||||
clearAllState(): void {
|
||||
this.authStore.reset();
|
||||
this.globalStore.reset();
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.USER_DATA);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.LAST_LOGIN_TIME);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.LOGIN_ATTEMPTS);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user