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
+12
View File
@@ -0,0 +1,12 @@
<div
class="relative w-full flex items-center justify-center gap-2 cursor-pointer"
(click)="copy($event)"
aria-label="کپی"
>
<div class="flex items-center gap-2">
<i class="pi pi-copy text-lg"></i>
</div>
{{ text }}
<p-popover #op>کپی شد</p-popover>
</div>
+55
View File
@@ -0,0 +1,55 @@
import { CommonModule } from '@angular/common';
import { Component, Input, ViewChild } from '@angular/core';
import { PopoverModule } from 'primeng/popover';
@Component({
selector: 'uikit-copy',
standalone: true,
imports: [CommonModule, PopoverModule],
templateUrl: './copy.component.html',
})
export class UikitCopyComponent {
@Input() text: string | null = null;
@ViewChild('op') op: any;
copied = false;
async copy(event?: Event) {
try {
const payload = this.text ?? '';
if (navigator && navigator.clipboard && payload !== '') {
await navigator.clipboard.writeText(payload);
this.showCopiedTooltip(event);
}
} catch (err) {
// fallback: try execCommand (older browsers) or ignore
try {
const textarea = document.createElement('textarea');
textarea.value = this.text ?? '';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
this.showCopiedTooltip(event);
} catch (e) {
console.error('Copy failed', e);
}
}
}
private showCopiedTooltip(event?: Event) {
this.copied = true;
try {
this.op?.show(event);
} catch (e) {
// ignore if popover not available
}
setTimeout(() => {
try {
this.op?.hide();
} catch (_) {}
this.copied = false;
}, 1400);
}
}
@@ -0,0 +1,20 @@
<uikit-field [label]="label" [control]="control" class="w-full datepicker">
<div #wrapper [attr.data-disable]="disabled" [attr.data-name]="name" class="w-full">
<input pInputText data-input [attr.placeholder]="placeholder" [name]="name" class="w-full" />
</div>
<div class="flat"></div>
</uikit-field>
<!--
<div #wrapper class="flex items-center gap-2" data-enable-time="false" data-click-opens="true">
<input pInputText class="w-full bg-white text-sm rounded border p-2" data-input [attr.placeholder]="placeholder" />
<button pButton type="button" class="p-button-text text-gray-500" data-toggle aria-label="toggle">
<i class="pi pi-calendar"></i>
</button>
<button pButton type="button" class="p-button-text text-gray-500" data-clear aria-label="clear">
<i class="pi pi-times"></i>
</button>
</div> -->
@@ -0,0 +1,48 @@
import { Maybe } from '@/core';
import { CommonModule } from '@angular/common';
import {
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import flatpickr from 'flatpickr-wrap';
import { BaseOptions } from 'flatpickr-wrap/dist/types/options';
import { InputTextModule } from 'primeng/inputtext';
import { UikitFieldComponent } from '../uikit-field.component';
@Component({
selector: 'uikit-datepicker',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, InputTextModule, UikitFieldComponent],
templateUrl: './datepicker.component.html',
})
export class UikitFlatpickrJalaliComponent implements OnInit {
@Input() control?: FormControl<Maybe<string>>;
@Input() placeholder = 'انتخاب تاریخ';
@Input() label = 'تاریخ';
@Input() disabled = false;
@Input() name: string = 'datepicker';
@Input() options: Partial<BaseOptions> = {};
@Output() valueChange = new EventEmitter<string>();
@ViewChild('wrapper', { static: true }) wrapperRef!: ElementRef<HTMLElement>;
private fpInstance: any | null = null;
ngOnInit(): void {
this.fpInstance = flatpickr(this.wrapperRef.nativeElement, {
...this.options,
altInputClass: 'p-inputtext w-full',
onChange: (selectedDates: Date[], dateStr: string) => {
this.valueChange.emit(dateStr);
if (this.control) this.control.setValue(dateStr);
},
});
}
}
@@ -0,0 +1,30 @@
<uikit-field [label]="label" [name]="name" [control]="control">
<div class="grid grid-cols-2 items-center gap-4">
<p-autoComplete
[formControl]="hoursCtrl"
[suggestions]="filteredHours"
(completeMethod)="filterHours($event)"
[dropdown]="true"
[forceSelection]="false"
placeholder="ساعت"
inputClass="w-full"
type="number"
></p-autoComplete>
<p-autoComplete
[formControl]="minutesCtrl"
[suggestions]="filteredMinutes"
(completeMethod)="filterMinutes($event)"
[dropdown]="true"
[forceSelection]="false"
placeholder="دقیقه"
type="number"
inputClass="w-full"
></p-autoComplete>
</div>
@if (hoursCtrl.value || minutesCtrl.value) {
<span class="text-xs mt-1 text-muted-color">
مدت زمان آزمون را {{ hoursCtrl.value || 0 }} ساعت و {{ minutesCtrl.value || 0 }} دقیقه انتخاب کردید.
</span>
}
</uikit-field>
@@ -0,0 +1,93 @@
import { UikitFieldComponent } from '@/uikit/uikit-field.component';
import { CommonModule } from '@angular/common';
import { Component, Input, OnInit } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { AutoCompleteModule } from 'primeng/autocomplete';
@Component({
selector: 'uikit-duration',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, AutoCompleteModule, UikitFieldComponent],
templateUrl: './duration-picker.component.html',
})
export class UikitDurationPickerComponent implements OnInit {
@Input() label = 'مدت زمان';
@Input() name = 'duration';
@Input() control!: FormControl<string | null>;
hoursCtrl = new FormControl<string | null>('0');
minutesCtrl = new FormControl<string | null>('0');
hoursOptions: string[] = Array.from({ length: 24 }, (_, i) => String(i));
minutesOptions: string[] = ['0', '5', '10', '15', '20', '30', '45'];
filteredHours: string[] = [];
filteredMinutes: string[] = [];
ngOnInit(): void {
// initialize from parent control if present
if (this.control) {
const total = Number(this.control.value) || 0;
const h = Math.floor(total / 60);
const m = total % 60;
this.hoursCtrl.setValue(String(h));
this.minutesCtrl.setValue(String(m));
this.hoursCtrl.valueChanges.subscribe(() => {
if (this.toNumberSafe(this.hoursCtrl.value) > 23) {
this.hoursCtrl.setValue('23', { emitEvent: false });
}
this.syncToParent();
});
this.minutesCtrl.valueChanges.subscribe(() => {
if (this.toNumberSafe(this.minutesCtrl.value) > 59) {
this.minutesCtrl.setValue('59', { emitEvent: false });
}
this.syncToParent();
});
this.control.valueChanges.subscribe((v) => this.syncFromParent(v));
} else {
this.hoursCtrl.valueChanges.subscribe(() => this.syncToParent());
this.minutesCtrl.valueChanges.subscribe(() => this.syncToParent());
}
}
private toNumberSafe(s: string | null | undefined) {
if (s == null || s === '') return 0;
const n = Number(s);
return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0;
}
syncToParent() {
if (!this.control) return;
const h = this.toNumberSafe(this.hoursCtrl.value);
const m = this.toNumberSafe(this.minutesCtrl.value);
const total = h * 60 + m;
// write numeric minutes into parent control only when changed
const current = this.control.value;
if (String(current) !== String(total)) {
this.control.setValue(String(total), { emitEvent: false });
}
}
syncFromParent(v: any) {
const total = Number(v) || 0;
const h = Math.floor(total / 60);
const m = total % 60;
if (String(h) !== this.hoursCtrl.value)
this.hoursCtrl.setValue(String(h), { emitEvent: false });
if (String(m) !== this.minutesCtrl.value)
this.minutesCtrl.setValue(String(m), { emitEvent: false });
}
filterHours(event: { query: string }) {
const q = (event.query || '').toString();
this.filteredHours = this.hoursOptions.filter((x) => x.startsWith(q));
}
filterMinutes(event: { query: string }) {
const q = (event.query || '').toString();
this.filteredMinutes = this.minutesOptions.filter((x) => x.startsWith(q));
}
}
+7
View File
@@ -0,0 +1,7 @@
export * from './copy/copy.component';
export * from './datepicker/datepicker.component';
export * from './duration-picker/duration-picker.component';
export * from './paginator.component';
export * from './uikit-emptystate.component';
export * from './uikit-field.component';
export * from './uikit-label.component';
+19
View File
@@ -0,0 +1,19 @@
<div class="flex items-center justify-center gap-3 p-4">
<button
pButton
icon="pi pi-angle-right"
size="small"
severity="secondary"
[disabled]="loading || currentPage === 1"
(click)="prevPage()"
></button>
<span class="text-pretty text-sm text-muted-color">صفحه {{ currentPage }} از {{ totalPages() }}</span>
<button
pButton
icon="pi pi-angle-left"
size="small"
severity="secondary"
[disabled]="loading || currentPage >= totalPages()"
(click)="nextPage()"
></button>
</div>
+36
View File
@@ -0,0 +1,36 @@
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { ButtonModule } from 'primeng/button';
@Component({
selector: 'app-paginator',
templateUrl: './paginator.component.html',
imports: [ButtonModule],
})
export class PaginatorComponent {
@Input() totalRecords!: number;
@Input() currentPage: number = 1;
@Input() perPage: number = 10;
@Input() loading: boolean = false;
@Output() onChange = new EventEmitter<number>();
totalPages = signal<number>(0);
ngOnChanges() {
this.totalPages.set(Math.ceil(this.totalRecords / this.perPage));
}
onPageChange(newPage: number) {
this.onChange.emit(newPage);
}
prevPage() {
if (this.currentPage > 1) {
this.onPageChange(this.currentPage - 1);
}
}
nextPage() {
if (this.currentPage < this.totalPages()) {
this.onPageChange(this.currentPage + 1);
}
}
}
@@ -0,0 +1,33 @@
<div class="flex flex-col gap-3">
<uikit-field [label]="label" [control]="control" class="w-full">
<p-inputGroup>
<input
pInputText
[formControl]="control"
[placeholder]="placeholder"
[readonly]="searchedLoading()"
[minLength]="6"
[maxLength]="6"
[invalid]="control.touched && (control.invalid || !searchedItem())"
/>
@if (searchedLoading()) {
<p-inputGroupAddon>
<p-progressSpinner class="!w-4 !h-4"></p-progressSpinner>
</p-inputGroupAddon>
}
</p-inputGroup>
</uikit-field>
@if (searchedLoading()) {
<div class="flex items-center justify-center">
<p-skeleton />
</div>
} @else if (searchedItem()) {
<p-message severity="success" [closable]="false" variant="text" icon="pi pi-check-circle">
{{ foundedMessage(searchedItem()!) }}
</p-message>
} @else if (control.value!.length === 6 && itemNotFound()) {
<p-message severity="error" [closable]="false" variant="text" icon="pi pi-times-circle">
{{ notFoundMessage }}
</p-message>
}
</div>
@@ -0,0 +1,67 @@
import { Maybe } from '@/core';
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { InputGroup } from 'primeng/inputgroup';
import { InputGroupAddon } from 'primeng/inputgroupaddon';
import { InputTextModule } from 'primeng/inputtext';
import { Message } from 'primeng/message';
import { ProgressSpinner } from 'primeng/progressspinner';
import { Skeleton } from 'primeng/skeleton';
import { debounceTime, filter, Observable } from 'rxjs';
import { UikitFieldComponent } from '../uikit-field.component';
@Component({
selector: 'uikit-search-field',
templateUrl: './search-field.component.html',
imports: [
UikitFieldComponent,
InputGroup,
InputGroupAddon,
ProgressSpinner,
Skeleton,
Message,
InputTextModule,
ReactiveFormsModule,
],
})
export class UikitSearchFieldComponent<T> {
@Input() label!: string;
@Input() placeholder: string = 'جستجو...';
@Input() debounceTime: number = 300;
@Input() notFoundMessage: string = 'موردی یافت نشد.';
@Input() foundedMessage: (item: T) => string = () => 'مورد یافت شد.';
@Input() search!: (query: string) => Observable<Maybe<T>>;
@Output() searchResult = new EventEmitter<Maybe<T>>();
control = new FormControl<string>('');
constructor() {
this.control.valueChanges
.pipe(
debounceTime(this.debounceTime),
filter((val) => !!val && val.length === 6),
)
.subscribe(() => {
this.searchedLoading.set(true);
this.search(this.control.value!).subscribe({
next: (item) => {
this.searchedItem.set(item);
this.searchedLoading.set(false);
this.itemNotFound.set(!item);
this.searchResult.emit(item);
},
error: () => {
this.searchedLoading.set(false);
this.itemNotFound.set(true);
this.searchResult.emit(null);
},
});
});
}
submitLoading = signal(false);
searchedLoading = signal(false);
searchedItem = signal<Maybe<T>>(null);
itemNotFound = signal(false);
}
@@ -0,0 +1,25 @@
<!-- Table Header Cell -->
@if (type === "th") {
<th [class]="widthClass" class="text-center">عملیات</th>
}
<!-- Table Body Cell -->
@if (type === "td") {
<td [class]="widthClass" class="text-center flex items-center gap-1">
@if (showEdit) {
<p-button (click)="edit.emit()" title="ویرایش">
<i class="pi pi-pencil"></i>
</p-button>
}
@if (showDelete) {
<p-button (click)="delete.emit()" title="حذف">
<i class="pi pi-trash"></i>
</p-button>
}
@if (showDetails) {
<p-button (click)="details.emit()" title="جزئیات">
<i class="pi pi-info-circle"></i>
</p-button>
}
</td>
}
@@ -0,0 +1,16 @@
<div class="flex flex-col items-center justify-center py-12 text-center bg-surface-overlay">
<ng-container>
<i [class]="icon + ' !text-6xl mb-4 text-surface-500'" aria-hidden="true"></i>
</ng-container>
<span class="text-xl font-semibold mb-2 text-surface-500">{{ title }}</span>
@if (description) {
<p class="text-surface-500 inline-block">{{ description }}</p>
}
<ng-content select="[cta]" #cta class="mt-4"></ng-content>
@if (!hasCtaContent && ctaLabel && showCTA) {
<p-button class="mt-2" size="small" (click)="ctaClick.emit()">
{{ ctaLabel }}
<i class="pi pi-plus" pButtonIcon></i>
</p-button>
}
</div>
@@ -0,0 +1,36 @@
import {
AfterContentInit,
Component,
ContentChild,
ElementRef,
EventEmitter,
Input,
Output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Button } from 'primeng/button';
@Component({
selector: 'uikit-empty-state',
standalone: true,
imports: [CommonModule, Button],
templateUrl: './uikit-emptystate.component.html',
})
export class UikitEmptyStateComponent implements AfterContentInit {
@ContentChild('cta', { static: false, read: ElementRef })
ctaContent?: ElementRef;
hasCtaContent = false;
@Input() title: string = 'متاسفانه موردی یافت نشد.';
@Input() description?: string;
@Input() icon: string = 'pi pi-database';
@Input() ctaLabel?: string;
@Input() showCTA?: boolean = true;
@Output() ctaClick = new EventEmitter<void>();
ngAfterContentInit() {
this.hasCtaContent = !!this.ctaContent;
}
}
+13
View File
@@ -0,0 +1,13 @@
<div class="flex flex-col gap-1">
<uikit-label [name]="name" [invalid]="!!invalid || showErrors">
{{ label }}
</uikit-label>
<ng-content></ng-content>
@if (showErrors) {
<ng-container>
<p-message severity="error" size="small" variant="simple" class="formHint">
{{ errors[0] }}
</p-message>
</ng-container>
}
</div>
+36
View File
@@ -0,0 +1,36 @@
import { FormErrorsService } from '@/core/services/form-errors.service';
import { CommonModule } from '@angular/common';
import { Component, Input, inject } from '@angular/core';
import { AbstractControl } from '@angular/forms';
import { MessageModule } from 'primeng/message';
import { UikitLabelComponent } from './uikit-label.component';
type PSize = 'small' | 'normal' | 'large';
@Component({
selector: 'uikit-field',
standalone: true,
imports: [CommonModule, UikitLabelComponent, MessageModule],
templateUrl: './uikit-field.component.html',
})
export class UikitFieldComponent {
@Input() label!: string;
@Input() name!: string;
@Input() pSize: PSize = 'normal';
@Input() className?: string;
@Input() invalid?: boolean = false;
// accept a FormControl or AbstractControl to display errors for
@Input() control?: AbstractControl | null;
private errorsService = inject(FormErrorsService);
get showErrors(): boolean {
if (!this.control) return !!this.invalid;
return !!(this.control.invalid && (this.control.touched || this.control.dirty));
}
get errors(): string[] {
if (!this.control) return this.invalid ? ['خطا'] : [];
return this.errorsService.getErrors(this.control, this.label).map((m) => m.message);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
type PSize = 'small' | 'normal' | 'large';
@Component({
selector: 'uikit-label',
standalone: true,
imports: [CommonModule],
template: `
<label [attr.for]="name" [ngClass]="computedClass" [attr.class]="className" class="select-none">
<ng-content></ng-content>
</label>
`,
})
export class UikitLabelComponent {
@Input() name?: string;
@Input() pSize: PSize = 'normal';
@Input() className?: string;
@Input() invalid?: boolean;
private sizeMap: Record<PSize, string> = {
small: 'text-sm',
normal: 'text-md',
large: 'text-lg',
};
get computedClass(): string {
// default styling used elsewhere in the app for labels
const base = 'block text-muted-color font-medium';
const size = this.sizeMap[this.pSize] ?? this.sizeMap.normal;
const errorClass = this.invalid ? 'text-red-600' : '';
return `${base} ${size} ${errorClass}`;
}
}