From 55b436c7d2a9fdc942d315d92bae9bf43d2a6604 Mon Sep 17 00:00:00 2001
From: Bangara Raju Kottedi
Date: Sun, 15 Feb 2026 03:56:17 +0530
Subject: [PATCH] feat: implement dynamic form and popup components
- Added DynamicField interface to define form field structure.
- Created DynamicFormConfig interface for form configuration.
- Developed DynamicFormComponent to handle dynamic form rendering and validation.
- Implemented DynamicPopupComponent for displaying forms in a modal dialog.
- Added HTML and SCSS for dynamic form and popup styling.
- Integrated Material Design components for form inputs and buttons.
- Implemented form submission logic with API integration.
- Added tests for DynamicForm and DynamicPopup components.
- Updated global styles for Material components in themed popups.
- Included Material Icons in index.html for better UI representation.
---
package-lock.json | 33 ++-
package.json | 4 +-
src/app/admin/about/about.html | 13 +-
src/app/admin/about/about.model.ts | 1 +
src/app/admin/about/about.scss | 33 +++
src/app/admin/about/about.ts | 96 +++++--
src/app/admin/base.component.ts | 19 --
src/app/admin/contact/contact.html | 11 +-
src/app/admin/contact/contact.scss | 24 ++
src/app/admin/contact/contact.ts | 120 +++++++--
src/app/admin/models/certification.model.ts | 8 +
src/app/admin/models/experience.model.ts | 3 +
src/app/admin/models/project.model.ts | 1 -
src/app/admin/projects/projects.html | 4 +-
src/app/admin/projects/projects.ts | 67 +++--
src/app/admin/resume/resume.html | 64 ++++-
src/app/admin/resume/resume.model.ts | 2 +
src/app/admin/resume/resume.scss | 41 +++
src/app/admin/resume/resume.ts | 230 ++++++++++++++--
src/app/admin/services/admin.service.ts | 45 ++--
src/app/admin/state/admin-state.service.ts | 144 ++++++++++
src/app/admin/state/admin.query.ts | 54 ++++
src/app/admin/state/admin.store.ts | 50 ++++
src/app/app.config.ts | 2 +
src/app/app.html | 2 +-
src/app/app.ts | 4 +-
src/app/auth/auth.service.ts | 14 -
src/app/guards/auth-guard.ts | 2 +-
src/app/interceptors/loading-interceptor.ts | 23 +-
src/app/services/loader.service.ts | 22 +-
src/app/shared/dynamic-form/dynamic-field.ts | 10 +
.../dynamic-form/dynamic-form-config.ts | 12 +
src/app/shared/dynamic-form/dynamic-form.html | 141 ++++++++++
src/app/shared/dynamic-form/dynamic-form.scss | 154 +++++++++++
.../shared/dynamic-form/dynamic-form.spec.ts | 23 ++
src/app/shared/dynamic-form/dynamic-form.ts | 112 ++++++++
.../shared/dynamic-popup/dynamic-popup.html | 18 ++
.../shared/dynamic-popup/dynamic-popup.scss | 245 ++++++++++++++++++
.../dynamic-popup/dynamic-popup.spec.ts | 23 ++
src/app/shared/dynamic-popup/dynamic-popup.ts | 145 +++++++++++
src/index.html | 1 +
src/main.ts | 2 -
src/styles.scss | 207 ++++++++++++++-
43 files changed, 2043 insertions(+), 186 deletions(-)
delete mode 100644 src/app/admin/base.component.ts
create mode 100644 src/app/admin/models/certification.model.ts
create mode 100644 src/app/admin/state/admin-state.service.ts
create mode 100644 src/app/admin/state/admin.query.ts
create mode 100644 src/app/admin/state/admin.store.ts
create mode 100644 src/app/shared/dynamic-form/dynamic-field.ts
create mode 100644 src/app/shared/dynamic-form/dynamic-form-config.ts
create mode 100644 src/app/shared/dynamic-form/dynamic-form.html
create mode 100644 src/app/shared/dynamic-form/dynamic-form.scss
create mode 100644 src/app/shared/dynamic-form/dynamic-form.spec.ts
create mode 100644 src/app/shared/dynamic-form/dynamic-form.ts
create mode 100644 src/app/shared/dynamic-popup/dynamic-popup.html
create mode 100644 src/app/shared/dynamic-popup/dynamic-popup.scss
create mode 100644 src/app/shared/dynamic-popup/dynamic-popup.spec.ts
create mode 100644 src/app/shared/dynamic-popup/dynamic-popup.ts
diff --git a/package-lock.json b/package-lock.json
index b78a547..d0630c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "portfolio-admin",
"version": "0.0.0",
"dependencies": {
+ "@angular/animations": "20.3.4",
"@angular/cdk": "^20.2.5",
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
@@ -18,6 +19,7 @@
"@angular/platform-server": "^20.3.0",
"@angular/router": "^20.3.0",
"@angular/ssr": "^20.3.2",
+ "@datorama/akita": "^8.0.1",
"express": "^5.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
@@ -445,6 +447,21 @@
"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": {
"version": "20.3.5",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.5.tgz",
@@ -1098,6 +1115,16 @@
"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": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
@@ -11029,9 +11056,9 @@
}
},
"node_modules/tslib": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
+ "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==",
"license": "0BSD"
},
"node_modules/tuf-js": {
diff --git a/package.json b/package.json
index e14aab2..7a05396 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
},
"private": true,
"dependencies": {
+ "@angular/animations": "20.3.4",
"@angular/cdk": "^20.2.5",
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
@@ -34,6 +35,7 @@
"@angular/platform-server": "^20.3.0",
"@angular/router": "^20.3.0",
"@angular/ssr": "^20.3.2",
+ "@datorama/akita": "^8.0.1",
"express": "^5.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
@@ -56,4 +58,4 @@
"typescript": "~5.9.2",
"typescript-eslint": "8.46.3"
}
-}
\ No newline at end of file
+}
diff --git a/src/app/admin/about/about.html b/src/app/admin/about/about.html
index 295f588..533d98a 100644
--- a/src/app/admin/about/about.html
+++ b/src/app/admin/about/about.html
@@ -1,6 +1,10 @@
+@if (about$ | async; as model) {
- About me
+ About Me
+
@@ -15,7 +19,8 @@
- -
+ @for (hobby of model.hobbies; track hobby.hobbyId) {
+
-
@@ -27,8 +32,10 @@
+ }
-
\ No newline at end of file
+
+}
\ No newline at end of file
diff --git a/src/app/admin/about/about.model.ts b/src/app/admin/about/about.model.ts
index 7aceb18..41b8dd3 100644
--- a/src/app/admin/about/about.model.ts
+++ b/src/app/admin/about/about.model.ts
@@ -2,5 +2,6 @@ import { IHobby } from "../models/hobby.model";
export interface IAbout{
about: string;
+ title: string;
hobbies: IHobby[];
}
\ No newline at end of file
diff --git a/src/app/admin/about/about.scss b/src/app/admin/about/about.scss
index 7855284..5ab1d47 100644
--- a/src/app/admin/about/about.scss
+++ b/src/app/admin/about/about.scss
@@ -1,4 +1,37 @@
.text-style {
white-space: pre-line;
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);
}
\ No newline at end of file
diff --git a/src/app/admin/about/about.ts b/src/app/admin/about/about.ts
index 07fddc7..0884389 100644
--- a/src/app/admin/about/about.ts
+++ b/src/app/admin/about/about.ts
@@ -1,29 +1,95 @@
-import { Component, inject, OnInit } from '@angular/core';
+import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
-import { AdminService } from '../services/admin.service';
-import { IAbout } from './about.model';
-import { BaseComponent } from '../base.component';
+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 { 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({
selector: 'app-about',
- imports: [CommonModule],
+ imports: [CommonModule, MatDialogModule],
templateUrl: './about.html',
styleUrl: './about.scss'
})
-export class About extends BaseComponent implements OnInit {
- constructor() {
- const svc = inject(AdminService);
- super(svc);
- }
+export class About implements OnInit, OnDestroy {
+ private dialog = inject(MatDialog);
+ private adminQuery = inject(AdminQuery);
+ private adminState = inject(AdminStateService);
+ private destroy$ = new Subject();
+
+ about$ = this.adminQuery.about$;
+ loading$ = this.adminQuery.aboutLoading$;
+ imagesOrigin = environment.apiUrl + '/images/';
+
+ popupConfig?: DynamicFormConfig;
+ popupData: Record = {};
ngOnInit(): void {
- this.getAbout();
+ const candidateId = this.adminQuery.getCandidateId();
+ this.adminState.loadAbout(candidateId)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe();
}
- getAbout(): void{
- this.svc.getHobbies(this.candidateId).subscribe((response: IAbout) => {
- this.svc.about = this.svc.about ?? response;
- this.assignData(response);
+ openEdit(): void {
+ const currentAbout = this.adminQuery.getAbout();
+ const candidateId = this.adminQuery.getCandidateId();
+
+ this.popupConfig = {
+ title: 'Edit About',
+ submitLabel: 'Save',
+ api: {
+ save: `/api/v1/admin/UpsertHobbies/${candidateId}`,
+ 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);
+
+ if (res.hobbies) {
+ this.adminState.upsertHobbies(candidateId, res.hobbies)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe();
+ }
+ }
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
}
}
diff --git a/src/app/admin/base.component.ts b/src/app/admin/base.component.ts
deleted file mode 100644
index 6bd3506..0000000
--- a/src/app/admin/base.component.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-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 {
- public model: T = {} as T;
- candidateId = 1;
- imagesOrigin: string = environment.apiUrl + '/images/';
- isDataLoading = signal(false);
-
- constructor(public svc: AdminService) {
- }
-
- assignData(response: Partial | unknown){
- Object.assign(this.model, response);
- }
-}
\ No newline at end of file
diff --git a/src/app/admin/contact/contact.html b/src/app/admin/contact/contact.html
index ae2bb65..b2c8f0a 100644
--- a/src/app/admin/contact/contact.html
+++ b/src/app/admin/contact/contact.html
@@ -1,3 +1,4 @@
+@if (contact$ | async; as model) {
\ No newline at end of file
+
+}
\ No newline at end of file
diff --git a/src/app/admin/contact/contact.scss b/src/app/admin/contact/contact.scss
index 052f0d6..f9a38b3 100644
--- a/src/app/admin/contact/contact.scss
+++ b/src/app/admin/contact/contact.scss
@@ -59,4 +59,28 @@ img {
height: 1px;
background: rgba(255, 255, 255, 0.06);
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);
+ }
}
\ No newline at end of file
diff --git a/src/app/admin/contact/contact.ts b/src/app/admin/contact/contact.ts
index c289d1a..b851e51 100644
--- a/src/app/admin/contact/contact.ts
+++ b/src/app/admin/contact/contact.ts
@@ -1,37 +1,121 @@
-import { Component, inject, OnInit } from '@angular/core';
-import { IContactModel } from './contact.model';
-import { AdminService } from '../services/admin.service';
-import { BaseComponent } from '../base.component';
+import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
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({
selector: 'app-contact',
- imports: [CommonModule],
+ imports: [CommonModule, MatDialogModule],
templateUrl: './contact.html',
styleUrl: './contact.scss'
})
-export class Contact extends BaseComponent implements OnInit {
+export class Contact implements OnInit, OnDestroy {
sideBarExpanded = false;
- displayName!: string;
- authSvc: AuthService = inject(AuthService);
- constructor(){
- const svc = inject(AdminService);
- super(svc);
- }
-
+ authSvc = inject(AuthService);
+ private dialog = inject(MatDialog);
+ private adminQuery = inject(AdminQuery);
+ private adminState = inject(AdminStateService);
+ private destroy$ = new Subject();
+
+ contact$ = this.adminQuery.contact$;
+ loading$ = this.adminQuery.contactLoading$;
+ imagesOrigin = environment.apiUrl + '/images/';
+
+ popupConfig?: DynamicFormConfig;
+ popupData: Record = {};
+
ngOnInit(): void {
- this.getCandidateAndSocialLinks();
+ const candidateId = this.adminQuery.getCandidateId();
+ this.adminState.loadContact(candidateId)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe();
}
- getCandidateAndSocialLinks(){
- this.svc.getCandidateWithSocialLinks(this.candidateId).subscribe((response: IContactModel) => {
- this.svc.candidateAndSocialLinks = this.svc.candidateAndSocialLinks ?? response;
- this.assignData(response);
+ openEdit(): void {
+ const current = this.adminQuery.getContact();
+ const candidateId = this.adminQuery.getCandidateId();
+
+ this.popupConfig = {
+ title: 'Edit Contact',
+ submitLabel: 'Save',
+ api: {
+ save: `/api/v1/admin/UpsertContact/${candidateId}`,
+ 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() {
this.authSvc.logout();
}
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
}
diff --git a/src/app/admin/models/certification.model.ts b/src/app/admin/models/certification.model.ts
new file mode 100644
index 0000000..62bafed
--- /dev/null
+++ b/src/app/admin/models/certification.model.ts
@@ -0,0 +1,8 @@
+export interface ICertification {
+ certificationId: number;
+ certificationName: string;
+ issuingOrganization: string;
+ certificationLink: string;
+ issueDate: string;
+ expiryDate?: string;
+}
diff --git a/src/app/admin/models/experience.model.ts b/src/app/admin/models/experience.model.ts
index 4e81186..57a8944 100644
--- a/src/app/admin/models/experience.model.ts
+++ b/src/app/admin/models/experience.model.ts
@@ -8,5 +8,8 @@ export interface IExperience{
startYear: string;
endYear: string;
period: string;
+ location: string;
+ startDate: string | null;
+ endDate: string | null;
details: IExperienceDetails[];
}
\ No newline at end of file
diff --git a/src/app/admin/models/project.model.ts b/src/app/admin/models/project.model.ts
index 4aedc7e..c24d47f 100644
--- a/src/app/admin/models/project.model.ts
+++ b/src/app/admin/models/project.model.ts
@@ -3,7 +3,6 @@ export interface IProject{
name: string;
description: string;
categories: string[];
- categoryList: string[];
roles: string[];
responsibilities: string[];
technologiesUsed: string[];
diff --git a/src/app/admin/projects/projects.html b/src/app/admin/projects/projects.html
index 8e3af42..7db6189 100644
--- a/src/app/admin/projects/projects.html
+++ b/src/app/admin/projects/projects.html
@@ -12,7 +12,7 @@
- @for (category of model.projectsCategories; track category) {
+ @for (category of projectsCategories; track category) {
@@ -40,7 +40,7 @@
- @for (category of model.projectsCategories; track category) {
+ @for (category of projectsCategories; track category) {
diff --git a/src/app/admin/projects/projects.ts b/src/app/admin/projects/projects.ts
index ef5236b..4a783a7 100644
--- a/src/app/admin/projects/projects.ts
+++ b/src/app/admin/projects/projects.ts
@@ -1,10 +1,10 @@
-import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit } from '@angular/core';
+import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit, OnDestroy } from '@angular/core';
import { IProject } from '../models/project.model';
-import { BaseComponent } from '../base.component';
-import { AdminService } from '../services/admin.service';
-import { IProjects } from './projects.model';
-import { Subscription } from 'rxjs';
import { CommonModule } from '@angular/common';
+import { AdminQuery } from '../state/admin.query';
+import { AdminStateService } from '../state/admin-state.service';
+import { Subject, takeUntil } from 'rxjs';
+import { environment } from '../../../environments/environment';
@Component({
selector: 'app-projects',
@@ -13,35 +13,50 @@ import { CommonModule } from '@angular/common';
imports: [CommonModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
-export class Projects extends BaseComponent implements OnInit {
+export class Projects implements OnInit, OnDestroy {
filter = 'All';
- projects!: IProject[];
- subscription: Subscription = {} as Subscription;
+ projects: IProject[] = [];
+ projectsCategories: string[] = [];
categoryClicked = false;
- constructor() {
- const svc = inject(AdminService);
- super(svc);
- }
+ imagesOrigin = environment.apiUrl + '/images/';
+
+ private adminQuery = inject(AdminQuery);
+ private adminState = inject(AdminStateService);
+ private destroy$ = new Subject();
+
+ projects$ = this.adminQuery.projects$;
+ loading$ = this.adminQuery.projectsLoading$;
ngOnInit(): void {
- this.getProjects();
- }
+ const candidateId = this.adminQuery.getCandidateId();
+ this.adminState.loadProjects(candidateId)
+ .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);
- });
+ this.projects$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(data => {
+ if (data) {
+ this.projects = data.projects;
+ this.projectsCategories = data.projectsCategories;
+ }
+ });
}
filterProjects(category: string) {
this.filter = category;
- this.projects = this.filter === 'All'
- ? this.model.projects
- : this.model.projects.filter(
- (project: IProject) => {
- return project.categories.includes(category)
- });
+ const allProjects = this.adminQuery.getProjects();
+ if (!allProjects) return;
+
+ this.projects = category === 'All'
+ ? allProjects.projects
+ : allProjects.projects.filter(
+ (project: IProject) => project.categories.includes(category)
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
}
}
diff --git a/src/app/admin/resume/resume.html b/src/app/admin/resume/resume.html
index 871ab1b..6207307 100644
--- a/src/app/admin/resume/resume.html
+++ b/src/app/admin/resume/resume.html
@@ -1,3 +1,4 @@
+@if (resume$ | async; as model) {
@@ -12,11 +13,15 @@
Education
+
+
- @for (education of model.academics; track education) {
+ @for (education of model.academics; track education.academicId) {
-
@@ -45,11 +50,15 @@
Experience
+
+
- @for (experience of model.experiences; track experience) {
+ @for (experience of model.experiences; track experience.experienceId) {
-
@@ -71,11 +80,17 @@
-
My skills
+
+
My skills
+
+
+
- @for (skill of model.skills; track skill) {
+ @for (skill of model.skills; track skill.skillId) {
-
@@ -96,4 +111,43 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
Certifications
+
+
+
+
+
+
+ @for (cert of model.certifications; track cert.certificationId) {
+
+ -
+
+
{{cert.certificationName}}
+
+ {{cert.issuingOrganization}}
+
+ @if (cert.certificationLink) {
+
+ View Certificate
+
+ }
+
+
+
+ }
+
+
+
+
+
+
+}
\ No newline at end of file
diff --git a/src/app/admin/resume/resume.model.ts b/src/app/admin/resume/resume.model.ts
index 46252f7..fdbb93b 100644
--- a/src/app/admin/resume/resume.model.ts
+++ b/src/app/admin/resume/resume.model.ts
@@ -1,4 +1,5 @@
import { IAcademic } from "../models/academic.model";
+import { ICertification } from "../models/certification.model";
import { IExperience } from "../models/experience.model";
import { ISkill } from "../models/skill.model";
@@ -6,4 +7,5 @@ export interface IResume{
academics?: IAcademic[];
experiences?: IExperience[];
skills?: ISkill[];
+ certifications?: ICertification[];
}
\ No newline at end of file
diff --git a/src/app/admin/resume/resume.scss b/src/app/admin/resume/resume.scss
index e69de29..b862ea3 100644
--- a/src/app/admin/resume/resume.scss
+++ b/src/app/admin/resume/resume.scss
@@ -0,0 +1,41 @@
+.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;
+ }
+}
diff --git a/src/app/admin/resume/resume.ts b/src/app/admin/resume/resume.ts
index d6d3df9..5bf06d5 100644
--- a/src/app/admin/resume/resume.ts
+++ b/src/app/admin/resume/resume.ts
@@ -1,39 +1,219 @@
-import { Component, inject, OnInit, PLATFORM_ID } from '@angular/core';
-import { CommonModule, isPlatformBrowser, isPlatformServer } from '@angular/common';
-import { BaseComponent } from '../base.component';
-import { IResume } from './resume.model';
-import { AdminService } from '../services/admin.service';
+import { Component, inject, OnInit, OnDestroy } from '@angular/core';
+import { CommonModule } from '@angular/common';
+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 { 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({
selector: 'app-resume',
templateUrl: './resume.html',
styleUrl: './resume.scss',
- imports: [CommonModule]
+ imports: [CommonModule, MatDialogModule]
})
-export class Resume extends BaseComponent implements OnInit {
- platformId = inject(PLATFORM_ID);
+export class Resume implements OnInit, OnDestroy {
+ private dialog = inject(MatDialog);
+ private adminQuery = inject(AdminQuery);
+ private adminState = inject(AdminStateService);
+ private destroy$ = new Subject();
- constructor() {
- const svc = inject(AdminService);
- super(svc);
- console.log("Resume component constructed");
- }
+ resume$ = this.adminQuery.resume$;
+ loading$ = this.adminQuery.resumeLoading$;
ngOnInit(): void {
- console.log("Resume component initialized");
- console.log("Server:", isPlatformServer(this.platformId));
- console.log("Browser:", isPlatformBrowser(this.platformId));
- if(!this.isDataLoading()) {
- this.isDataLoading.set(true);
- this.getResume();
- }
+ const candidateId = this.adminQuery.getCandidateId();
+ this.adminState.loadResume(candidateId)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe();
}
- getResume() {
- this.svc.getResume(this.candidateId).subscribe((response: IResume) => {
- this.svc.resume = this.svc.resume ?? response;
- this.assignData(response);
- this.isDataLoading.set(false);
+ openEditEducation(): void {
+ const resume = this.adminQuery.getResume();
+ const candidateId = this.adminQuery.getCandidateId();
+
+ const config: DynamicFormConfig = {
+ title: 'Edit Education',
+ submitLabel: 'Save',
+ api: {
+ save: `/api/v1/admin/UpsertAcademics/${candidateId}`,
+ 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 candidateId = this.adminQuery.getCandidateId();
+
+ const config: DynamicFormConfig = {
+ title: 'Edit Experience',
+ submitLabel: 'Save',
+ api: {
+ save: `/api/v1/admin/UpsertExperiences/${candidateId}`,
+ 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 candidateId = this.adminQuery.getCandidateId();
+
+ const config: DynamicFormConfig = {
+ title: 'Edit Skills',
+ submitLabel: 'Save',
+ api: {
+ save: `/api/v1/admin/UpsertSkills/${candidateId}`,
+ 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 candidateId = this.adminQuery.getCandidateId();
+
+ const config: DynamicFormConfig = {
+ title: 'Edit Certifications',
+ submitLabel: 'Save',
+ api: {
+ save: `/api/v1/admin/UpsertCertifications/${candidateId}`,
+ 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();
}
}
diff --git a/src/app/admin/services/admin.service.ts b/src/app/admin/services/admin.service.ts
index 9d23dd0..020bb2a 100644
--- a/src/app/admin/services/admin.service.ts
+++ b/src/app/admin/services/admin.service.ts
@@ -1,11 +1,12 @@
import { inject, Injectable } from '@angular/core';
import { IAbout } from '../about/about.model';
-import { ICv } from '../models/cv.model';
import { IContactModel } from '../contact/contact.model';
import { IResume } from '../resume/resume.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 { Observable, of } from 'rxjs';
+import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
@Injectable({
@@ -13,43 +14,37 @@ import { environment } from '../../../environments/environment';
})
export class AdminService {
private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, '');
- public cv!: ICv;
-
- public about!: IAbout;
- public candidateAndSocialLinks!: IContactModel;
- public resume!: IResume;
- public projects!: IProjects;
- private http: HttpClient = inject(HttpClient);
+ private http = inject(HttpClient);
private api(path: string) {
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
}
- getHobbies(candidateId: number): Observable{
- if(this.about != null){
- return of(this.about);
- }
+ getHobbies(candidateId: number): Observable {
return this.http.get(this.api(`/api/v1/admin/GetHobbies/${candidateId}`));
}
- getCandidateWithSocialLinks(candidateId: number): Observable{
- if(this.candidateAndSocialLinks != null){
- return of(this.candidateAndSocialLinks);
- }
+ getCandidateWithSocialLinks(candidateId: number): Observable {
return this.http.get(this.api(`/api/v1/admin/GetCandidateWithSocialLinks/${candidateId}`));
}
- getResume(candidateId: number): Observable{
- if(this.resume != null){
- return of(this.resume);
- }
+ getResume(candidateId: number): Observable {
return this.http.get(this.api(`/api/v1/admin/GetResume/${candidateId}`));
}
- getProjects(candidateId: number): Observable{
- if(this.projects != null){
- return of(this.projects);
- }
+ getProjects(candidateId: number): Observable {
return this.http.get(this.api(`/api/v1/admin/GetProjects/${candidateId}`));
}
+
+ upsertProject(project: IProject): Observable {
+ return this.http.post(this.api('/api/v1/admin/UpsertProject'), project);
+ }
+
+ upsertHobbies(resumeId: number, hobbies: IHobby[]): Observable {
+ return this.http.post(this.api(`/api/v1/admin/UpsertHobbies/${resumeId}`), hobbies);
+ }
+
+ upsertResume(candidateId: number, resume: IResume): Observable {
+ return this.http.post(this.api(`/api/v1/admin/UpsertResume/${candidateId}`), resume);
+ }
}
diff --git a/src/app/admin/state/admin-state.service.ts b/src/app/admin/state/admin-state.service.ts
new file mode 100644
index 0000000..9c0f246
--- /dev/null
+++ b/src/app/admin/state/admin-state.service.ts
@@ -0,0 +1,144 @@
+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);
+
+ setCandidateId(id: number) {
+ this.store.update({ candidateId: id });
+ }
+
+ // ── About ──────────────────────────────────────────────
+
+ loadAbout(candidateId: number) {
+ this.store.setSectionLoading('about', true);
+ return this.api.getHobbies(candidateId).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(resumeId: number, hobbies: IHobby[]) {
+ this.store.setSectionLoading('about', true);
+ return this.api.upsertHobbies(resumeId, 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(candidateId: number) {
+ this.store.setSectionLoading('contact', true);
+ return this.api.getCandidateWithSocialLinks(candidateId).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(candidateId: number) {
+ this.store.setSectionLoading('projects', true);
+ return this.api.getProjects(candidateId).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(candidateId: number) {
+ this.store.setSectionLoading('resume', true);
+ return this.api.getResume(candidateId).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();
+ }
+}
diff --git a/src/app/admin/state/admin.query.ts b/src/app/admin/state/admin.query.ts
new file mode 100644
index 0000000..0dbe1e9
--- /dev/null
+++ b/src/app/admin/state/admin.query.ts
@@ -0,0 +1,54 @@
+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 {
+ protected override store = inject(AdminStore);
+
+ constructor() {
+ super(inject(AdminStore));
+ }
+ // Candidate
+ candidateId$ = this.select('candidateId');
+
+ // 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;
+ }
+
+ getCandidateId() {
+ return this.getValue().candidateId;
+ }
+}
diff --git a/src/app/admin/state/admin.store.ts b/src/app/admin/state/admin.store.ts
new file mode 100644
index 0000000..83ed4a5
--- /dev/null
+++ b/src/app/admin/state/admin.store.ts
@@ -0,0 +1,50 @@
+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 {
+ candidateId: number;
+ about: IAbout | null;
+ contact: IContactModel | null;
+ projects: IProjects | null;
+ resume: IResume | null;
+ loading: Record;
+ error: Record;
+}
+
+export function createInitialState(): AdminState {
+ return {
+ candidateId: 1,
+ 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 {
+ 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 }
+ }));
+ }
+}
diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index e44eacb..6401df0 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -1,4 +1,5 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
+import { provideAnimations } from '@angular/platform-browser/animations';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
@@ -9,6 +10,7 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
+ provideAnimations(),
provideHttpClient(withInterceptors([loadingInterceptor, AuthInterceptor]), withFetch()),
provideRouter(routes)
]
diff --git a/src/app/app.html b/src/app/app.html
index 45e94f0..704226a 100644
--- a/src/app/app.html
+++ b/src/app/app.html
@@ -1,5 +1,5 @@
- @if(loader.getLoading()){
+ @if(loading()){
}
diff --git a/src/app/app.ts b/src/app/app.ts
index c0e16c3..53e0495 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -13,7 +13,5 @@ import { RouterModule } from "@angular/router";
export class App {
loader = inject(LoaderService);
protected readonly title = signal('portfolio-admin');
- constructor(){
- console.log('🎯 AppComponent initialized', { time: Date.now() });
- }
+ protected readonly loading = this.loader.isLoading;
}
diff --git a/src/app/auth/auth.service.ts b/src/app/auth/auth.service.ts
index 19f4a7b..a9e713a 100644
--- a/src/app/auth/auth.service.ts
+++ b/src/app/auth/auth.service.ts
@@ -28,9 +28,7 @@ export class AuthService {
tokenReady$ = new BehaviorSubject
(null);
constructor() {
- console.log('🔥 AuthService constructor started');
this.accessToken = this.safeGetToken();
- console.log('🔥 AuthService constructor finished', { accessToken: !!this.accessToken });
}
private api(path: string) {
@@ -49,18 +47,6 @@ export class AuthService {
this.tokenReady$.next(true);
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 {
diff --git a/src/app/guards/auth-guard.ts b/src/app/guards/auth-guard.ts
index 0720204..7ffdf78 100644
--- a/src/app/guards/auth-guard.ts
+++ b/src/app/guards/auth-guard.ts
@@ -2,7 +2,7 @@ import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../auth/auth.service';
-export const authGuard: CanActivateFn = (route, state) => {
+export const authGuard: CanActivateFn = (route, state)=> {
const auth = inject(AuthService);
const router = inject(Router);
diff --git a/src/app/interceptors/loading-interceptor.ts b/src/app/interceptors/loading-interceptor.ts
index 4bf0024..456fe83 100644
--- a/src/app/interceptors/loading-interceptor.ts
+++ b/src/app/interceptors/loading-interceptor.ts
@@ -3,28 +3,27 @@ import { inject } from "@angular/core";
import { Observable, finalize } from "rxjs";
import { LoaderService } from "../services/loader.service";
-let totalRequests = 0;
-
export const loadingInterceptor: HttpInterceptorFn = (
req: HttpRequest,
next: HttpHandlerFn
): Observable> => {
- const loadingSvc = inject(LoaderService);
+ const loader = inject(LoaderService);
- if(req.url.includes('/RefreshToken')) {
- return next(req);
+ const isRefresh = req.url.includes('/auth/RefreshToken');
+ if (!isRefresh) {
+ loader.increase();
}
- totalRequests++;
- loadingSvc.setLoading(true);
-
return next(req).pipe(
+
+ // catchError(err => {
+ // if (!isRefresh) loader.decrease();
+ // throw err;
+ // }),
+
finalize(() => {
- totalRequests--;
- if (totalRequests === 0) {
- loadingSvc.setLoading(false);
- }
+ if (!isRefresh) loader.decrease();
})
);
};
diff --git a/src/app/services/loader.service.ts b/src/app/services/loader.service.ts
index 87a45b0..9b7b6a1 100644
--- a/src/app/services/loader.service.ts
+++ b/src/app/services/loader.service.ts
@@ -1,16 +1,24 @@
-import { Injectable, signal } from '@angular/core';
+import { Injectable, signal, computed } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class LoaderService {
- private loading = signal(false);
-
- setLoading(loading: boolean){
- this.loading.set(loading);
+
+ private _requestCount = signal(0);
+
+ // computed loader state
+ isLoading = computed(() => this._requestCount() > 0);
+
+ increase() {
+ this._requestCount.update(c => c + 1);
}
- getLoading(): boolean{
- return this.loading();
+ decrease() {
+ this._requestCount.update(c => Math.max(0, c - 1));
+ }
+
+ reset() {
+ this._requestCount.set(0);
}
}
diff --git a/src/app/shared/dynamic-form/dynamic-field.ts b/src/app/shared/dynamic-form/dynamic-field.ts
new file mode 100644
index 0000000..50cb960
--- /dev/null
+++ b/src/app/shared/dynamic-form/dynamic-field.ts
@@ -0,0 +1,10 @@
+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' };
+}
\ No newline at end of file
diff --git a/src/app/shared/dynamic-form/dynamic-form-config.ts b/src/app/shared/dynamic-form/dynamic-form-config.ts
new file mode 100644
index 0000000..a268842
--- /dev/null
+++ b/src/app/shared/dynamic-form/dynamic-form-config.ts
@@ -0,0 +1,12 @@
+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[];
+}
\ No newline at end of file
diff --git a/src/app/shared/dynamic-form/dynamic-form.html b/src/app/shared/dynamic-form/dynamic-form.html
new file mode 100644
index 0000000..5fbda29
--- /dev/null
+++ b/src/app/shared/dynamic-form/dynamic-form.html
@@ -0,0 +1,141 @@
+
\ No newline at end of file
diff --git a/src/app/shared/dynamic-form/dynamic-form.scss b/src/app/shared/dynamic-form/dynamic-form.scss
new file mode 100644
index 0000000..12fbdc6
--- /dev/null
+++ b/src/app/shared/dynamic-form/dynamic-form.scss
@@ -0,0 +1,154 @@
+/* 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;
+}
\ No newline at end of file
diff --git a/src/app/shared/dynamic-form/dynamic-form.spec.ts b/src/app/shared/dynamic-form/dynamic-form.spec.ts
new file mode 100644
index 0000000..15fab31
--- /dev/null
+++ b/src/app/shared/dynamic-form/dynamic-form.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DynamicForm } from './dynamic-form';
+
+describe('DynamicForm', () => {
+ let component: DynamicForm;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [DynamicForm]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(DynamicForm);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/shared/dynamic-form/dynamic-form.ts b/src/app/shared/dynamic-form/dynamic-form.ts
new file mode 100644
index 0000000..4d87056
--- /dev/null
+++ b/src/app/shared/dynamic-form/dynamic-form.ts
@@ -0,0 +1,112 @@
+import { CommonModule } from '@angular/common';
+import { Component, OnInit, Input, Output, EventEmitter } 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 { provideNativeDateAdapter } from '@angular/material/core';
+
+@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]
+})
+export class DynamicFormComponent implements OnInit {
+ @Input() config!: DynamicFormConfig;
+ @Input() initialData: Record = {};
+
+ @Output() formBuilt = new EventEmitter();
+
+ form!: FormGroup;
+
+ ngOnInit() {
+ const group: Record = {};
+
+ 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 = {};
+
+ config.forEach(f => {
+ group[f.name] = new FormControl(
+ (item as Record)[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;
+ 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();
+
+ 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();
+ };
+}
diff --git a/src/app/shared/dynamic-popup/dynamic-popup.html b/src/app/shared/dynamic-popup/dynamic-popup.html
new file mode 100644
index 0000000..864074e
--- /dev/null
+++ b/src/app/shared/dynamic-popup/dynamic-popup.html
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/src/app/shared/dynamic-popup/dynamic-popup.scss b/src/app/shared/dynamic-popup/dynamic-popup.scss
new file mode 100644
index 0000000..bc3edaf
--- /dev/null
+++ b/src/app/shared/dynamic-popup/dynamic-popup.scss
@@ -0,0 +1,245 @@
+.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);
+ box-shadow: 0 24px 64px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
+ border: 1px solid rgba(255,255,255,0.10);
+ 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;} }
diff --git a/src/app/shared/dynamic-popup/dynamic-popup.spec.ts b/src/app/shared/dynamic-popup/dynamic-popup.spec.ts
new file mode 100644
index 0000000..17af470
--- /dev/null
+++ b/src/app/shared/dynamic-popup/dynamic-popup.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DynamicPopupComponent } from './dynamic-popup';
+
+describe('DynamicPopupComponent', () => {
+ let component: DynamicPopupComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [DynamicPopupComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(DynamicPopupComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/shared/dynamic-popup/dynamic-popup.ts b/src/app/shared/dynamic-popup/dynamic-popup.ts
new file mode 100644
index 0000000..8b14603
--- /dev/null
+++ b/src/app/shared/dynamic-popup/dynamic-popup.ts
@@ -0,0 +1,145 @@
+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 = {};
+ form!: FormGroup;
+
+ @Output() saved = new EventEmitter();
+ readonly http = inject(HttpClient);
+ private dialogRef = inject(MatDialogRef, { optional: true }) as unknown as MatDialogRef | null;
+ private injectedDialogData = inject(MAT_DIALOG_DATA, { optional: true }) as { config?: DynamicFormConfig; data?: Record } | 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;
+ 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)[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);
+ }
+ }
+}
diff --git a/src/index.html b/src/index.html
index 70ebe2a..71b0f64 100644
--- a/src/index.html
+++ b/src/index.html
@@ -13,6 +13,7 @@
+
diff --git a/src/main.ts b/src/main.ts
index 4831653..5df75f9 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,7 +2,5 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
-console.log('🌍 bootstrapApplication called', { time: Date.now() });
-
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
diff --git a/src/styles.scss b/src/styles.scss
index 0ffd987..5ee9f7f 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -2,6 +2,8 @@
#style.css
\*-----------------------------------*/
+@import '@angular/material/prebuilt-themes/indigo-pink.css';
+
/**
* copyright 2022 @codewithsadee
@@ -396,6 +398,129 @@
opacity: 1;
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;
+ }
+
+ /* 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 {
display: grid;
@@ -1887,4 +2012,84 @@
.timeline-text { max-width: 700px; }
- }
\ No newline at end of file
+ }
+
+
+/* ── 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;
+ }
+}
\ No newline at end of file