Compare commits
No commits in common. "69b3d1fd726265f943d4bacb6cc72652447a9157" and "9e34de73ee9226c1546bcc8b422832b35c1a5361" have entirely different histories.
69b3d1fd72
...
9e34de73ee
@ -23,7 +23,6 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular/build:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist/portfolio-admin",
|
|
||||||
"browser": "src/main.ts",
|
"browser": "src/main.ts",
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
@ -35,7 +34,12 @@
|
|||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
]
|
],
|
||||||
|
"server": "src/main.server.ts",
|
||||||
|
"outputMode": "server",
|
||||||
|
"ssr": {
|
||||||
|
"entry": "src/server.ts"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
|||||||
33
package-lock.json
generated
33
package-lock.json
generated
@ -8,7 +8,6 @@
|
|||||||
"name": "portfolio-admin",
|
"name": "portfolio-admin",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "20.3.4",
|
|
||||||
"@angular/cdk": "^20.2.5",
|
"@angular/cdk": "^20.2.5",
|
||||||
"@angular/common": "^20.3.0",
|
"@angular/common": "^20.3.0",
|
||||||
"@angular/compiler": "^20.3.0",
|
"@angular/compiler": "^20.3.0",
|
||||||
@ -19,7 +18,6 @@
|
|||||||
"@angular/platform-server": "^20.3.0",
|
"@angular/platform-server": "^20.3.0",
|
||||||
"@angular/router": "^20.3.0",
|
"@angular/router": "^20.3.0",
|
||||||
"@angular/ssr": "^20.3.2",
|
"@angular/ssr": "^20.3.2",
|
||||||
"@datorama/akita": "^8.0.1",
|
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@ -447,21 +445,6 @@
|
|||||||
"typescript": "*"
|
"typescript": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular/animations": {
|
|
||||||
"version": "20.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.4.tgz",
|
|
||||||
"integrity": "sha512-b+vFsTtMYtOrcZZLXB4BxuErbrLlShFT6khTvkwu/pFK8ri3tasyJGkeKRZJHao5ZsWdZSqV2mRwzg7vphchnA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@angular/core": "20.3.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@angular/build": {
|
"node_modules/@angular/build": {
|
||||||
"version": "20.3.5",
|
"version": "20.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.5.tgz",
|
||||||
@ -1115,16 +1098,6 @@
|
|||||||
"node": ">=0.1.90"
|
"node": ">=0.1.90"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@datorama/akita": {
|
|
||||||
"version": "8.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@datorama/akita/-/akita-8.0.1.tgz",
|
|
||||||
"integrity": "sha512-0VnPWd+Sy3ColhzjDSBNcEnzAQtbezk6bYmJHvPaLMK5Ysl90KcNls2bE4sj5vaLeGLjhMtqtfp/RgrigPXDxA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"peerDependencies": {
|
|
||||||
"rxjs": "*",
|
|
||||||
"tslib": "2.4.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.9",
|
"version": "0.25.9",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||||
@ -11056,9 +11029,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.4.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/tuf-js": {
|
"node_modules/tuf-js": {
|
||||||
|
|||||||
@ -24,7 +24,6 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "20.3.4",
|
|
||||||
"@angular/cdk": "^20.2.5",
|
"@angular/cdk": "^20.2.5",
|
||||||
"@angular/common": "^20.3.0",
|
"@angular/common": "^20.3.0",
|
||||||
"@angular/compiler": "^20.3.0",
|
"@angular/compiler": "^20.3.0",
|
||||||
@ -35,7 +34,6 @@
|
|||||||
"@angular/platform-server": "^20.3.0",
|
"@angular/platform-server": "^20.3.0",
|
||||||
"@angular/router": "^20.3.0",
|
"@angular/router": "^20.3.0",
|
||||||
"@angular/ssr": "^20.3.2",
|
"@angular/ssr": "^20.3.2",
|
||||||
"@datorama/akita": "^8.0.1",
|
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0%" y1="100%" x2="100%" y2="0%">
|
|
||||||
<stop offset="3%" style="stop-color:hsl(240,1%,25%)"/>
|
|
||||||
<stop offset="97%" style="stop-color:hsl(0,0%,19%)"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="64" height="64" rx="14" fill="url(#bg)"/>
|
|
||||||
<!-- Crown -->
|
|
||||||
<path d="M10 44 L10 24 L22 34 L32 16 L42 34 L54 24 L54 44 Z"
|
|
||||||
fill="hsl(45,100%,72%)" stroke="hsl(45,100%,72%)" stroke-width="1" stroke-linejoin="round"/>
|
|
||||||
<rect x="10" y="44" width="44" height="6" rx="2" fill="hsl(45,100%,72%)"/>
|
|
||||||
<!-- jewels -->
|
|
||||||
<circle cx="22" cy="38" r="2.5" fill="hsl(240,1%,17%)"/>
|
|
||||||
<circle cx="32" cy="35" r="2.5" fill="hsl(240,1%,17%)"/>
|
|
||||||
<circle cx="42" cy="38" r="2.5" fill="hsl(240,1%,17%)"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 805 B |
@ -1,10 +1,6 @@
|
|||||||
@if (about$ | async; as model) {
|
|
||||||
<article class="about active" data-page="about">
|
<article class="about active" data-page="about">
|
||||||
<header>
|
<header>
|
||||||
<h2 class="h2 article-title">About Me</h2>
|
<h2 class="h2 article-title">About me</h2>
|
||||||
<button class="edit-btn" (click)="openEdit()" aria-label="Edit About">
|
|
||||||
<i class="fa-light fa-pencil"></i>
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="about-text">
|
<section class="about-text">
|
||||||
@ -19,8 +15,7 @@
|
|||||||
|
|
||||||
<ul class="service-list">
|
<ul class="service-list">
|
||||||
|
|
||||||
@for (hobby of model.hobbies; track hobby.hobbyId) {
|
<li class="service-item" *ngFor="let hobby of model.hobbies">
|
||||||
<li class="service-item">
|
|
||||||
<div class="service-icon-box">
|
<div class="service-icon-box">
|
||||||
<i class="fa-regular fa-2x " [ngClass]="hobby.icon"></i>
|
<i class="fa-regular fa-2x " [ngClass]="hobby.icon"></i>
|
||||||
</div>
|
</div>
|
||||||
@ -32,10 +27,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</article>
|
</article>
|
||||||
}
|
|
||||||
@ -2,6 +2,5 @@ import { IHobby } from "../models/hobby.model";
|
|||||||
|
|
||||||
export interface IAbout{
|
export interface IAbout{
|
||||||
about: string;
|
about: string;
|
||||||
title: string;
|
|
||||||
hobbies: IHobby[];
|
hobbies: IHobby[];
|
||||||
}
|
}
|
||||||
@ -2,36 +2,3 @@
|
|||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
font-family: var(--ff-poppins);
|
font-family: var(--ff-poppins);
|
||||||
}
|
}
|
||||||
|
|
||||||
header{
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
justify-content:flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--orange-yellow-crayola);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
align-self: flex-start;
|
|
||||||
margin-top: -4px;
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn:hover {
|
|
||||||
background: rgba(227, 179, 65, 0.12);
|
|
||||||
}
|
|
||||||
@ -1,89 +1,29 @@
|
|||||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { DynamicPopupComponent } from '../../shared/dynamic-popup/dynamic-popup';
|
import { AdminService } from '../services/admin.service';
|
||||||
import { DynamicFormConfig } from '../../shared/dynamic-form/dynamic-form-config';
|
import { IAbout } from './about.model';
|
||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { BaseComponent } from '../base.component';
|
||||||
import { AdminQuery } from '../state/admin.query';
|
|
||||||
import { AdminStateService } from '../state/admin-state.service';
|
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
import { IHobby } from '../models/hobby.model';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-about',
|
selector: 'app-about',
|
||||||
imports: [CommonModule, MatDialogModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './about.html',
|
templateUrl: './about.html',
|
||||||
styleUrl: './about.scss'
|
styleUrl: './about.scss'
|
||||||
})
|
})
|
||||||
export class About implements OnInit, OnDestroy {
|
export class About extends BaseComponent<IAbout> implements OnInit {
|
||||||
private dialog = inject(MatDialog);
|
constructor() {
|
||||||
private adminQuery = inject(AdminQuery);
|
const svc = inject(AdminService);
|
||||||
private adminState = inject(AdminStateService);
|
super(svc);
|
||||||
private destroy$ = new Subject<void>();
|
}
|
||||||
|
|
||||||
about$ = this.adminQuery.about$;
|
|
||||||
loading$ = this.adminQuery.aboutLoading$;
|
|
||||||
imagesOrigin = environment.apiUrl + '/images/';
|
|
||||||
|
|
||||||
popupConfig?: DynamicFormConfig;
|
|
||||||
popupData: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (!this.adminQuery.getAbout()) {
|
this.getAbout();
|
||||||
this.adminState.loadAbout()
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openEdit(): void {
|
getAbout(): void{
|
||||||
const currentAbout = this.adminQuery.getAbout();
|
this.svc.getHobbies(this.candidateId).subscribe((response: IAbout) => {
|
||||||
|
this.svc.about = this.svc.about ?? response;
|
||||||
this.popupConfig = {
|
this.assignData(response);
|
||||||
title: 'Edit About',
|
|
||||||
submitLabel: 'Save',
|
|
||||||
api: {
|
|
||||||
save: '/api/v1/admin/UpsertHobbies',
|
|
||||||
method: 'POST'
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{ name: 'about', label: 'About', type: 'textarea', required: true },
|
|
||||||
{
|
|
||||||
name: 'hobbies',
|
|
||||||
label: 'Hobbies',
|
|
||||||
type: 'array',
|
|
||||||
itemConfig: [
|
|
||||||
{ name: 'hobbyId', label: 'Hobby ID', type: 'hidden' },
|
|
||||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
|
||||||
{ name: 'description', label: 'Description', type: 'text', required: true },
|
|
||||||
{ name: 'icon', label: 'Icon (FA class)', type: 'text', placeholder: 'e.g. fa-code' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
this.popupData = {
|
|
||||||
about: currentAbout?.about,
|
|
||||||
hobbies: currentAbout?.hobbies ?? []
|
|
||||||
};
|
|
||||||
|
|
||||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
|
||||||
data: { config: this.popupConfig, data: this.popupData },
|
|
||||||
panelClass: 'dark-popup-panel'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ref.afterClosed()
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe((res: { about: string; hobbies: IHobby[] } | null) => {
|
|
||||||
if (res) {
|
|
||||||
const updated = { ...currentAbout!, about: res.about, hobbies: res.hobbies ?? currentAbout!.hobbies };
|
|
||||||
this.adminState.updateAbout(updated);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.destroy$.next();
|
|
||||||
this.destroy$.complete();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/app/admin/base.component.ts
Normal file
19
src/app/admin/base.component.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { signal } from "@angular/core";
|
||||||
|
import { environment } from "../../environments/environment";
|
||||||
|
import { ICv } from "./models/cv.model";
|
||||||
|
import { AdminService } from "./services/admin.service";
|
||||||
|
|
||||||
|
|
||||||
|
export abstract class BaseComponent<T extends object> {
|
||||||
|
public model: T = {} as T;
|
||||||
|
candidateId = 1;
|
||||||
|
imagesOrigin: string = environment.apiUrl + '/images/';
|
||||||
|
isDataLoading = signal(false);
|
||||||
|
|
||||||
|
constructor(public svc: AdminService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
assignData(response: Partial<ICv> | unknown){
|
||||||
|
Object.assign(this.model, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,3 @@
|
|||||||
@if (contact$ | async; as model) {
|
|
||||||
<aside class="sidebar" [ngClass]="sideBarExpanded ? 'active' : ''" data-sidebar>
|
<aside class="sidebar" [ngClass]="sideBarExpanded ? 'active' : ''" data-sidebar>
|
||||||
|
|
||||||
<div class="sidebar-info">
|
<div class="sidebar-info">
|
||||||
@ -8,12 +7,7 @@
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<div class="info-content">
|
<div class="info-content">
|
||||||
<div class="name-row">
|
|
||||||
<h1 class="name" title="{{model.candidate?.displayName}}">{{model.candidate?.displayName}}</h1>
|
<h1 class="name" title="{{model.candidate?.displayName}}">{{model.candidate?.displayName}}</h1>
|
||||||
<button class="edit-btn" (click)="openEdit()" aria-label="Edit Contact">
|
|
||||||
<i class="fa-light fa-pencil"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="title">{{model.title}}</p>
|
<p class="title">{{model.title}}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -129,4 +123,3 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
}
|
|
||||||
@ -60,27 +60,3 @@ img {
|
|||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
margin: 18px 0 14px 0;
|
margin: 18px 0 14px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.name-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--orange-yellow-crayola);
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
top: -8px;
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(227, 179, 65, 0.12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,121 +1,37 @@
|
|||||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
|
import { IContactModel } from './contact.model';
|
||||||
|
import { AdminService } from '../services/admin.service';
|
||||||
|
import { BaseComponent } from '../base.component';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { AuthService } from '../../auth/auth.service';
|
import { AuthService } from '../../auth/auth.service';
|
||||||
import { AdminQuery } from '../state/admin.query';
|
|
||||||
import { AdminStateService } from '../state/admin-state.service';
|
|
||||||
import { DynamicPopupComponent } from '../../shared/dynamic-popup/dynamic-popup';
|
|
||||||
import { DynamicFormConfig } from '../../shared/dynamic-form/dynamic-form-config';
|
|
||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
import { IContactModel } from './contact.model';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-contact',
|
selector: 'app-contact',
|
||||||
imports: [CommonModule, MatDialogModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './contact.html',
|
templateUrl: './contact.html',
|
||||||
styleUrl: './contact.scss'
|
styleUrl: './contact.scss'
|
||||||
})
|
})
|
||||||
export class Contact implements OnInit, OnDestroy {
|
export class Contact extends BaseComponent<IContactModel> implements OnInit {
|
||||||
sideBarExpanded = false;
|
sideBarExpanded = false;
|
||||||
authSvc = inject(AuthService);
|
displayName!: string;
|
||||||
private dialog = inject(MatDialog);
|
authSvc: AuthService = inject(AuthService);
|
||||||
private adminQuery = inject(AdminQuery);
|
constructor(){
|
||||||
private adminState = inject(AdminStateService);
|
const svc = inject(AdminService);
|
||||||
private destroy$ = new Subject<void>();
|
super(svc);
|
||||||
|
}
|
||||||
contact$ = this.adminQuery.contact$;
|
|
||||||
loading$ = this.adminQuery.contactLoading$;
|
|
||||||
imagesOrigin = environment.apiUrl + '/images/';
|
|
||||||
|
|
||||||
popupConfig?: DynamicFormConfig;
|
|
||||||
popupData: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (!this.adminQuery.getContact()) {
|
this.getCandidateAndSocialLinks();
|
||||||
this.adminState.loadContact()
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openEdit(): void {
|
getCandidateAndSocialLinks(){
|
||||||
const current = this.adminQuery.getContact();
|
this.svc.getCandidateWithSocialLinks(this.candidateId).subscribe((response: IContactModel) => {
|
||||||
|
this.svc.candidateAndSocialLinks = this.svc.candidateAndSocialLinks ?? response;
|
||||||
this.popupConfig = {
|
this.assignData(response);
|
||||||
title: 'Edit Contact',
|
|
||||||
submitLabel: 'Save',
|
|
||||||
api: {
|
|
||||||
save: '/api/v1/admin/UpsertContact',
|
|
||||||
method: 'POST'
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{ name: 'title', label: 'Title', type: 'text', required: true },
|
|
||||||
{ name: 'firstName', label: 'First Name', type: 'text', required: true },
|
|
||||||
{ name: 'lastName', label: 'Last Name', type: 'text', required: true },
|
|
||||||
{ name: 'email', label: 'Email', type: 'text', required: true },
|
|
||||||
{ name: 'phone', label: 'Phone', type: 'text', required: true },
|
|
||||||
{ name: 'dob', label: 'Date of Birth', type: 'date' },
|
|
||||||
{ name: 'address', label: 'Address', type: 'textarea' },
|
|
||||||
{ name: 'linkedin', label: 'LinkedIn URL', type: 'text' },
|
|
||||||
{ name: 'gitHub', label: 'GitHub URL', type: 'text' },
|
|
||||||
{ name: 'blogUrl', label: 'Blog URL', type: 'text' }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
this.popupData = {
|
|
||||||
title: current?.title,
|
|
||||||
firstName: current?.candidate?.firstName,
|
|
||||||
lastName: current?.candidate?.lastName,
|
|
||||||
email: current?.candidate?.email,
|
|
||||||
phone: current?.candidate?.phone,
|
|
||||||
dob: current?.candidate?.dob,
|
|
||||||
address: current?.candidate?.address,
|
|
||||||
linkedin: current?.socialLinks?.linkedin,
|
|
||||||
gitHub: current?.socialLinks?.gitHub,
|
|
||||||
blogUrl: current?.socialLinks?.blogUrl
|
|
||||||
};
|
|
||||||
|
|
||||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
|
||||||
data: { config: this.popupConfig, data: this.popupData },
|
|
||||||
panelClass: 'dark-popup-panel'
|
|
||||||
});
|
|
||||||
|
|
||||||
ref.afterClosed()
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe((res: IContactModel) => {
|
|
||||||
if (res) {
|
|
||||||
const updated = {
|
|
||||||
...current!,
|
|
||||||
title: res.title ?? '',
|
|
||||||
candidate: {
|
|
||||||
...current!.candidate!,
|
|
||||||
firstName: res.candidate?.firstName ?? '',
|
|
||||||
lastName: res.candidate?.lastName ?? '',
|
|
||||||
displayName: `${res.candidate?.firstName ?? ''} ${res.candidate?.lastName ?? ''}`,
|
|
||||||
email: res.candidate?.email ?? '',
|
|
||||||
phone: res.candidate?.phone ?? '',
|
|
||||||
dob: res.candidate?.dob ?? '',
|
|
||||||
address: res.candidate?.address ?? ''
|
|
||||||
},
|
|
||||||
socialLinks: {
|
|
||||||
...current!.socialLinks!,
|
|
||||||
linkedin: res.socialLinks?.linkedin ?? '',
|
|
||||||
gitHub: res.socialLinks?.gitHub ?? '',
|
|
||||||
blogUrl: res.socialLinks?.blogUrl ?? ''
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.adminState.updateContact(updated);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
this.authSvc.logout();
|
this.authSvc.logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.destroy$.next();
|
|
||||||
this.destroy$.complete();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
export interface ICertification {
|
|
||||||
certificationId: number;
|
|
||||||
certificationName: string;
|
|
||||||
issuingOrganization: string;
|
|
||||||
certificationLink: string;
|
|
||||||
issueDate: string;
|
|
||||||
expiryDate?: string;
|
|
||||||
}
|
|
||||||
@ -8,8 +8,5 @@ export interface IExperience{
|
|||||||
startYear: string;
|
startYear: string;
|
||||||
endYear: string;
|
endYear: string;
|
||||||
period: string;
|
period: string;
|
||||||
location: string;
|
|
||||||
startDate: string | null;
|
|
||||||
endDate: string | null;
|
|
||||||
details: IExperienceDetails[];
|
details: IExperienceDetails[];
|
||||||
}
|
}
|
||||||
@ -3,15 +3,9 @@ export interface IProject{
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
categoryList: string[];
|
||||||
roles: string[];
|
roles: string[];
|
||||||
responsibilities: string[];
|
responsibilities: string[];
|
||||||
technologiesUsed: string[];
|
technologiesUsed: string[];
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
challenges: string;
|
|
||||||
lessonsLearned: string;
|
|
||||||
impact: string;
|
|
||||||
startDate: string;
|
|
||||||
endDate: string;
|
|
||||||
status: string;
|
|
||||||
resumeId: number;
|
|
||||||
}
|
}
|
||||||
@ -1,101 +0,0 @@
|
|||||||
<div class="project-detail" role="dialog" aria-labelledby="project-title">
|
|
||||||
<div class="detail-header">
|
|
||||||
<h2 id="project-title">{{ project.name }}</h2>
|
|
||||||
<button class="close-btn" (click)="close()" aria-label="Close">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (project.status) {
|
|
||||||
<span class="status-badge">{{ project.status }}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (project.description) {
|
|
||||||
<div class="detail-section">
|
|
||||||
<h4>Description</h4>
|
|
||||||
<p>{{ project.description }}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (project.roles.length) {
|
|
||||||
<div class="detail-section">
|
|
||||||
<h4>Roles</h4>
|
|
||||||
<div class="tag-list">
|
|
||||||
@for (role of project.roles; track role) {
|
|
||||||
<span class="tag">{{ role }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (project.responsibilities.length) {
|
|
||||||
<div class="detail-section">
|
|
||||||
<h4>Responsibilities</h4>
|
|
||||||
<div class="tag-list">
|
|
||||||
@for (r of project.responsibilities; track r) {
|
|
||||||
<span class="tag">{{ r }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (project.technologiesUsed.length) {
|
|
||||||
<div class="detail-section">
|
|
||||||
<h4>Technologies</h4>
|
|
||||||
<div class="tag-list">
|
|
||||||
@for (tech of project.technologiesUsed; track tech) {
|
|
||||||
<span class="tag tech">{{ tech }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (project.startDate || project.endDate) {
|
|
||||||
<div class="detail-section">
|
|
||||||
<h4>Duration</h4>
|
|
||||||
<p class="duration">
|
|
||||||
@if (project.startDate) {
|
|
||||||
<span>{{ project.startDate | date: 'MMM yyyy' }}</span>
|
|
||||||
}
|
|
||||||
@if (project.startDate && project.endDate) {
|
|
||||||
<span> — </span>
|
|
||||||
}
|
|
||||||
@if (project.endDate) {
|
|
||||||
<span>{{ project.endDate | date: 'MMM yyyy' }}</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (project.challenges) {
|
|
||||||
<div class="detail-section">
|
|
||||||
<h4>Challenges</h4>
|
|
||||||
<p>{{ project.challenges }}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (project.lessonsLearned) {
|
|
||||||
<div class="detail-section">
|
|
||||||
<h4>Lessons Learned</h4>
|
|
||||||
<p>{{ project.lessonsLearned }}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (project.impact) {
|
|
||||||
<div class="detail-section">
|
|
||||||
<h4>Impact</h4>
|
|
||||||
<p>{{ project.impact }}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (project.categories?.length) {
|
|
||||||
<div class="detail-section">
|
|
||||||
<h4>Categories</h4>
|
|
||||||
<div class="tag-list">
|
|
||||||
@for (cat of project.categories; track cat) {
|
|
||||||
<span class="tag category">{{ cat }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
.project-detail {
|
|
||||||
padding: 24px;
|
|
||||||
color: var(--white-1, #fff);
|
|
||||||
max-height: 80vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--eerie-black-2, #1e1e1e);
|
|
||||||
border: 1px solid var(--jet, #383838);
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.35rem;
|
|
||||||
color: var(--white-1, #fff);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--light-gray-70, #aaa);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: background 0.2s, color 0.2s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: var(--white-1, #fff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: rgba(227, 179, 65, 0.15);
|
|
||||||
color: var(--orange-yellow-crayola, #e3b341);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-section {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--light-gray-70, #999);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--white-2, #ddd);
|
|
||||||
white-space: pre-line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration {
|
|
||||||
color: var(--white-2, #ddd);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
color: var(--white-2, #ddd);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
|
|
||||||
&.tech {
|
|
||||||
background: rgba(100, 180, 255, 0.1);
|
|
||||||
color: #8cc4ff;
|
|
||||||
border-color: rgba(100, 180, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.category {
|
|
||||||
background: rgba(227, 179, 65, 0.1);
|
|
||||||
color: var(--orange-yellow-crayola, #e3b341);
|
|
||||||
border-color: rgba(227, 179, 65, 0.15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { CommonModule, DatePipe } from '@angular/common';
|
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
|
||||||
import { IProject } from '../../models/project.model';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-project-detail-popup',
|
|
||||||
templateUrl: './project-detail-popup.html',
|
|
||||||
styleUrls: ['./project-detail-popup.scss'],
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, DatePipe]
|
|
||||||
})
|
|
||||||
export class ProjectDetailPopupComponent {
|
|
||||||
private dialogRef = inject(MatDialogRef<ProjectDetailPopupComponent>);
|
|
||||||
project: IProject = inject(MAT_DIALOG_DATA);
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
this.dialogRef.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h2 class="h2 article-title">Projects</h2>
|
<h2 class="h2 article-title">Projects</h2>
|
||||||
<button class="edit-btn add-btn" (click)="openAddProject()" title="Add Project">
|
|
||||||
<i class="fa-solid fa-plus"></i>
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="projects">
|
<section class="projects">
|
||||||
@ -15,7 +12,7 @@
|
|||||||
<button [ngClass]="{active: filter === 'All'}" (click)="filterProjects('All')">All</button>
|
<button [ngClass]="{active: filter === 'All'}" (click)="filterProjects('All')">All</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@for (category of projectsCategories; track category) {
|
@for (category of model.projectsCategories; track category) {
|
||||||
<li class="filter-item">
|
<li class="filter-item">
|
||||||
<button (click)="filterProjects(category)"
|
<button (click)="filterProjects(category)"
|
||||||
[ngClass]="{active: filter === category}">{{category}}</button>
|
[ngClass]="{active: filter === category}">{{category}}</button>
|
||||||
@ -43,7 +40,7 @@
|
|||||||
<button (click)="filterProjects('All')">All</button>
|
<button (click)="filterProjects('All')">All</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@for (category of projectsCategories; track category) {
|
@for (category of model.projectsCategories; track category) {
|
||||||
<li class="select-item">
|
<li class="select-item">
|
||||||
<button (click)="filterProjects(category)">{{category}}</button>
|
<button (click)="filterProjects(category)">{{category}}</button>
|
||||||
</li>
|
</li>
|
||||||
@ -59,9 +56,9 @@
|
|||||||
<a>
|
<a>
|
||||||
|
|
||||||
<figure class="project-img">
|
<figure class="project-img">
|
||||||
<button class="project-item-icon-box" (click)="openViewProject(project)" (keyup.enter)="openViewProject(project)" title="View Project">
|
<div class="project-item-icon-box">
|
||||||
<i class="fa-regular fa-eye"></i>
|
<ion-icon name="eye-outline"></ion-icon>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<img src="{{imagesOrigin + project.imagePath}}" alt="{{project.name}}" loading="lazy">
|
<img src="{{imagesOrigin + project.imagePath}}" alt="{{project.name}}" loading="lazy">
|
||||||
</figure>
|
</figure>
|
||||||
@ -73,12 +70,6 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<button class="edit-btn project-edit-btn" (click)="openEditProject(project)" title="Edit Project">
|
|
||||||
<i class="fa-solid fa-pen-to-square"></i>
|
|
||||||
</button>
|
|
||||||
<button class="edit-btn project-delete-btn" (click)="deleteProject(project)" title="Delete Project">
|
|
||||||
<i class="fa-solid fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,93 +5,3 @@
|
|||||||
.no-margin{
|
.no-margin{
|
||||||
margin-left: 0px;
|
margin-left: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--orange-yellow-crayola);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn:hover {
|
|
||||||
background: rgba(227, 179, 65, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn {
|
|
||||||
margin-top: 2px;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 50%;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background: rgba(227, 179, 65, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn:hover {
|
|
||||||
background: rgba(227, 179, 65, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-item {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-edit-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 6px;
|
|
||||||
right: 6px;
|
|
||||||
z-index: 2;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease, background 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-item:hover .project-edit-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-edit-btn:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.7) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-delete-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 6px;
|
|
||||||
right: 42px;
|
|
||||||
z-index: 2;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #ff6b6b;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease, background 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-item:hover .project-delete-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-delete-btn:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.7) !important;
|
|
||||||
color: #ff4444;
|
|
||||||
}
|
|
||||||
@ -1,223 +1,47 @@
|
|||||||
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit, OnDestroy } from '@angular/core';
|
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit } from '@angular/core';
|
||||||
import { IProject } from '../models/project.model';
|
import { IProject } from '../models/project.model';
|
||||||
import { CommonModule } from '@angular/common';
|
import { BaseComponent } from '../base.component';
|
||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { DynamicPopupComponent } from '../../shared/dynamic-popup/dynamic-popup';
|
|
||||||
import { DynamicFormConfig } from '../../shared/dynamic-form/dynamic-form-config';
|
|
||||||
import { ProjectDetailPopupComponent } from './project-detail-popup/project-detail-popup';
|
|
||||||
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog';
|
|
||||||
import { AdminQuery } from '../state/admin.query';
|
|
||||||
import { AdminStateService } from '../state/admin-state.service';
|
|
||||||
import { AdminService } from '../services/admin.service';
|
import { AdminService } from '../services/admin.service';
|
||||||
import { Subject, takeUntil, filter } from 'rxjs';
|
import { IProjects } from './projects.model';
|
||||||
import { environment } from '../../../environments/environment';
|
import { Subscription } from 'rxjs';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-projects',
|
selector: 'app-projects',
|
||||||
templateUrl: './projects.html',
|
templateUrl: './projects.html',
|
||||||
styleUrl: './projects.scss',
|
styleUrl: './projects.scss',
|
||||||
imports: [CommonModule, MatDialogModule],
|
imports: [CommonModule],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class Projects implements OnInit, OnDestroy {
|
export class Projects extends BaseComponent<IProjects> implements OnInit {
|
||||||
filter = 'All';
|
filter = 'All';
|
||||||
projects: IProject[] = [];
|
projects!: IProject[];
|
||||||
projectsCategories: string[] = [];
|
subscription: Subscription = {} as Subscription;
|
||||||
categoryClicked = false;
|
categoryClicked = false;
|
||||||
imagesOrigin = environment.apiUrl + '/images/';
|
constructor() {
|
||||||
|
const svc = inject(AdminService);
|
||||||
private dialog = inject(MatDialog);
|
super(svc);
|
||||||
private http = inject(HttpClient);
|
}
|
||||||
private adminQuery = inject(AdminQuery);
|
|
||||||
private adminState = inject(AdminStateService);
|
|
||||||
private adminService = inject(AdminService);
|
|
||||||
private destroy$ = new Subject<void>();
|
|
||||||
|
|
||||||
projects$ = this.adminQuery.projects$;
|
|
||||||
loading$ = this.adminQuery.projectsLoading$;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.projects$
|
this.getProjects();
|
||||||
.pipe(
|
|
||||||
filter(data => data != null),
|
|
||||||
takeUntil(this.destroy$)
|
|
||||||
)
|
|
||||||
.subscribe(data => {
|
|
||||||
this.projects = data.projects;
|
|
||||||
this.projectsCategories = data.projectsCategories;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only fetch if not already in store
|
|
||||||
if (!this.adminQuery.getProjects()) {
|
|
||||||
this.adminState.loadProjects()
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getProjects() {
|
||||||
|
this.svc.getProjects(this.candidateId).subscribe((response: IProjects) => {
|
||||||
|
this.svc.projects = this.svc.projects ?? response;
|
||||||
|
this.projects = response.projects;
|
||||||
|
this.assignData(response);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
filterProjects(category: string) {
|
filterProjects(category: string) {
|
||||||
this.filter = category;
|
this.filter = category;
|
||||||
const allProjects = this.adminQuery.getProjects();
|
this.projects = this.filter === 'All'
|
||||||
if (!allProjects) return;
|
? this.model.projects
|
||||||
|
: this.model.projects.filter(
|
||||||
this.projects = category === 'All'
|
(project: IProject) => {
|
||||||
? allProjects.projects
|
return project.categories.includes(category)
|
||||||
: allProjects.projects.filter(
|
|
||||||
(project: IProject) => project.categories.includes(category)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getProjectFormConfig(title: string): DynamicFormConfig {
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
submitLabel: 'Save',
|
|
||||||
fields: [
|
|
||||||
{ name: 'projectId', label: 'ID', type: 'hidden' },
|
|
||||||
{ name: 'name', label: 'Project Name', type: 'text', required: true },
|
|
||||||
{ name: 'description', label: 'Description', type: 'textarea' },
|
|
||||||
{ name: 'status', label: 'Status', type: 'text', placeholder: 'e.g. Completed, In Progress' },
|
|
||||||
{ name: 'categories', label: 'Categories', type: 'text', placeholder: 'Comma separated' },
|
|
||||||
{ name: 'roles', label: 'Roles', type: 'text', placeholder: 'Comma separated' },
|
|
||||||
{ name: 'responsibilities', label: 'Responsibilities', type: 'text', placeholder: 'Comma separated' },
|
|
||||||
{ name: 'technologiesUsed', label: 'Technologies Used', type: 'text', placeholder: 'Comma separated' },
|
|
||||||
{ name: 'startDate', label: 'Start Date', type: 'date' },
|
|
||||||
{ name: 'endDate', label: 'End Date', type: 'date' },
|
|
||||||
{ name: 'challenges', label: 'Challenges', type: 'textarea' },
|
|
||||||
{ name: 'lessonsLearned', label: 'Lessons Learned', type: 'textarea' },
|
|
||||||
{ name: 'impact', label: 'Impact', type: 'textarea' }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private projectToFormData(project: IProject): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
...project,
|
|
||||||
categories: (project.categories ?? []).join(', '),
|
|
||||||
roles: (project.roles ?? []).join(', '),
|
|
||||||
responsibilities: (project.responsibilities ?? []).join(', '),
|
|
||||||
technologiesUsed: (project.technologiesUsed ?? []).join(', ')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private formDataToProject(formVal: Record<string, unknown>): IProject {
|
|
||||||
const toArray = (val: unknown): string[] => {
|
|
||||||
if (!val || typeof val !== 'string') return [];
|
|
||||||
return val.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...formVal,
|
|
||||||
categories: toArray(formVal['categories']),
|
|
||||||
roles: toArray(formVal['roles']),
|
|
||||||
responsibilities: toArray(formVal['responsibilities']),
|
|
||||||
technologiesUsed: toArray(formVal['technologiesUsed'])
|
|
||||||
} as IProject;
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveProject(project: IProject, isNew: boolean): void {
|
|
||||||
const url = `${environment.apiUrl}/api/v1/admin/UpsertProject`;
|
|
||||||
|
|
||||||
this.http.post<IProject>(url, project)
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe((res: IProject) => {
|
|
||||||
const allProjects = this.adminQuery.getProjects();
|
|
||||||
let updatedList: IProject[];
|
|
||||||
|
|
||||||
if (isNew) {
|
|
||||||
updatedList = [...(allProjects?.projects ?? []), res];
|
|
||||||
} else {
|
|
||||||
updatedList = (allProjects?.projects ?? []).map(p =>
|
|
||||||
p.projectId === res.projectId ? res : p
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const categories = [...new Set(updatedList.flatMap(p => p.categories ?? []))];
|
|
||||||
this.adminState.updateProjects({
|
|
||||||
projects: updatedList,
|
|
||||||
projectsCategories: categories.length ? categories : allProjects?.projectsCategories ?? []
|
|
||||||
});
|
});
|
||||||
this.filter = 'All';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
openViewProject(project: IProject): void {
|
|
||||||
this.dialog.open(ProjectDetailPopupComponent, {
|
|
||||||
data: project,
|
|
||||||
panelClass: 'dark-popup-panel',
|
|
||||||
width: '520px',
|
|
||||||
maxHeight: '85vh'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
openEditProject(project: IProject): void {
|
|
||||||
const config = this.getProjectFormConfig('Edit Project');
|
|
||||||
const data = this.projectToFormData(project);
|
|
||||||
|
|
||||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
|
||||||
data: { config, data },
|
|
||||||
panelClass: 'dark-popup-panel'
|
|
||||||
});
|
|
||||||
|
|
||||||
ref.afterClosed()
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe((res: Record<string, unknown> | null) => {
|
|
||||||
if (!res) return;
|
|
||||||
const updated = this.formDataToProject(res);
|
|
||||||
this.saveProject(updated, false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
openAddProject(): void {
|
|
||||||
const config = this.getProjectFormConfig('Add Project');
|
|
||||||
const data: Record<string, unknown> = { projectId: 0 };
|
|
||||||
|
|
||||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
|
||||||
data: { config, data },
|
|
||||||
panelClass: 'dark-popup-panel'
|
|
||||||
});
|
|
||||||
|
|
||||||
ref.afterClosed()
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe((res: Record<string, unknown> | null) => {
|
|
||||||
if (!res) return;
|
|
||||||
const newProject = this.formDataToProject(res);
|
|
||||||
this.saveProject(newProject, true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteProject(project: IProject): void {
|
|
||||||
const ref = this.dialog.open(ConfirmDialogComponent, {
|
|
||||||
data: {
|
|
||||||
title: 'Delete Project',
|
|
||||||
message: `Are you sure you want to delete "${project.name}"? This action cannot be undone.`
|
|
||||||
},
|
|
||||||
panelClass: 'dark-popup-panel',
|
|
||||||
width: '420px'
|
|
||||||
});
|
|
||||||
|
|
||||||
ref.afterClosed()
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe((confirmed: boolean) => {
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
this.adminService.deleteProject(project.projectId)
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe(() => {
|
|
||||||
const allProjects = this.adminQuery.getProjects();
|
|
||||||
const updatedList = (allProjects?.projects ?? []).filter(p => p.projectId !== project.projectId);
|
|
||||||
const categories = [...new Set(updatedList.flatMap(p => p.categories ?? []))];
|
|
||||||
|
|
||||||
this.adminState.updateProjects({
|
|
||||||
projects: updatedList,
|
|
||||||
projectsCategories: categories.length ? categories : []
|
|
||||||
});
|
|
||||||
this.filter = 'All';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.destroy$.next();
|
|
||||||
this.destroy$.complete();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
@if (resume$ | async; as model) {
|
|
||||||
<article class="resume" data-page="resume">
|
<article class="resume" data-page="resume">
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
@ -13,15 +12,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="h3">Education</h3>
|
<h3 class="h3">Education</h3>
|
||||||
|
|
||||||
<button class="edit-btn" type="button" (click)="openEditEducation()" title="Edit Education">
|
|
||||||
<i class="fa-solid fa-pen-to-square"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol class="timeline-list">
|
<ol class="timeline-list">
|
||||||
|
|
||||||
@for (education of model.academics; track education.academicId) {
|
@for (education of model.academics; track education) {
|
||||||
|
|
||||||
<li class="timeline-item">
|
<li class="timeline-item">
|
||||||
|
|
||||||
@ -50,15 +45,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="h3">Experience</h3>
|
<h3 class="h3">Experience</h3>
|
||||||
|
|
||||||
<button class="edit-btn" type="button" (click)="openEditExperience()" title="Edit Experience">
|
|
||||||
<i class="fa-solid fa-pen-to-square"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol class="timeline-list">
|
<ol class="timeline-list">
|
||||||
|
|
||||||
@for (experience of model.experiences; track experience.experienceId) {
|
@for (experience of model.experiences; track experience) {
|
||||||
|
|
||||||
<li class="timeline-item">
|
<li class="timeline-item">
|
||||||
|
|
||||||
@ -80,17 +71,11 @@
|
|||||||
|
|
||||||
<section class="skill">
|
<section class="skill">
|
||||||
|
|
||||||
<div class="title-wrapper">
|
|
||||||
<h3 class="h3 skills-title">My skills</h3>
|
<h3 class="h3 skills-title">My skills</h3>
|
||||||
|
|
||||||
<button class="edit-btn" type="button" (click)="openEditSkills()" title="Edit Skills">
|
|
||||||
<i class="fa-solid fa-pen-to-square"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="skills-list content-card">
|
<ul class="skills-list content-card">
|
||||||
|
|
||||||
@for (skill of model.skills; track skill.skillId) {
|
@for (skill of model.skills; track skill) {
|
||||||
|
|
||||||
<li class="skills-item">
|
<li class="skills-item">
|
||||||
|
|
||||||
@ -111,43 +96,4 @@
|
|||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="timeline">
|
|
||||||
|
|
||||||
<div class="title-wrapper">
|
|
||||||
<div class="icon-box">
|
|
||||||
<i class="fa-light fa-certificate"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="h3">Certifications</h3>
|
|
||||||
|
|
||||||
<button class="edit-btn" type="button" (click)="openEditCertifications()" title="Edit Certifications">
|
|
||||||
<i class="fa-solid fa-pen-to-square"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol class="timeline-list">
|
|
||||||
|
|
||||||
@for (cert of model.certifications; track cert.certificationId) {
|
|
||||||
|
|
||||||
<li class="timeline-item">
|
|
||||||
|
|
||||||
<h4 class="h4 timeline-item-title">{{cert.certificationName}}</h4>
|
|
||||||
|
|
||||||
<span>{{cert.issuingOrganization}}</span>
|
|
||||||
|
|
||||||
@if (cert.certificationLink) {
|
|
||||||
<a class="timeline-link" [href]="cert.certificationLink" target="_blank" rel="noopener">
|
|
||||||
<i class="fa-solid fa-arrow-up-right-from-square"></i> View Certificate
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
|
|
||||||
</li>
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</article>
|
</article>
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { IAcademic } from "../models/academic.model";
|
import { IAcademic } from "../models/academic.model";
|
||||||
import { ICertification } from "../models/certification.model";
|
|
||||||
import { IExperience } from "../models/experience.model";
|
import { IExperience } from "../models/experience.model";
|
||||||
import { ISkill } from "../models/skill.model";
|
import { ISkill } from "../models/skill.model";
|
||||||
|
|
||||||
@ -7,5 +6,4 @@ export interface IResume{
|
|||||||
academics?: IAcademic[];
|
academics?: IAcademic[];
|
||||||
experiences?: IExperience[];
|
experiences?: IExperience[];
|
||||||
skills?: ISkill[];
|
skills?: ISkill[];
|
||||||
certifications?: ICertification[];
|
|
||||||
}
|
}
|
||||||
@ -1,41 +0,0 @@
|
|||||||
.title-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--orange-yellow-crayola);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
align-self: flex-start;
|
|
||||||
margin-top: -4px;
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn:hover {
|
|
||||||
background: rgba(227, 179, 65, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--orange-yellow-crayola);
|
|
||||||
font-size: var(--fs-6);
|
|
||||||
margin-top: 4px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--vegas-gold);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,216 +1,39 @@
|
|||||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
import { Component, inject, OnInit, PLATFORM_ID } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule, isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { BaseComponent } from '../base.component';
|
||||||
import { DynamicPopupComponent } from '../../shared/dynamic-popup/dynamic-popup';
|
import { IResume } from './resume.model';
|
||||||
import { DynamicFormConfig } from '../../shared/dynamic-form/dynamic-form-config';
|
import { AdminService } from '../services/admin.service';
|
||||||
import { AdminQuery } from '../state/admin.query';
|
|
||||||
import { AdminStateService } from '../state/admin-state.service';
|
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
|
||||||
import { IAcademic } from '../models/academic.model';
|
|
||||||
import { IExperience } from '../models/experience.model';
|
|
||||||
import { ISkill } from '../models/skill.model';
|
|
||||||
import { ICertification } from '../models/certification.model';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-resume',
|
selector: 'app-resume',
|
||||||
templateUrl: './resume.html',
|
templateUrl: './resume.html',
|
||||||
styleUrl: './resume.scss',
|
styleUrl: './resume.scss',
|
||||||
imports: [CommonModule, MatDialogModule]
|
imports: [CommonModule]
|
||||||
})
|
})
|
||||||
export class Resume implements OnInit, OnDestroy {
|
export class Resume extends BaseComponent<IResume> implements OnInit {
|
||||||
private dialog = inject(MatDialog);
|
platformId = inject(PLATFORM_ID);
|
||||||
private adminQuery = inject(AdminQuery);
|
|
||||||
private adminState = inject(AdminStateService);
|
|
||||||
private destroy$ = new Subject<void>();
|
|
||||||
|
|
||||||
resume$ = this.adminQuery.resume$;
|
constructor() {
|
||||||
loading$ = this.adminQuery.resumeLoading$;
|
const svc = inject(AdminService);
|
||||||
|
super(svc);
|
||||||
|
console.log("Resume component constructed");
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (!this.adminQuery.getResume()) {
|
console.log("Resume component initialized");
|
||||||
this.adminState.loadResume()
|
console.log("Server:", isPlatformServer(this.platformId));
|
||||||
.pipe(takeUntil(this.destroy$))
|
console.log("Browser:", isPlatformBrowser(this.platformId));
|
||||||
.subscribe();
|
if(!this.isDataLoading()) {
|
||||||
|
this.isDataLoading.set(true);
|
||||||
|
this.getResume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
openEditEducation(): void {
|
getResume() {
|
||||||
const resume = this.adminQuery.getResume();
|
this.svc.getResume(this.candidateId).subscribe((response: IResume) => {
|
||||||
|
this.svc.resume = this.svc.resume ?? response;
|
||||||
const config: DynamicFormConfig = {
|
this.assignData(response);
|
||||||
title: 'Edit Education',
|
this.isDataLoading.set(false);
|
||||||
submitLabel: 'Save',
|
|
||||||
api: {
|
|
||||||
save: '/api/v1/admin/UpsertAcademics',
|
|
||||||
method: 'POST',
|
|
||||||
bodyKey: 'academics'
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'academics',
|
|
||||||
label: 'Education',
|
|
||||||
type: 'array',
|
|
||||||
itemConfig: [
|
|
||||||
{ name: 'academicId', label: 'ID', type: 'hidden' },
|
|
||||||
{ name: 'degree', label: 'Degree', type: 'text', required: true },
|
|
||||||
{ name: 'degreeSpecialization', label: 'Specialization', type: 'text' },
|
|
||||||
{ name: 'institution', label: 'Institution', type: 'text', required: true },
|
|
||||||
{ name: 'startYear', label: 'Start Year', type: 'year' },
|
|
||||||
{ name: 'endYear', label: 'End Year', type: 'year' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = { academics: resume?.academics ?? [] };
|
|
||||||
|
|
||||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
|
||||||
data: { config, data },
|
|
||||||
panelClass: 'dark-popup-panel'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ref.afterClosed()
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe((res: IAcademic[] | null) => {
|
|
||||||
if (res) {
|
|
||||||
this.adminState.updateResume({ ...resume!, academics: res });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
openEditExperience(): void {
|
|
||||||
const resume = this.adminQuery.getResume();
|
|
||||||
|
|
||||||
const config: DynamicFormConfig = {
|
|
||||||
title: 'Edit Experience',
|
|
||||||
submitLabel: 'Save',
|
|
||||||
api: {
|
|
||||||
save: '/api/v1/admin/UpsertExperiences',
|
|
||||||
method: 'POST',
|
|
||||||
bodyKey: 'experiences'
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'experiences',
|
|
||||||
label: 'Experiences',
|
|
||||||
type: 'array',
|
|
||||||
itemConfig: [
|
|
||||||
{ name: 'experienceId', label: 'ID', type: 'hidden' },
|
|
||||||
{ name: 'title', label: 'Job Title', type: 'text', required: true },
|
|
||||||
{ name: 'company', label: 'Company', type: 'text', required: true },
|
|
||||||
{ name: 'location', label: 'Location', type: 'text' },
|
|
||||||
{ name: 'description', label: 'Description', type: 'textarea' },
|
|
||||||
{ name: 'startDate', label: 'Start Date', type: 'date', required: true },
|
|
||||||
{ name: 'endDate', label: 'End Date', type: 'date' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = { experiences: resume?.experiences ?? [] };
|
|
||||||
|
|
||||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
|
||||||
data: { config, data },
|
|
||||||
panelClass: 'dark-popup-panel'
|
|
||||||
});
|
|
||||||
|
|
||||||
ref.afterClosed()
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe((res: IExperience[] | null) => {
|
|
||||||
if (res) {
|
|
||||||
this.adminState.updateResume({ ...resume!, experiences: res });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
openEditSkills(): void {
|
|
||||||
const resume = this.adminQuery.getResume();
|
|
||||||
|
|
||||||
const config: DynamicFormConfig = {
|
|
||||||
title: 'Edit Skills',
|
|
||||||
submitLabel: 'Save',
|
|
||||||
api: {
|
|
||||||
save: '/api/v1/admin/UpsertSkills',
|
|
||||||
method: 'POST',
|
|
||||||
bodyKey: 'skills'
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'skills',
|
|
||||||
label: 'Skills',
|
|
||||||
type: 'array',
|
|
||||||
itemConfig: [
|
|
||||||
{ name: 'skillId', label: 'ID', type: 'hidden' },
|
|
||||||
{ name: 'name', label: 'Skill Name', type: 'text', required: true },
|
|
||||||
{ name: 'description', label: 'Description', type: 'text' },
|
|
||||||
{ name: 'proficiencyLevel', label: 'Proficiency (%)', type: 'number', required: true }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = { skills: resume?.skills ?? [] };
|
|
||||||
|
|
||||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
|
||||||
data: { config, data },
|
|
||||||
panelClass: 'dark-popup-panel'
|
|
||||||
});
|
|
||||||
|
|
||||||
ref.afterClosed()
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe((res: ISkill[] | null) => {
|
|
||||||
if (res) {
|
|
||||||
this.adminState.updateResume({ ...resume!, skills: res });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
openEditCertifications(): void {
|
|
||||||
const resume = this.adminQuery.getResume();
|
|
||||||
|
|
||||||
const config: DynamicFormConfig = {
|
|
||||||
title: 'Edit Certifications',
|
|
||||||
submitLabel: 'Save',
|
|
||||||
api: {
|
|
||||||
save: '/api/v1/admin/UpsertCertifications',
|
|
||||||
method: 'POST',
|
|
||||||
bodyKey: 'certifications'
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'certifications',
|
|
||||||
label: 'Certifications',
|
|
||||||
type: 'array',
|
|
||||||
itemConfig: [
|
|
||||||
{ name: 'certificationId', label: 'ID', type: 'hidden' },
|
|
||||||
{ name: 'certificationName', label: 'Certification Name', type: 'text', required: true },
|
|
||||||
{ name: 'issuingOrganization', label: 'Issuing Organization', type: 'text', required: true },
|
|
||||||
{ name: 'certificationLink', label: 'Link', type: 'text', placeholder: 'https://...' },
|
|
||||||
{ name: 'issueDate', label: 'Issue Date', type: 'date' },
|
|
||||||
{ name: 'expiryDate', label: 'Expiry Date', type: 'date' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = { certifications: resume?.certifications ?? [] };
|
|
||||||
|
|
||||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
|
||||||
data: { config, data },
|
|
||||||
panelClass: 'dark-popup-panel'
|
|
||||||
});
|
|
||||||
|
|
||||||
ref.afterClosed()
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe((res: ICertification[] | null) => {
|
|
||||||
if (res) {
|
|
||||||
this.adminState.updateResume({ ...resume!, certifications: res });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.destroy$.next();
|
|
||||||
this.destroy$.complete();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { IAbout } from '../about/about.model';
|
import { IAbout } from '../about/about.model';
|
||||||
|
import { ICv } from '../models/cv.model';
|
||||||
import { IContactModel } from '../contact/contact.model';
|
import { IContactModel } from '../contact/contact.model';
|
||||||
import { IResume } from '../resume/resume.model';
|
import { IResume } from '../resume/resume.model';
|
||||||
import { IProjects } from '../projects/projects.model';
|
import { IProjects } from '../projects/projects.model';
|
||||||
import { IProject } from '../models/project.model';
|
|
||||||
import { IHobby } from '../models/hobby.model';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@ -14,37 +13,43 @@ import { environment } from '../../../environments/environment';
|
|||||||
})
|
})
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, '');
|
private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, '');
|
||||||
private http = inject(HttpClient);
|
public cv!: ICv;
|
||||||
|
|
||||||
|
public about!: IAbout;
|
||||||
|
public candidateAndSocialLinks!: IContactModel;
|
||||||
|
public resume!: IResume;
|
||||||
|
public projects!: IProjects;
|
||||||
|
private http: HttpClient = inject(HttpClient);
|
||||||
|
|
||||||
private api(path: string) {
|
private api(path: string) {
|
||||||
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getHobbies(): Observable<IAbout> {
|
getHobbies(candidateId: number): Observable<IAbout>{
|
||||||
return this.http.get<IAbout>(this.api('/api/v1/admin/GetHobbies'));
|
if(this.about != null){
|
||||||
|
return of(this.about);
|
||||||
|
}
|
||||||
|
return this.http.get<IAbout>(this.api(`/api/v1/admin/GetHobbies/${candidateId}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
getCandidateWithSocialLinks(): Observable<IContactModel> {
|
getCandidateWithSocialLinks(candidateId: number): Observable<IContactModel>{
|
||||||
return this.http.get<IContactModel>(this.api('/api/v1/admin/GetCandidateWithSocialLinks'));
|
if(this.candidateAndSocialLinks != null){
|
||||||
|
return of(this.candidateAndSocialLinks);
|
||||||
|
}
|
||||||
|
return this.http.get<IContactModel>(this.api(`/api/v1/admin/GetCandidateWithSocialLinks/${candidateId}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
getResume(): Observable<IResume> {
|
getResume(candidateId: number): Observable<IResume>{
|
||||||
return this.http.get<IResume>(this.api('/api/v1/admin/GetResume'));
|
if(this.resume != null){
|
||||||
|
return of(this.resume);
|
||||||
|
}
|
||||||
|
return this.http.get<IResume>(this.api(`/api/v1/admin/GetResume/${candidateId}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
getProjects(): Observable<IProjects> {
|
getProjects(candidateId: number): Observable<IProjects>{
|
||||||
return this.http.get<IProjects>(this.api('/api/v1/admin/GetProjects'));
|
if(this.projects != null){
|
||||||
|
return of(this.projects);
|
||||||
}
|
}
|
||||||
|
return this.http.get<IProjects>(this.api(`/api/v1/admin/GetProjects/${candidateId}`));
|
||||||
upsertProject(project: IProject): Observable<IProject> {
|
|
||||||
return this.http.post<IProject>(this.api('/api/v1/admin/UpsertProject'), project);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteProject(projectId: number): Observable<void> {
|
|
||||||
return this.http.delete<void>(this.api(`/api/v1/admin/DeleteProject/${projectId}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
upsertHobbies(hobbies: IHobby[]): Observable<IHobby[]> {
|
|
||||||
return this.http.post<IHobby[]>(this.api('/api/v1/admin/UpsertHobbies'), hobbies);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,140 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { tap } from 'rxjs';
|
|
||||||
import { AdminStore, AdminSection } from './admin.store';
|
|
||||||
import { AdminService } from '../services/admin.service';
|
|
||||||
import { IAbout } from '../about/about.model';
|
|
||||||
import { IHobby } from '../models/hobby.model';
|
|
||||||
import { IContactModel } from '../contact/contact.model';
|
|
||||||
import { IProjects } from '../projects/projects.model';
|
|
||||||
import { IResume } from '../resume/resume.model';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class AdminStateService {
|
|
||||||
private store = inject(AdminStore);
|
|
||||||
private api = inject(AdminService);
|
|
||||||
|
|
||||||
// ── About ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
loadAbout() {
|
|
||||||
this.store.setSectionLoading('about', true);
|
|
||||||
return this.api.getHobbies().pipe(
|
|
||||||
tap({
|
|
||||||
next: (about: IAbout) => {
|
|
||||||
this.store.update({ about });
|
|
||||||
this.store.setSectionLoading('about', false);
|
|
||||||
this.store.setSectionError('about', null);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
this.store.setSectionLoading('about', false);
|
|
||||||
this.store.setSectionError('about', err.message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAbout(about: IAbout) {
|
|
||||||
this.store.update({ about });
|
|
||||||
}
|
|
||||||
|
|
||||||
upsertHobbies(hobbies: IHobby[]) {
|
|
||||||
this.store.setSectionLoading('about', true);
|
|
||||||
return this.api.upsertHobbies(hobbies).pipe(
|
|
||||||
tap({
|
|
||||||
next: (updatedHobbies: IHobby[]) => {
|
|
||||||
this.store.update(state => ({
|
|
||||||
about: state.about ? { ...state.about, hobbies: updatedHobbies } : state.about
|
|
||||||
}));
|
|
||||||
this.store.setSectionLoading('about', false);
|
|
||||||
this.store.setSectionError('about', null);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
this.store.setSectionLoading('about', false);
|
|
||||||
this.store.setSectionError('about', err.message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Contact ────────────────────────────────────────────
|
|
||||||
|
|
||||||
loadContact() {
|
|
||||||
this.store.setSectionLoading('contact', true);
|
|
||||||
return this.api.getCandidateWithSocialLinks().pipe(
|
|
||||||
tap({
|
|
||||||
next: (contact: IContactModel) => {
|
|
||||||
this.store.update({ contact });
|
|
||||||
this.store.setSectionLoading('contact', false);
|
|
||||||
this.store.setSectionError('contact', null);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
this.store.setSectionLoading('contact', false);
|
|
||||||
this.store.setSectionError('contact', err.message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateContact(contact: IContactModel) {
|
|
||||||
this.store.update({ contact });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Projects ───────────────────────────────────────────
|
|
||||||
|
|
||||||
loadProjects() {
|
|
||||||
this.store.setSectionLoading('projects', true);
|
|
||||||
return this.api.getProjects().pipe(
|
|
||||||
tap({
|
|
||||||
next: (projects: IProjects) => {
|
|
||||||
this.store.update({ projects });
|
|
||||||
this.store.setSectionLoading('projects', false);
|
|
||||||
this.store.setSectionError('projects', null);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
this.store.setSectionLoading('projects', false);
|
|
||||||
this.store.setSectionError('projects', err.message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateProjects(projects: IProjects) {
|
|
||||||
this.store.update({ projects });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Resume ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
loadResume() {
|
|
||||||
this.store.setSectionLoading('resume', true);
|
|
||||||
return this.api.getResume().pipe(
|
|
||||||
tap({
|
|
||||||
next: (resume: IResume) => {
|
|
||||||
this.store.update({ resume });
|
|
||||||
this.store.setSectionLoading('resume', false);
|
|
||||||
this.store.setSectionError('resume', null);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
this.store.setSectionLoading('resume', false);
|
|
||||||
this.store.setSectionError('resume', err.message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateResume(resume: IResume) {
|
|
||||||
this.store.update({ resume });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Reset helpers ──────────────────────────────────────
|
|
||||||
|
|
||||||
resetSection(section: AdminSection) {
|
|
||||||
this.store.update(state => ({
|
|
||||||
[section]: null,
|
|
||||||
loading: { ...state.loading, [section]: false },
|
|
||||||
error: { ...state.error, [section]: null }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAll() {
|
|
||||||
this.store.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { Query } from '@datorama/akita';
|
|
||||||
import { AdminStore, AdminState } from './admin.store';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class AdminQuery extends Query<AdminState> {
|
|
||||||
protected override store = inject(AdminStore);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super(inject(AdminStore));
|
|
||||||
}
|
|
||||||
|
|
||||||
// About
|
|
||||||
about$ = this.select('about');
|
|
||||||
aboutLoading$ = this.select(s => s.loading.about);
|
|
||||||
aboutError$ = this.select(s => s.error.about);
|
|
||||||
|
|
||||||
// Contact
|
|
||||||
contact$ = this.select('contact');
|
|
||||||
contactLoading$ = this.select(s => s.loading.contact);
|
|
||||||
contactError$ = this.select(s => s.error.contact);
|
|
||||||
|
|
||||||
// Projects
|
|
||||||
projects$ = this.select('projects');
|
|
||||||
projectsLoading$ = this.select(s => s.loading.projects);
|
|
||||||
projectsError$ = this.select(s => s.error.projects);
|
|
||||||
|
|
||||||
// Resume
|
|
||||||
resume$ = this.select('resume');
|
|
||||||
resumeLoading$ = this.select(s => s.loading.resume);
|
|
||||||
resumeError$ = this.select(s => s.error.resume);
|
|
||||||
|
|
||||||
getAbout() {
|
|
||||||
return this.getValue().about;
|
|
||||||
}
|
|
||||||
|
|
||||||
getContact() {
|
|
||||||
return this.getValue().contact;
|
|
||||||
}
|
|
||||||
|
|
||||||
getProjects() {
|
|
||||||
return this.getValue().projects;
|
|
||||||
}
|
|
||||||
|
|
||||||
getResume() {
|
|
||||||
return this.getValue().resume;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { Store, StoreConfig } from '@datorama/akita';
|
|
||||||
import { IAbout } from '../about/about.model';
|
|
||||||
import { IContactModel } from '../contact/contact.model';
|
|
||||||
import { IProjects } from '../projects/projects.model';
|
|
||||||
import { IResume } from '../resume/resume.model';
|
|
||||||
|
|
||||||
export type AdminSection = 'about' | 'contact' | 'projects' | 'resume';
|
|
||||||
|
|
||||||
export interface AdminState {
|
|
||||||
about: IAbout | null;
|
|
||||||
contact: IContactModel | null;
|
|
||||||
projects: IProjects | null;
|
|
||||||
resume: IResume | null;
|
|
||||||
loading: Record<AdminSection, boolean>;
|
|
||||||
error: Record<AdminSection, string | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createInitialState(): AdminState {
|
|
||||||
return {
|
|
||||||
about: null,
|
|
||||||
contact: null,
|
|
||||||
projects: null,
|
|
||||||
resume: null,
|
|
||||||
loading: { about: false, contact: false, projects: false, resume: false },
|
|
||||||
error: { about: null, contact: null, projects: null, resume: null }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
@StoreConfig({ name: 'admin', resettable: true })
|
|
||||||
export class AdminStore extends Store<AdminState> {
|
|
||||||
constructor() {
|
|
||||||
super(createInitialState());
|
|
||||||
}
|
|
||||||
|
|
||||||
setSectionLoading(section: AdminSection, loading: boolean) {
|
|
||||||
this.update(state => ({
|
|
||||||
loading: { ...state.loading, [section]: loading }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
setSectionError(section: AdminSection, error: string | null) {
|
|
||||||
this.update(state => ({
|
|
||||||
error: { ...state.error, [section]: error }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
|
import { provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
|
||||||
@ -10,7 +9,6 @@ export const appConfig: ApplicationConfig = {
|
|||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZonelessChangeDetection(),
|
provideZonelessChangeDetection(),
|
||||||
provideAnimations(),
|
|
||||||
provideHttpClient(withInterceptors([loadingInterceptor, AuthInterceptor]), withFetch()),
|
provideHttpClient(withInterceptors([loadingInterceptor, AuthInterceptor]), withFetch()),
|
||||||
provideRouter(routes)
|
provideRouter(routes)
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<div>
|
<div>
|
||||||
@if(loading()){
|
@if(loader.getLoading()){
|
||||||
<app-spinner></app-spinner>
|
<app-spinner></app-spinner>
|
||||||
}
|
}
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
|
|||||||
@ -1,12 +1,8 @@
|
|||||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||||
|
|
||||||
export const serverRoutes: ServerRoute[] = [
|
export const serverRoutes: ServerRoute[] = [
|
||||||
{
|
|
||||||
path: 'admin/login',
|
|
||||||
renderMode: RenderMode.Prerender
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
renderMode: RenderMode.Client
|
renderMode: RenderMode.Prerender
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@ -13,5 +13,7 @@ import { RouterModule } from "@angular/router";
|
|||||||
export class App {
|
export class App {
|
||||||
loader = inject(LoaderService);
|
loader = inject(LoaderService);
|
||||||
protected readonly title = signal('portfolio-admin');
|
protected readonly title = signal('portfolio-admin');
|
||||||
protected readonly loading = this.loader.isLoading;
|
constructor(){
|
||||||
|
console.log('🎯 AppComponent initialized', { time: Date.now() });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
|
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
|
||||||
import { BehaviorSubject, map, Observable } from 'rxjs';
|
import { BehaviorSubject, map, Observable, of } from 'rxjs';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
@ -28,7 +28,9 @@ export class AuthService {
|
|||||||
tokenReady$ = new BehaviorSubject<boolean | null>(null);
|
tokenReady$ = new BehaviorSubject<boolean | null>(null);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
console.log('🔥 AuthService constructor started');
|
||||||
this.accessToken = this.safeGetToken();
|
this.accessToken = this.safeGetToken();
|
||||||
|
console.log('🔥 AuthService constructor finished', { accessToken: !!this.accessToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
private api(path: string) {
|
private api(path: string) {
|
||||||
@ -47,6 +49,18 @@ export class AuthService {
|
|||||||
this.tokenReady$.next(true);
|
this.tokenReady$.next(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// // Optionally: try a silent refresh on startup to restore session using HttpOnly cookie
|
||||||
|
// try {
|
||||||
|
// const res = await firstValueFrom(this.refreshToken());
|
||||||
|
// this.safeSetToken(res.accessToken);
|
||||||
|
// }
|
||||||
|
// catch{
|
||||||
|
// console.warn('Silent token refresh failed on startup');
|
||||||
|
// }
|
||||||
|
// finally{
|
||||||
|
// this.tokenReady$.next(true);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentToken(): string | null {
|
get currentToken(): string | null {
|
||||||
@ -107,11 +121,11 @@ export class AuthService {
|
|||||||
return this.accessToken;
|
return this.accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): Observable<void> {
|
||||||
this.http.post<void>(this.api('/api/v1/auth/logout'), {}).subscribe();
|
|
||||||
this.accessToken = null;
|
this.accessToken = null;
|
||||||
this.safeRemoveToken();
|
this.safeRemoveToken();
|
||||||
this.router.navigate(['/admin/login']);
|
this.router.navigate(['/admin/login']);
|
||||||
|
return of();
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshToken(): Observable<RefreshTokenResponse> {
|
refreshToken(): Observable<RefreshTokenResponse> {
|
||||||
|
|||||||
@ -28,12 +28,6 @@
|
|||||||
<form [formGroup]="otpForm" (ngSubmit)="verifyOtp()">
|
<form [formGroup]="otpForm" (ngSubmit)="verifyOtp()">
|
||||||
<label for="otp">Enter 6-digit OTP</label>
|
<label for="otp">Enter 6-digit OTP</label>
|
||||||
<input id="otp" type="text" maxlength="6" formControlName="otp" placeholder="123456" />
|
<input id="otp" type="text" maxlength="6" formControlName="otp" placeholder="123456" />
|
||||||
@if (isError()) {
|
|
||||||
<div class="error-banner">
|
|
||||||
<i class="fa-solid fa-circle-exclamation"></i>
|
|
||||||
<span>{{ message() }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<button type="submit" [disabled]="otpForm.invalid">
|
<button type="submit" [disabled]="otpForm.invalid">
|
||||||
Verify OTP
|
Verify OTP
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -132,40 +132,6 @@ button {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-text {
|
|
||||||
color: #ff6b6b;
|
|
||||||
font-size: 13px;
|
|
||||||
margin: 6px 0 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
background: rgba(255, 80, 80, 0.1);
|
|
||||||
border: 1px solid rgba(255, 80, 80, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
margin: 10px 0 10px;
|
|
||||||
color: #ff6b6b;
|
|
||||||
font-size: 13px;
|
|
||||||
animation: shakeError 0.4s ease;
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 15px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shakeError {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
20% { transform: translateX(-6px); }
|
|
||||||
40% { transform: translateX(6px); }
|
|
||||||
60% { transform: translateX(-4px); }
|
|
||||||
80% { transform: translateX(4px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-text {
|
.info-text {
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@ -82,9 +82,9 @@ export class OtpComponent implements OnInit {
|
|||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.isError.set(true);
|
this.isError.set(true);
|
||||||
if (err.status === 401 && err.error?.message) {
|
if (err.status === 401 && err.error?.message) {
|
||||||
this.message.set(err.error.message);
|
console.log(err.error.message); // "OTP Expired" or "Invalid OTP"
|
||||||
} else {
|
} else {
|
||||||
this.message.set('Something went wrong. Please try again.');
|
console.log('Something went wrong. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,16 +1,8 @@
|
|||||||
import { inject, PLATFORM_ID } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
|
||||||
import { CanActivateFn, Router } from '@angular/router';
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
|
||||||
export const authGuard: CanActivateFn = (route, state) => {
|
export const authGuard: CanActivateFn = (route, state) => {
|
||||||
const platformId = inject(PLATFORM_ID);
|
|
||||||
|
|
||||||
// During SSR there is no localStorage — skip the guard and let the client enforce auth after hydration
|
|
||||||
if (!isPlatformBrowser(platformId)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = inject(AuthService);
|
const auth = inject(AuthService);
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
|
|
||||||
|
|||||||
@ -52,12 +52,6 @@ export const AuthInterceptor: HttpInterceptorFn = (
|
|||||||
return throwError(() => err);
|
return throwError(() => err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't attempt token refresh for auth endpoints
|
|
||||||
const isAuthUrl = req.url.includes('/auth/');
|
|
||||||
if (isAuthUrl) {
|
|
||||||
return throwError(() => err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if refresh is already in progress
|
// if refresh is already in progress
|
||||||
if (refreshInProgress) {
|
if (refreshInProgress) {
|
||||||
return refreshSubject.pipe(
|
return refreshSubject.pipe(
|
||||||
|
|||||||
@ -3,27 +3,28 @@ import { inject } from "@angular/core";
|
|||||||
import { Observable, finalize } from "rxjs";
|
import { Observable, finalize } from "rxjs";
|
||||||
import { LoaderService } from "../services/loader.service";
|
import { LoaderService } from "../services/loader.service";
|
||||||
|
|
||||||
|
let totalRequests = 0;
|
||||||
|
|
||||||
export const loadingInterceptor: HttpInterceptorFn = (
|
export const loadingInterceptor: HttpInterceptorFn = (
|
||||||
req: HttpRequest<unknown>,
|
req: HttpRequest<unknown>,
|
||||||
next: HttpHandlerFn
|
next: HttpHandlerFn
|
||||||
): Observable<HttpEvent<unknown>> => {
|
): Observable<HttpEvent<unknown>> => {
|
||||||
|
|
||||||
const loader = inject(LoaderService);
|
const loadingSvc = inject(LoaderService);
|
||||||
|
|
||||||
const isRefresh = req.url.includes('/auth/RefreshToken');
|
if(req.url.includes('/RefreshToken')) {
|
||||||
if (!isRefresh) {
|
return next(req);
|
||||||
loader.increase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalRequests++;
|
||||||
|
loadingSvc.setLoading(true);
|
||||||
|
|
||||||
return next(req).pipe(
|
return next(req).pipe(
|
||||||
|
|
||||||
// catchError(err => {
|
|
||||||
// if (!isRefresh) loader.decrease();
|
|
||||||
// throw err;
|
|
||||||
// }),
|
|
||||||
|
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
if (!isRefresh) loader.decrease();
|
totalRequests--;
|
||||||
|
if (totalRequests === 0) {
|
||||||
|
loadingSvc.setLoading(false);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,24 +1,16 @@
|
|||||||
import { Injectable, signal, computed } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class LoaderService {
|
export class LoaderService {
|
||||||
|
private loading = signal(false);
|
||||||
|
|
||||||
private _requestCount = signal(0);
|
setLoading(loading: boolean){
|
||||||
|
this.loading.set(loading);
|
||||||
// computed loader state
|
|
||||||
isLoading = computed(() => this._requestCount() > 0);
|
|
||||||
|
|
||||||
increase() {
|
|
||||||
this._requestCount.update(c => c + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
decrease() {
|
getLoading(): boolean{
|
||||||
this._requestCount.update(c => Math.max(0, c - 1));
|
return this.loading();
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this._requestCount.set(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
<div class="confirm-dialog" role="alertdialog">
|
|
||||||
<div class="confirm-header">
|
|
||||||
<i class="fa-solid fa-triangle-exclamation warn-icon"></i>
|
|
||||||
<h3>{{ title }}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="confirm-message">{{ message }}</p>
|
|
||||||
|
|
||||||
<div class="confirm-actions">
|
|
||||||
<button class="btn btn-cancel" (click)="onCancel()">{{ cancelLabel }}</button>
|
|
||||||
<button class="btn" [class.btn-warn]="isWarn" [class.btn-primary]="!isWarn" (click)="onConfirm()">
|
|
||||||
{{ confirmLabel }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
.confirm-dialog {
|
|
||||||
padding: 24px;
|
|
||||||
background: var(--eerie-black-2, #1e1e1e);
|
|
||||||
border: 1px solid var(--jet, #383838);
|
|
||||||
border-radius: 14px;
|
|
||||||
color: var(--white-1, #fff);
|
|
||||||
min-width: 320px;
|
|
||||||
max-width: 420px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
color: var(--white-1, #fff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.warn-icon {
|
|
||||||
color: #ff6b6b;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-message {
|
|
||||||
margin: 0 0 20px;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--white-2, #ddd);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 8px 18px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s ease, transform 0.1s ease;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
color: var(--light-gray-70, #aaa);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: var(--white-1, #fff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warn {
|
|
||||||
background: rgba(255, 70, 70, 0.15);
|
|
||||||
color: #ff6b6b;
|
|
||||||
border: 1px solid rgba(255, 70, 70, 0.25);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(255, 70, 70, 0.25);
|
|
||||||
color: #ff4444;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: rgba(227, 179, 65, 0.15);
|
|
||||||
color: var(--orange-yellow-crayola, #e3b341);
|
|
||||||
border: 1px solid rgba(227, 179, 65, 0.25);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(227, 179, 65, 0.25);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
|
||||||
|
|
||||||
export interface ConfirmDialogData {
|
|
||||||
title?: string;
|
|
||||||
message: string;
|
|
||||||
confirmLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
confirmColor?: 'warn' | 'primary' | 'accent';
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-confirm-dialog',
|
|
||||||
standalone: true,
|
|
||||||
imports: [MatDialogModule],
|
|
||||||
templateUrl: './confirm-dialog.html',
|
|
||||||
styleUrls: ['./confirm-dialog.scss']
|
|
||||||
})
|
|
||||||
export class ConfirmDialogComponent {
|
|
||||||
private dialogRef = inject(MatDialogRef<ConfirmDialogComponent>);
|
|
||||||
data: ConfirmDialogData = inject(MAT_DIALOG_DATA);
|
|
||||||
|
|
||||||
get title(): string {
|
|
||||||
return this.data.title ?? 'Confirm';
|
|
||||||
}
|
|
||||||
|
|
||||||
get message(): string {
|
|
||||||
return this.data.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
get confirmLabel(): string {
|
|
||||||
return this.data.confirmLabel ?? 'Delete';
|
|
||||||
}
|
|
||||||
|
|
||||||
get cancelLabel(): string {
|
|
||||||
return this.data.cancelLabel ?? 'Cancel';
|
|
||||||
}
|
|
||||||
|
|
||||||
get isWarn(): boolean {
|
|
||||||
return (this.data.confirmColor ?? 'warn') === 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
onCancel(): void {
|
|
||||||
this.dialogRef.close(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
onConfirm(): void {
|
|
||||||
this.dialogRef.close(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
export interface DynamicField {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
placeholder?: string;
|
|
||||||
type: 'text' | 'textarea' | 'number' | 'date' | 'select' | 'file' | 'array' | 'hidden' | 'year';
|
|
||||||
required?: boolean;
|
|
||||||
options?: { label: string; value: unknown }[];
|
|
||||||
itemConfig?: DynamicField[];
|
|
||||||
yearRange?: { start?: number; end?: number; allowPresent?: boolean; valueType?: 'string' | 'number' };
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { DynamicField } from "./dynamic-field";
|
|
||||||
|
|
||||||
export interface DynamicFormConfig {
|
|
||||||
title: string;
|
|
||||||
submitLabel: string;
|
|
||||||
api?: {
|
|
||||||
save: string; // POST or PUT endpoint
|
|
||||||
method: 'POST' | 'PUT';
|
|
||||||
bodyKey?: string; // extract this key from form value before sending (e.g. 'academics' sends the array directly)
|
|
||||||
};
|
|
||||||
fields: DynamicField[];
|
|
||||||
}
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
<form [formGroup]="form">
|
|
||||||
|
|
||||||
@for (f of config.fields; track f) {
|
|
||||||
|
|
||||||
<ng-container *ngIf="f.type !== 'array' && f.type !== 'hidden'">
|
|
||||||
@if (f.type === 'text') {
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>{{ f.label }}</mat-label>
|
|
||||||
<input matInput [id]="f.name" [formControlName]="f.name" />
|
|
||||||
<mat-hint *ngIf="f.placeholder">{{ f.placeholder }}</mat-hint>
|
|
||||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
|
||||||
This field is required.
|
|
||||||
</mat-error>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (f.type === 'textarea') {
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>{{ f.label }}</mat-label>
|
|
||||||
<textarea matInput [id]="f.name" cdkTextareaAutosize cdkAutosizeMinRows="3" cdkAutosizeMaxRows="6" [formControlName]="f.name"></textarea>
|
|
||||||
<mat-hint *ngIf="f.placeholder">{{ f.placeholder }}</mat-hint>
|
|
||||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
|
||||||
This field is required.
|
|
||||||
</mat-error>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (f.type === 'number') {
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>{{ f.label }}</mat-label>
|
|
||||||
<input matInput [id]="f.name" type="number" [formControlName]="f.name" />
|
|
||||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
|
||||||
Please enter a valid number.
|
|
||||||
</mat-error>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (f.type === 'select') {
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>{{ f.label }}</mat-label>
|
|
||||||
<mat-select [id]="f.name" [formControlName]="f.name">
|
|
||||||
@for (opt of f.options; track opt) {
|
|
||||||
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
|
||||||
Please select a value.
|
|
||||||
</mat-error>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (f.type === 'year') {
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>{{ f.label }}</mat-label>
|
|
||||||
<mat-select [id]="f.name" [formControlName]="f.name" [compareWith]="compareYearValues">
|
|
||||||
@if (f.yearRange?.allowPresent) {
|
|
||||||
<mat-option value="Present">Present</mat-option>
|
|
||||||
}
|
|
||||||
@for (yr of getYearOptions(f); track yr) {
|
|
||||||
<mat-option [value]="yr">{{ yr }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
|
||||||
Please select a year.
|
|
||||||
</mat-error>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (f.type === 'date') {
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>{{ f.label }}</mat-label>
|
|
||||||
<input matInput [matDatepicker]="picker" [id]="f.name" [formControlName]="f.name" [placeholder]="f.placeholder || ''" />
|
|
||||||
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
|
||||||
<mat-datepicker #picker></mat-datepicker>
|
|
||||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
|
||||||
Please select a date.
|
|
||||||
</mat-error>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (f.type === 'file') {
|
|
||||||
<div>
|
|
||||||
<label [for]="f.name">{{ f.label }}</label>
|
|
||||||
<input [id]="f.name" type="file" (change)="onFileChange($event, f.name)" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ngIf="f.type === 'array'">
|
|
||||||
<div>
|
|
||||||
<h3>{{ f.label }}</h3>
|
|
||||||
<div [formArrayName]="f.name">
|
|
||||||
@for (item of getArrayControls(f.name); track item; let i = $index) {
|
|
||||||
<div [formGroupName]="i" class="array-item">
|
|
||||||
<div class="array-item-fields">
|
|
||||||
@for (sub of f.itemConfig; track sub) {
|
|
||||||
@if (sub.type !== 'hidden') {
|
|
||||||
@if (sub.type === 'date') {
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>{{ sub.label }}</mat-label>
|
|
||||||
<input matInput [matDatepicker]="subPicker" [id]="sub.name + '_' + i" [formControlName]="sub.name" [placeholder]="sub.placeholder || ''" />
|
|
||||||
<mat-datepicker-toggle matIconSuffix [for]="subPicker"></mat-datepicker-toggle>
|
|
||||||
<mat-datepicker #subPicker></mat-datepicker>
|
|
||||||
</mat-form-field>
|
|
||||||
} @else if (sub.type === 'year') {
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>{{ sub.label }}</mat-label>
|
|
||||||
<mat-select [id]="sub.name + '_' + i" [formControlName]="sub.name" [compareWith]="compareYearValues">
|
|
||||||
@if (sub.yearRange?.allowPresent) {
|
|
||||||
<mat-option value="Present">Present</mat-option>
|
|
||||||
}
|
|
||||||
@for (yr of getYearOptions(sub); track yr) {
|
|
||||||
<mat-option [value]="yr">{{ yr }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
} @else {
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>{{ sub.label }}</mat-label>
|
|
||||||
<input matInput [id]="sub.name + '_' + i" [formControlName]="sub.name" [placeholder]="sub.placeholder || ''" />
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="array-item-actions">
|
|
||||||
<button type="button" class="remove-link" (click)="removeArrayItem(f, i)">
|
|
||||||
<i class="fa-solid fa-trash-can"></i> Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<button type="button" class="add-btn" (click)="addArrayItem(f)">
|
|
||||||
<i class="fa-solid fa-plus"></i> Add Item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
}
|
|
||||||
|
|
||||||
</form>
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
/* Dynamic form spacing and material tweaks to match popup theme */
|
|
||||||
.full-width { width: 100%; display: block; }
|
|
||||||
|
|
||||||
mat-form-field.full-width { margin-bottom: 12px; }
|
|
||||||
|
|
||||||
/* Reduce label size slightly to fit popup */
|
|
||||||
mat-form-field .mat-form-field-label { font-size: 0.95rem; color: var(--light-gray-70); }
|
|
||||||
|
|
||||||
/* Make input text contrast better */
|
|
||||||
.mat-input-element,
|
|
||||||
input.mat-mdc-input-element,
|
|
||||||
textarea.mat-mdc-input-element,
|
|
||||||
.mat-mdc-input-element {
|
|
||||||
color: var(--white-1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Smaller helper/hint text */
|
|
||||||
.mat-hint { color: var(--light-gray-70); font-size: 0.85rem; }
|
|
||||||
|
|
||||||
/* Error styling consistent with theme */
|
|
||||||
mat-error { color: #ff8a80; font-size: 0.9rem; }
|
|
||||||
|
|
||||||
/* Outline border always visible, highlight on focus */
|
|
||||||
::ng-deep .mdc-notched-outline__leading,
|
|
||||||
::ng-deep .mdc-notched-outline__notch,
|
|
||||||
::ng-deep .mdc-notched-outline__trailing {
|
|
||||||
border-color: var(--light-gray-70) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .mat-mdc-form-field.mat-focused .mdc-notched-outline__leading,
|
|
||||||
::ng-deep .mat-mdc-form-field.mat-focused .mdc-notched-outline__notch,
|
|
||||||
::ng-deep .mat-mdc-form-field.mat-focused .mdc-notched-outline__trailing {
|
|
||||||
border-color: var(--orange-yellow-crayola) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make array item fields inline on larger screens */
|
|
||||||
.array-item-fields { display: flex; gap: 12px; align-items: baseline; flex-wrap: wrap; }
|
|
||||||
.array-item-fields mat-form-field { flex: 1 1 auto; margin-bottom: 0; min-width: 120px; }
|
|
||||||
|
|
||||||
/* Stack array item fields vertically on small screens */
|
|
||||||
@media (max-width: 580px) {
|
|
||||||
.array-item-fields {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.array-item-fields mat-form-field {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide subscript wrapper inside array items so fields align */
|
|
||||||
.array-item ::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove link at bottom-right of each array card */
|
|
||||||
.array-item-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-link {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: rgba(255, 255, 255, 0.35);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: color 0.2s ease, background 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: hsl(0deg 75% 60%);
|
|
||||||
background: rgba(255, 70, 70, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Force material label/input colors to match dark popup theme (covers MDC + legacy classes) */
|
|
||||||
.themed-popup {
|
|
||||||
::ng-deep .mat-form-field-label,
|
|
||||||
::ng-deep .mat-mdc-floating-label,
|
|
||||||
::ng-deep .mat-form-field .mat-form-field-label {
|
|
||||||
color: var(--light-gray-70) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .mat-input-element,
|
|
||||||
::ng-deep input.mat-input-element,
|
|
||||||
::ng-deep .mat-mdc-text-field-input,
|
|
||||||
::ng-deep textarea.mat-input-element {
|
|
||||||
color: var(--white-1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .mat-select-value-text,
|
|
||||||
::ng-deep .mat-mdc-select-value-text {
|
|
||||||
color: var(--white-1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep input::placeholder,
|
|
||||||
::ng-deep textarea::placeholder {
|
|
||||||
color: var(--light-gray-70) !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.form-array {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.array-item {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-field {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 16px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: var(--orange-yellow-crayola);
|
|
||||||
border: 1px dashed rgba(227, 179, 65, 0.3);
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 500;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
transition: background 0.2s ease, border-color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(227, 179, 65, 0.1);
|
|
||||||
border-color: rgba(227, 179, 65, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-btn {
|
|
||||||
margin-top: 8px;
|
|
||||||
background: #ff4747;
|
|
||||||
color: #fff;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { DynamicForm } from './dynamic-form';
|
|
||||||
|
|
||||||
describe('DynamicForm', () => {
|
|
||||||
let component: DynamicForm;
|
|
||||||
let fixture: ComponentFixture<DynamicForm>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [DynamicForm]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(DynamicForm);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { Component, OnInit, Input, Output, EventEmitter, inject } from '@angular/core';
|
|
||||||
import { ReactiveFormsModule, FormGroup, FormControl, Validators, FormArray, AbstractControl } from '@angular/forms';
|
|
||||||
import { DynamicFormConfig } from './dynamic-form-config';
|
|
||||||
import { DynamicField } from './dynamic-field';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
|
||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { provideNativeDateAdapter } from '@angular/material/core';
|
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-dynamic-form",
|
|
||||||
templateUrl: "./dynamic-form.html",
|
|
||||||
standalone: true,
|
|
||||||
styleUrls: ['./dynamic-form.scss'],
|
|
||||||
providers: [provideNativeDateAdapter()],
|
|
||||||
imports: [ReactiveFormsModule, CommonModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule, MatIconModule, MatDatepickerModule, MatDialogModule]
|
|
||||||
})
|
|
||||||
export class DynamicFormComponent implements OnInit {
|
|
||||||
@Input() config!: DynamicFormConfig;
|
|
||||||
@Input() initialData: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
@Output() formBuilt = new EventEmitter<FormGroup>();
|
|
||||||
|
|
||||||
private dialog = inject(MatDialog);
|
|
||||||
|
|
||||||
form!: FormGroup;
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
const group: Record<string, AbstractControl> = {};
|
|
||||||
|
|
||||||
this.config.fields.forEach(f => {
|
|
||||||
if (f.type === "array") {
|
|
||||||
group[f.name] = new FormArray(
|
|
||||||
this.initialData[f.name] && Array.isArray(this.initialData[f.name])
|
|
||||||
? (this.initialData[f.name] as unknown[]).map((item: unknown) =>
|
|
||||||
this.buildArrayItem(f.itemConfig!, item)
|
|
||||||
)
|
|
||||||
: [this.buildArrayItem(f.itemConfig!, {})]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
group[f.name] = new FormControl(
|
|
||||||
this.initialData[f.name] || "",
|
|
||||||
f.required ? Validators.required : null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.form = new FormGroup(group);
|
|
||||||
this.formBuilt.emit(this.form);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildArrayItem(config: DynamicField[], item: unknown) {
|
|
||||||
const group: Record<string, FormControl> = {};
|
|
||||||
|
|
||||||
config.forEach(f => {
|
|
||||||
group[f.name] = new FormControl(
|
|
||||||
(item as Record<string, unknown>)[f.name] || "",
|
|
||||||
f.required ? Validators.required : null
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return new FormGroup(group);
|
|
||||||
}
|
|
||||||
|
|
||||||
addArrayItem(field: DynamicField) {
|
|
||||||
const array = this.form.get(field.name) as FormArray;
|
|
||||||
array.push(this.buildArrayItem(field.itemConfig!, {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
removeArrayItem(field: DynamicField, index: number) {
|
|
||||||
const array = this.form.get(field.name) as FormArray;
|
|
||||||
|
|
||||||
this.dialog.open(ConfirmDialogComponent, {
|
|
||||||
data: {
|
|
||||||
title: 'Remove Item',
|
|
||||||
message: `Are you sure you want to remove this ${field.label?.toLowerCase() ?? 'item'}?`,
|
|
||||||
confirmLabel: 'Remove',
|
|
||||||
confirmColor: 'warn'
|
|
||||||
},
|
|
||||||
panelClass: 'dark-popup-panel',
|
|
||||||
width: '400px'
|
|
||||||
}).afterClosed().subscribe((confirmed: boolean) => {
|
|
||||||
if (confirmed) {
|
|
||||||
array.removeAt(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getArrayControls(name: string): AbstractControl[] {
|
|
||||||
return (this.form.get(name) as FormArray).controls;
|
|
||||||
}
|
|
||||||
|
|
||||||
onFileChange(e: Event, field: string) {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
if (target?.files?.length) {
|
|
||||||
const file = target.files[0];
|
|
||||||
this.form.patchValue({ [field]: file });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private yearOptionsCache = new Map<string, (number | string)[]>();
|
|
||||||
|
|
||||||
getYearOptions(field: DynamicField): (number | string)[] {
|
|
||||||
const asString = field.yearRange?.valueType === 'string';
|
|
||||||
const key = `${field.name}_${field.yearRange?.start}_${field.yearRange?.end}_${asString}`;
|
|
||||||
if (this.yearOptionsCache.has(key)) return this.yearOptionsCache.get(key)!;
|
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const start = field.yearRange?.start ?? (currentYear - 50);
|
|
||||||
const end = field.yearRange?.end ?? (currentYear + 5);
|
|
||||||
const years: (number | string)[] = [];
|
|
||||||
for (let y = end; y >= start; y--) {
|
|
||||||
years.push(asString ? y.toString() : y);
|
|
||||||
}
|
|
||||||
this.yearOptionsCache.set(key, years);
|
|
||||||
return years;
|
|
||||||
}
|
|
||||||
|
|
||||||
compareYearValues = (a: unknown, b: unknown): boolean => {
|
|
||||||
if (a == null && b == null) return true;
|
|
||||||
if (a == null || b == null) return false;
|
|
||||||
return a.toString() === b.toString();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<div class="popup-box themed-popup" role="dialog" [attr.aria-modal]="true" [attr.aria-labelledby]="titleId">
|
|
||||||
<div class="popup-header">
|
|
||||||
<h2 class="popup-title" [id]="titleId">{{ config.title }}</h2>
|
|
||||||
<button class="close-btn" (click)="close()" aria-label="Close">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="popup-content">
|
|
||||||
<app-dynamic-form class="popup-form" [config]="config" [initialData]="data" (formBuilt)="onFormBuilt($event)">
|
|
||||||
</app-dynamic-form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="popup-actions">
|
|
||||||
<button class="submit-btn primary" (click)="submit()">{{ config.submitLabel }}</button>
|
|
||||||
<button class="submit-btn" (click)="close()">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,247 +0,0 @@
|
|||||||
.themed-popup{
|
|
||||||
background: linear-gradient(180deg, hsl(240, 2%, 13%) 0%, hsl(0, 0%, 7%) 100%);
|
|
||||||
padding: 24px 28px;
|
|
||||||
border-radius: 14px;
|
|
||||||
min-width: 420px;
|
|
||||||
width: min(92vw, 820px);
|
|
||||||
max-width: 820px;
|
|
||||||
color: var(--white-2);
|
|
||||||
/* Subtle, thinner border using a dull gold */
|
|
||||||
border: 1.2px solid hsla(45, 100%, 72%, 0.45);
|
|
||||||
/* Softer shadow for separation */
|
|
||||||
box-shadow: 0 0 0 1.5px rgba(45, 45, 45, 0.5), 0 24px 64px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
|
|
||||||
font-family: var(--ff-poppins);
|
|
||||||
animation: popupFade 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
max-height: 78vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar styles moved to .popup-content */
|
|
||||||
|
|
||||||
/* Responsive min-width adjustments */
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.themed-popup {
|
|
||||||
/* slightly smaller min width on medium screens */
|
|
||||||
min-width: 360px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 920px) {
|
|
||||||
.themed-popup {
|
|
||||||
/* allow the popup to shrink more on small/tablet screens */
|
|
||||||
min-width: unset;
|
|
||||||
width: 80vw;
|
|
||||||
max-width: 80vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.themed-popup {
|
|
||||||
/* full-bleed popup on phones */
|
|
||||||
min-width: 0;
|
|
||||||
width: 80vw;
|
|
||||||
max-width: 80vw;
|
|
||||||
border-radius: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 12px;
|
|
||||||
padding-right: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-title{
|
|
||||||
margin: 0;
|
|
||||||
padding-bottom: 14px;
|
|
||||||
color: var(--white-1);
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
background: linear-gradient(90deg, var(--orange-yellow-crayola), var(--vegas-gold));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.10);
|
|
||||||
padding-bottom: 4px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--light-gray-70);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: color 150ms ease, background 150ms ease;
|
|
||||||
|
|
||||||
mat-icon, i {
|
|
||||||
font-size: 18px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--white-1);
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-form label{
|
|
||||||
display:block;
|
|
||||||
margin-bottom:6px;
|
|
||||||
color: var(--light-gray-70);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-form input,
|
|
||||||
.popup-form textarea,
|
|
||||||
.popup-form select{
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid rgba(255,255,255,0.06);
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--white-1);
|
|
||||||
min-height: 42px;
|
|
||||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-form textarea{ min-height: 92px; resize: vertical; }
|
|
||||||
|
|
||||||
.popup-form input:focus,
|
|
||||||
.popup-form textarea:focus,
|
|
||||||
.popup-form select:focus{
|
|
||||||
border-color: var(--orange-yellow-crayola);
|
|
||||||
box-shadow: 0 6px 18px rgba(36,26,18,0.12);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-content{
|
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
padding: 16px 6px 8px 0;
|
|
||||||
min-height: 0; /* allow flex child to shrink below content size */
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-actions{
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding-top: 14px;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn{
|
|
||||||
padding: 8px 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--white-2);
|
|
||||||
border: 1px solid rgba(255,255,255,0.06);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn.primary{
|
|
||||||
background: linear-gradient(90deg, var(--orange-yellow-crayola), var(--vegas-gold));
|
|
||||||
color: var(--smoky-black);
|
|
||||||
border: none;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 8px 24px rgba(45,30,10,0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn:hover{
|
|
||||||
filter: brightness(1.08);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
transition: all 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn:active{
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.array-item{ margin-bottom:8px; padding:8px; border-radius:8px; background: rgba(255,255,255,0.02); }
|
|
||||||
|
|
||||||
/* Themed scrollbar for popup content */
|
|
||||||
.popup-content::-webkit-scrollbar { width: 10px; }
|
|
||||||
.popup-content::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.popup-content::-webkit-scrollbar-thumb { background: linear-gradient(180deg, var(--orange-yellow-crayola), var(--vegas-gold)); border-radius: 8px; }
|
|
||||||
.popup-content { scrollbar-width: thin; scrollbar-color: var(--orange-yellow-crayola) transparent; }
|
|
||||||
|
|
||||||
/* textarea scrollbar */
|
|
||||||
.popup-form textarea::-webkit-scrollbar { width: 10px; }
|
|
||||||
.popup-form textarea::-webkit-scrollbar-track { background: rgba(255,255,255,0.02); border-radius:6px; }
|
|
||||||
.popup-form textarea::-webkit-scrollbar-thumb { background: linear-gradient(180deg, var(--orange-yellow-crayola), var(--vegas-gold)); border-radius:6px; }
|
|
||||||
|
|
||||||
/* Material-specific color fixes inside the themed popup */
|
|
||||||
.themed-popup {
|
|
||||||
/* Floating labels */
|
|
||||||
.mat-form-field-label,
|
|
||||||
.mat-mdc-floating-label {
|
|
||||||
color: var(--light-gray-70) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input / textarea text */
|
|
||||||
.mat-input-element,
|
|
||||||
textarea.mat-input-element,
|
|
||||||
.mat-mdc-text-field-input {
|
|
||||||
color: var(--white-1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Select value text */
|
|
||||||
.mat-select-value-text,
|
|
||||||
.mat-mdc-select-value-text {
|
|
||||||
color: var(--white-1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Placeholder visibility */
|
|
||||||
input::placeholder,
|
|
||||||
textarea::placeholder {
|
|
||||||
color: var(--light-gray-70) !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Outline/border color adjustments for outlined fields */
|
|
||||||
.mat-form-field-appearance-outline .mat-form-field-outline,
|
|
||||||
.mat-mdc-notched-outline {
|
|
||||||
stroke: rgba(255,255,255,0.06) !important;
|
|
||||||
border-color: rgba(255,255,255,0.06) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Focused state accent */
|
|
||||||
.mat-form-field.mat-focused .mat-form-field-outline,
|
|
||||||
.mat-mdc-text-field.mat-mdc-focused .mat-mdc-notched-outline {
|
|
||||||
stroke: var(--orange-yellow-crayola) !important;
|
|
||||||
border-color: var(--orange-yellow-crayola) !important;
|
|
||||||
box-shadow: 0 6px 18px rgba(36,26,18,0.12) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make mat-hint lighter */
|
|
||||||
.mat-hint {
|
|
||||||
color: var(--light-gray-70) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar for material textarea/input */
|
|
||||||
textarea.mat-input-element::-webkit-scrollbar { width: 10px; }
|
|
||||||
textarea.mat-input-element::-webkit-scrollbar-track { background: rgba(255,255,255,0.02); border-radius:6px; }
|
|
||||||
textarea.mat-input-element::-webkit-scrollbar-thumb { background: linear-gradient(180deg, var(--orange-yellow-crayola), var(--vegas-gold)); border-radius:6px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes popupFade{ from{opacity:0; transform:translateY(-8px);} to{opacity:1; transform:none;} }
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { DynamicPopupComponent } from './dynamic-popup';
|
|
||||||
|
|
||||||
describe('DynamicPopupComponent', () => {
|
|
||||||
let component: DynamicPopupComponent;
|
|
||||||
let fixture: ComponentFixture<DynamicPopupComponent >;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [DynamicPopupComponent]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(DynamicPopupComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
import { Component, EventEmitter, inject, Input, Output, OnInit, ElementRef, OnDestroy } from '@angular/core';
|
|
||||||
import { DynamicFormComponent } from '../dynamic-form/dynamic-form';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { DynamicFormConfig } from '../dynamic-form/dynamic-form-config';
|
|
||||||
import { FormGroup } from '@angular/forms';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-dynamic-popup",
|
|
||||||
templateUrl: "./dynamic-popup.html",
|
|
||||||
styleUrls: ['./dynamic-popup.scss'],
|
|
||||||
standalone: true,
|
|
||||||
imports: [DynamicFormComponent, CommonModule]
|
|
||||||
})
|
|
||||||
export class DynamicPopupComponent implements OnInit, OnDestroy {
|
|
||||||
|
|
||||||
@Input() config!: DynamicFormConfig;
|
|
||||||
@Input() data: Record<string, unknown> = {};
|
|
||||||
form!: FormGroup;
|
|
||||||
|
|
||||||
@Output() saved = new EventEmitter<unknown>();
|
|
||||||
readonly http = inject(HttpClient);
|
|
||||||
private dialogRef = inject(MatDialogRef, { optional: true }) as unknown as MatDialogRef<DynamicPopupComponent> | null;
|
|
||||||
private injectedDialogData = inject(MAT_DIALOG_DATA, { optional: true }) as { config?: DynamicFormConfig; data?: Record<string, unknown> } | undefined;
|
|
||||||
private el = inject(ElementRef);
|
|
||||||
private focusableElements: HTMLElement[] = [];
|
|
||||||
private keydownHandler?: (e: KeyboardEvent) => void;
|
|
||||||
titleId = `popup-title-${Math.random().toString(36).slice(2,9)}`;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
if (this.injectedDialogData) {
|
|
||||||
if (this.injectedDialogData.config) {
|
|
||||||
this.config = this.injectedDialogData.config;
|
|
||||||
}
|
|
||||||
if (this.injectedDialogData.data) {
|
|
||||||
this.data = this.injectedDialogData.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// setup focus trap
|
|
||||||
this.keydownHandler = (e: KeyboardEvent) => this.onKeyDown(e);
|
|
||||||
this.el.nativeElement.addEventListener('keydown', this.keydownHandler as EventListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
onFormBuilt(f: FormGroup) {
|
|
||||||
this.form = f;
|
|
||||||
setTimeout(() => {
|
|
||||||
// collect focusable elements inside this component
|
|
||||||
const nodes = this.el.nativeElement.querySelectorAll('button, a, input, textarea, select, [tabindex]:not([tabindex="-1"])') as NodeListOf<HTMLElement>;
|
|
||||||
this.focusableElements = Array.from(nodes).filter(n => !n.hasAttribute('disabled'));
|
|
||||||
if (this.focusableElements.length) {
|
|
||||||
this.focusableElements[0].focus();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
submit() {
|
|
||||||
let payload: unknown;
|
|
||||||
|
|
||||||
if (this.containsFile()) {
|
|
||||||
payload = new FormData();
|
|
||||||
Object.keys(this.form.value).forEach(k => {
|
|
||||||
(payload as FormData).append(k, this.form.value[k]);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
payload = this.form.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no API configured, just close with form value
|
|
||||||
if (!this.config.api?.save) {
|
|
||||||
this.saved.emit(payload);
|
|
||||||
if (this.dialogRef) {
|
|
||||||
this.dialogRef.close(payload);
|
|
||||||
} else {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = this.resolveApiUrl(this.config.api.save as string);
|
|
||||||
|
|
||||||
let apiBody = payload;
|
|
||||||
if (this.config.api.bodyKey && payload && typeof payload === 'object' && !(payload instanceof FormData)) {
|
|
||||||
apiBody = (payload as Record<string, unknown>)[this.config.api.bodyKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.http.request(
|
|
||||||
this.config.api.method,
|
|
||||||
url,
|
|
||||||
{ body: apiBody }
|
|
||||||
).subscribe(res => {
|
|
||||||
this.saved.emit(res);
|
|
||||||
if(this.dialogRef){
|
|
||||||
this.dialogRef.close(res);
|
|
||||||
} else {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveApiUrl(path: string) {
|
|
||||||
if (!path) return path;
|
|
||||||
if (/^https?:\/\//i.test(path)) return path;
|
|
||||||
const base = (environment.apiUrl || '').replace(/\/$/, '');
|
|
||||||
return `${base}/${path.replace(/^\//, '')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
containsFile() {
|
|
||||||
return this.config.fields.some(f => f.type === "file");
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.saved.emit(null);
|
|
||||||
if(this.dialogRef){
|
|
||||||
this.dialogRef.close(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onKeyDown(e: KeyboardEvent){
|
|
||||||
if (e.key !== 'Tab') return;
|
|
||||||
if (!this.focusableElements.length) return;
|
|
||||||
|
|
||||||
const first = this.focusableElements[0];
|
|
||||||
const last = this.focusableElements[this.focusableElements.length - 1];
|
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
if (document.activeElement === first) {
|
|
||||||
e.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (document.activeElement === last) {
|
|
||||||
e.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
if (this.keydownHandler) {
|
|
||||||
this.el.nativeElement.removeEventListener('keydown', this.keydownHandler as EventListener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,7 +5,7 @@
|
|||||||
<title>PortfolioAdmin</title>
|
<title>PortfolioAdmin</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@ -13,7 +13,6 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
@ -2,5 +2,7 @@ import { bootstrapApplication } from '@angular/platform-browser';
|
|||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
|
||||||
|
console.log('🌍 bootstrapApplication called', { time: Date.now() });
|
||||||
|
|
||||||
bootstrapApplication(App, appConfig)
|
bootstrapApplication(App, appConfig)
|
||||||
.catch((err) => console.error(err));
|
.catch((err) => console.error(err));
|
||||||
|
|||||||
217
src/styles.scss
217
src/styles.scss
@ -2,8 +2,6 @@
|
|||||||
#style.css
|
#style.css
|
||||||
\*-----------------------------------*/
|
\*-----------------------------------*/
|
||||||
|
|
||||||
@import '@angular/material/prebuilt-themes/indigo-pink.css';
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* copyright 2022 @codewithsadee
|
* copyright 2022 @codewithsadee
|
||||||
@ -399,141 +397,6 @@
|
|||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global overrides for themed popup Material elements (important to override component scoping) */
|
|
||||||
.themed-popup {
|
|
||||||
/* floating label */
|
|
||||||
.mat-form-field-label,
|
|
||||||
.mat-mdc-floating-label,
|
|
||||||
.mat-form-field .mat-form-field-label {
|
|
||||||
color: var(--light-gray-70) !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep required asterisk inline with label */
|
|
||||||
.mat-mdc-floating-label {
|
|
||||||
white-space: nowrap !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
display: inline-flex !important;
|
|
||||||
align-items: baseline !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-form-field-required-marker {
|
|
||||||
display: inline !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* input / textarea text */
|
|
||||||
.mat-form-field,
|
|
||||||
.mat-form-field .mat-input-element,
|
|
||||||
.mat-form-field textarea.mat-input-element,
|
|
||||||
.mat-form-field input.mat-input-element,
|
|
||||||
.mat-form-field .mat-mdc-text-field-input,
|
|
||||||
.mat-mdc-text-field-input,
|
|
||||||
textarea.mat-input-element,
|
|
||||||
input.mat-input-element {
|
|
||||||
color: var(--white-1) !important;
|
|
||||||
-webkit-text-fill-color: var(--white-1) !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ensure placeholders are visible */
|
|
||||||
.mat-form-field ::placeholder,
|
|
||||||
input::placeholder,
|
|
||||||
textarea::placeholder {
|
|
||||||
color: var(--light-gray-70) !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* select value */
|
|
||||||
.mat-select-value-text,
|
|
||||||
.mat-mdc-select-value-text,
|
|
||||||
.mat-form-field .mat-select-value-text {
|
|
||||||
color: var(--white-1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* outlines */
|
|
||||||
.mat-form-field-appearance-outline .mat-form-field-outline,
|
|
||||||
.mat-mdc-notched-outline,
|
|
||||||
.mat-form-field-ripple,
|
|
||||||
.mat-form-field .mat-form-field-outline {
|
|
||||||
stroke: rgba(255,255,255,0.06) !important;
|
|
||||||
border-color: rgba(255,255,255,0.06) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-form-field.mat-focused .mat-form-field-outline,
|
|
||||||
.mat-mdc-focused .mat-mdc-notched-outline,
|
|
||||||
.mat-form-field.mat-focused .mat-form-field-outline-path {
|
|
||||||
stroke: var(--orange-yellow-crayola) !important;
|
|
||||||
border-color: var(--orange-yellow-crayola) !important;
|
|
||||||
box-shadow: 0 6px 18px rgba(36,26,18,0.12) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* readability for hints */
|
|
||||||
.mat-hint { color: var(--light-gray-70) !important; opacity: 1 !important; }
|
|
||||||
|
|
||||||
/* scrollbar for textareas inside material fields */
|
|
||||||
textarea.mat-input-element::-webkit-scrollbar { width: 10px; }
|
|
||||||
textarea.mat-input-element::-webkit-scrollbar-track { background: rgba(255,255,255,0.02); border-radius:6px; }
|
|
||||||
textarea.mat-input-element::-webkit-scrollbar-thumb { background: linear-gradient(180deg, var(--orange-yellow-crayola), var(--vegas-gold)); border-radius:6px; }
|
|
||||||
|
|
||||||
/* remove possible mix-blend-mode that could dim text */
|
|
||||||
.mat-form-field, .mat-input-element { mix-blend-mode: normal !important; }
|
|
||||||
|
|
||||||
/* Very specific textarea selector to override defaults */
|
|
||||||
textarea[matInput],
|
|
||||||
.mat-form-field textarea[matInput],
|
|
||||||
.mat-form-field .mat-input-element[matInput],
|
|
||||||
textarea.mat-input-element[matInput] {
|
|
||||||
color: var(--white-1) !important;
|
|
||||||
-webkit-text-fill-color: var(--white-1) !important;
|
|
||||||
caret-color: var(--white-1) !important;
|
|
||||||
background: transparent !important;
|
|
||||||
mix-blend-mode: normal !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* also target cdk autosize textarea (if present) */
|
|
||||||
textarea[cdkTextareaAutosize] {
|
|
||||||
color: var(--white-1) !important;
|
|
||||||
-webkit-text-fill-color: var(--white-1) !important;
|
|
||||||
caret-color: var(--white-1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Strong override for focused outline specifically for textarea / text fields */
|
|
||||||
/* legacy and MDC selectors targeted to replace default blue focus */
|
|
||||||
.mat-form-field.mat-focused .mat-form-field-outline,
|
|
||||||
.mat-form-field.mat-focused .mat-form-field-outline-path,
|
|
||||||
.mat-form-field.mat-focused .mat-form-field-outline-thick,
|
|
||||||
.mat-mdc-text-field.mat-mdc-focused .mat-mdc-notched-outline,
|
|
||||||
.mat-mdc-text-field.mat-mdc-focused .mat-mdc-notched-outline .mat-mdc-notched-outline-path {
|
|
||||||
stroke: var(--orange-yellow-crayola) !important;
|
|
||||||
border-color: var(--orange-yellow-crayola) !important;
|
|
||||||
|
|
||||||
/* Additional overrides: set MDC theme variables and force a gold focus ring */
|
|
||||||
.themed-popup {
|
|
||||||
/* Override MDC primary color used by many Material components */
|
|
||||||
--mdc-theme-primary: var(--orange-yellow-crayola);
|
|
||||||
--mdc-theme-on-primary: var(--smoky-black);
|
|
||||||
|
|
||||||
/* Fallback variables for other implementations */
|
|
||||||
--mat-mdc-theme-primary: var(--orange-yellow-crayola);
|
|
||||||
|
|
||||||
/* Ensure native focus rings are replaced with a gold ring */
|
|
||||||
input:focus, textarea:focus, .mat-form-field.mat-focused, .mat-mdc-text-field.mat-mdc-focused {
|
|
||||||
outline: none !important;
|
|
||||||
box-shadow: 0 0 0 4px rgba(255,206,102,0.12) !important;
|
|
||||||
border-color: var(--orange-yellow-crayola) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Specifically target MDC notched outline path */
|
|
||||||
.mat-mdc-text-field .mat-mdc-notched-outline-path,
|
|
||||||
.mat-mdc-text-field.mat-mdc-focused .mat-mdc-notched-outline-path {
|
|
||||||
stroke: var(--orange-yellow-crayola) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
box-shadow: 0 6px 18px rgba(36,26,18,0.12) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-list {
|
.contacts-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -2025,83 +1888,3 @@
|
|||||||
.timeline-text { max-width: 700px; }
|
.timeline-text { max-width: 700px; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ── Dark popup panel (Material Dialog override) ── */
|
|
||||||
.dark-popup-panel .mat-mdc-dialog-container {
|
|
||||||
background: transparent !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
border-radius: 14px !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark-popup-panel .mat-mdc-dialog-surface {
|
|
||||||
background: transparent !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
border-radius: 14px !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Datepicker toggle icon inside dark popup */
|
|
||||||
.themed-popup .mat-datepicker-toggle,
|
|
||||||
.themed-popup .mat-datepicker-toggle button {
|
|
||||||
color: var(--light-gray-70) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themed-popup .mat-datepicker-toggle:hover,
|
|
||||||
.themed-popup .mat-datepicker-toggle button:hover {
|
|
||||||
color: var(--orange-yellow-crayola) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Datepicker calendar overlay (renders outside dialog in CDK overlay) */
|
|
||||||
.mat-datepicker-content {
|
|
||||||
background: var(--eerie-black-2, #1e1e1e) !important;
|
|
||||||
color: var(--white-1, #fff) !important;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 12px !important;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5) !important;
|
|
||||||
|
|
||||||
.mat-calendar-header,
|
|
||||||
.mat-calendar-controls {
|
|
||||||
color: var(--white-1, #fff) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-calendar-arrow {
|
|
||||||
fill: var(--white-1, #fff) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-calendar-table-header,
|
|
||||||
.mat-calendar-body-label {
|
|
||||||
color: var(--light-gray-70, rgba(255,255,255,0.7)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-calendar-body-cell-content {
|
|
||||||
color: var(--white-1, #fff) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-calendar-body-selected {
|
|
||||||
background-color: var(--orange-yellow-crayola) !important;
|
|
||||||
color: var(--smoky-black, #1a1a1a) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-calendar-body-today:not(.mat-calendar-body-selected) {
|
|
||||||
border-color: var(--orange-yellow-crayola) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-calendar-body-cell:hover .mat-calendar-body-cell-content {
|
|
||||||
background-color: rgba(255, 206, 102, 0.15) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-calendar-previous-button,
|
|
||||||
.mat-calendar-next-button,
|
|
||||||
.mat-calendar-period-button {
|
|
||||||
color: var(--white-1, #fff) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-calendar-previous-button:hover,
|
|
||||||
.mat-calendar-next-button:hover,
|
|
||||||
.mat-calendar-period-button:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user