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:
2025-12-04 21:07:18 +03:30
parent c58210cdbd
commit 07fec02ea1
164 changed files with 20175 additions and 735 deletions
+305
View File
@@ -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>);
}
}
+337
View File
@@ -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);
}
}
+26
View File
@@ -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';
+326
View File
@@ -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);
}
}