From 55b436c7d2a9fdc942d315d92bae9bf43d2a6604 Mon Sep 17 00:00:00 2001 From: Bangara Raju Kottedi Date: Sun, 15 Feb 2026 03:56:17 +0530 Subject: [PATCH 1/5] 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) {
    1. @@ -45,11 +50,15 @@

      Experience

      + +
        - @for (experience of model.experiences; track experience) { + @for (experience of model.experiences; track experience.experienceId) {
      1. @@ -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) { + +
    1. + +

      {{cert.certificationName}}

      + + {{cert.issuingOrganization}} + + @if (cert.certificationLink) { + + View Certificate + + } + +
    2. + + } + +
    + +
    + + +} \ 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 @@ +
    + + @for (f of config.fields; track f) { + + + @if (f.type === 'text') { + + {{ f.label }} + + {{ f.placeholder }} + + This field is required. + + + } + + @if (f.type === 'textarea') { + + {{ f.label }} + + {{ f.placeholder }} + + This field is required. + + + } + + @if (f.type === 'number') { + + {{ f.label }} + + + Please enter a valid number. + + + } + + @if (f.type === 'select') { + + {{ f.label }} + + @for (opt of f.options; track opt) { + {{ opt.label }} + } + + + Please select a value. + + + } + + @if (f.type === 'year') { + + {{ f.label }} + + @if (f.yearRange?.allowPresent) { + Present + } + @for (yr of getYearOptions(f); track yr) { + {{ yr }} + } + + + Please select a year. + + + } + + @if (f.type === 'date') { + + {{ f.label }} + + + + + Please select a date. + + + } + + @if (f.type === 'file') { +
    + + +
    + } +
    + + +
    +

    {{ f.label }}

    +
    + @for (item of getArrayControls(f.name); track item; let i = $index) { +
    +
    + @for (sub of f.itemConfig; track sub) { + @if (sub.type !== 'hidden') { + @if (sub.type === 'date') { + + {{ sub.label }} + + + + + } @else if (sub.type === 'year') { + + {{ sub.label }} + + @if (sub.yearRange?.allowPresent) { + Present + } + @for (yr of getYearOptions(sub); track yr) { + {{ yr }} + } + + + } @else { + + {{ sub.label }} + + + } + } + } +
    +
    + +
    +
    + } +
    + +
    +
    + } + +
    \ 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 From 58929ae6d43b713b37cef97c8ad3db0066563c1b Mon Sep 17 00:00:00 2001 From: Bangara Raju Kottedi Date: Sun, 15 Feb 2026 05:27:23 +0530 Subject: [PATCH 2/5] feat: enhance dynamic forms and popups for About, Contact, and Resume sections; add project detail popup --- src/app/admin/about/about.ts | 18 +- src/app/admin/contact/contact.ts | 12 +- src/app/admin/models/project.model.ts | 7 + .../project-detail-popup.html | 101 +++++++++++ .../project-detail-popup.scss | 107 +++++++++++ .../project-detail-popup.ts | 20 ++ src/app/admin/projects/projects.html | 15 +- src/app/admin/projects/projects.scss | 90 +++++++++ src/app/admin/projects/projects.ts | 171 ++++++++++++++++-- src/app/admin/resume/resume.ts | 21 +-- src/app/admin/services/admin.service.ts | 24 +-- src/app/admin/state/admin-state.service.ts | 24 +-- src/app/admin/state/admin.query.ts | 6 - src/app/admin/state/admin.store.ts | 2 - src/app/app.routes.server.ts | 6 +- src/app/auth/auth.service.ts | 6 +- src/app/auth/otp/otp.component.html | 6 + src/app/auth/otp/otp.component.scss | 34 ++++ src/app/auth/otp/otp.component.ts | 4 +- src/app/guards/auth-guard.ts | 12 +- src/app/interceptors/auth-interceptor.ts | 6 + 21 files changed, 605 insertions(+), 87 deletions(-) create mode 100644 src/app/admin/projects/project-detail-popup/project-detail-popup.html create mode 100644 src/app/admin/projects/project-detail-popup/project-detail-popup.scss create mode 100644 src/app/admin/projects/project-detail-popup/project-detail-popup.ts diff --git a/src/app/admin/about/about.ts b/src/app/admin/about/about.ts index 0884389..d5a033a 100644 --- a/src/app/admin/about/about.ts +++ b/src/app/admin/about/about.ts @@ -29,21 +29,21 @@ export class About implements OnInit, OnDestroy { popupData: Record = {}; ngOnInit(): void { - const candidateId = this.adminQuery.getCandidateId(); - this.adminState.loadAbout(candidateId) - .pipe(takeUntil(this.destroy$)) - .subscribe(); + if (!this.adminQuery.getAbout()) { + this.adminState.loadAbout() + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } } 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}`, + save: '/api/v1/admin/UpsertHobbies', method: 'POST' }, fields: [ @@ -78,12 +78,6 @@ export class About implements OnInit, OnDestroy { 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(); - } } }); } diff --git a/src/app/admin/contact/contact.ts b/src/app/admin/contact/contact.ts index b851e51..a87a8ea 100644 --- a/src/app/admin/contact/contact.ts +++ b/src/app/admin/contact/contact.ts @@ -32,21 +32,21 @@ export class Contact implements OnInit, OnDestroy { popupData: Record = {}; ngOnInit(): void { - const candidateId = this.adminQuery.getCandidateId(); - this.adminState.loadContact(candidateId) - .pipe(takeUntil(this.destroy$)) - .subscribe(); + if (!this.adminQuery.getContact()) { + this.adminState.loadContact() + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } } 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}`, + save: '/api/v1/admin/UpsertContact', method: 'POST' }, fields: [ diff --git a/src/app/admin/models/project.model.ts b/src/app/admin/models/project.model.ts index c24d47f..38ad3f6 100644 --- a/src/app/admin/models/project.model.ts +++ b/src/app/admin/models/project.model.ts @@ -7,4 +7,11 @@ export interface IProject{ responsibilities: string[]; technologiesUsed: string[]; imagePath: string; + challenges: string; + lessonsLearned: string; + impact: string; + startDate: string; + endDate: string; + status: string; + resumeId: number; } \ No newline at end of file diff --git a/src/app/admin/projects/project-detail-popup/project-detail-popup.html b/src/app/admin/projects/project-detail-popup/project-detail-popup.html new file mode 100644 index 0000000..590af70 --- /dev/null +++ b/src/app/admin/projects/project-detail-popup/project-detail-popup.html @@ -0,0 +1,101 @@ + diff --git a/src/app/admin/projects/project-detail-popup/project-detail-popup.scss b/src/app/admin/projects/project-detail-popup/project-detail-popup.scss new file mode 100644 index 0000000..7bb14b1 --- /dev/null +++ b/src/app/admin/projects/project-detail-popup/project-detail-popup.scss @@ -0,0 +1,107 @@ +.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); + } +} diff --git a/src/app/admin/projects/project-detail-popup/project-detail-popup.ts b/src/app/admin/projects/project-detail-popup/project-detail-popup.ts new file mode 100644 index 0000000..dbd090f --- /dev/null +++ b/src/app/admin/projects/project-detail-popup/project-detail-popup.ts @@ -0,0 +1,20 @@ +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); + project: IProject = inject(MAT_DIALOG_DATA); + + close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/admin/projects/projects.html b/src/app/admin/projects/projects.html index 7db6189..236ac2f 100644 --- a/src/app/admin/projects/projects.html +++ b/src/app/admin/projects/projects.html @@ -2,6 +2,9 @@

    Projects

    +
    @@ -56,9 +59,9 @@
    -
    - -
    + {{project.name}}
    @@ -70,6 +73,12 @@ }
    + + } diff --git a/src/app/admin/projects/projects.scss b/src/app/admin/projects/projects.scss index cc380e9..b2e9aea 100644 --- a/src/app/admin/projects/projects.scss +++ b/src/app/admin/projects/projects.scss @@ -4,4 +4,94 @@ .no-margin{ 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; } \ No newline at end of file diff --git a/src/app/admin/projects/projects.ts b/src/app/admin/projects/projects.ts index 4a783a7..9a1316d 100644 --- a/src/app/admin/projects/projects.ts +++ b/src/app/admin/projects/projects.ts @@ -1,16 +1,22 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit, OnDestroy } from '@angular/core'; import { IProject } from '../models/project.model'; 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 { ProjectDetailPopupComponent } from './project-detail-popup/project-detail-popup'; import { AdminQuery } from '../state/admin.query'; import { AdminStateService } from '../state/admin-state.service'; -import { Subject, takeUntil } from 'rxjs'; +import { AdminService } from '../services/admin.service'; +import { Subject, takeUntil, filter } from 'rxjs'; import { environment } from '../../../environments/environment'; +import { HttpClient } from '@angular/common/http'; @Component({ selector: 'app-projects', templateUrl: './projects.html', styleUrl: './projects.scss', - imports: [CommonModule], + imports: [CommonModule, MatDialogModule], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class Projects implements OnInit, OnDestroy { @@ -20,27 +26,33 @@ export class Projects implements OnInit, OnDestroy { categoryClicked = false; imagesOrigin = environment.apiUrl + '/images/'; + private dialog = inject(MatDialog); + private http = inject(HttpClient); private adminQuery = inject(AdminQuery); private adminState = inject(AdminStateService); + private adminService = inject(AdminService); private destroy$ = new Subject(); projects$ = this.adminQuery.projects$; loading$ = this.adminQuery.projectsLoading$; ngOnInit(): void { - const candidateId = this.adminQuery.getCandidateId(); - this.adminState.loadProjects(candidateId) - .pipe(takeUntil(this.destroy$)) - .subscribe(); - this.projects$ - .pipe(takeUntil(this.destroy$)) + .pipe( + filter(data => data != null), + takeUntil(this.destroy$) + ) .subscribe(data => { - if (data) { - this.projects = data.projects; - this.projectsCategories = data.projectsCategories; - } + 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(); + } } filterProjects(category: string) { @@ -55,6 +67,141 @@ export class Projects implements OnInit, OnDestroy { ); } + 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 { + return { + ...project, + categories: (project.categories ?? []).join(', '), + roles: (project.roles ?? []).join(', '), + responsibilities: (project.responsibilities ?? []).join(', '), + technologiesUsed: (project.technologiesUsed ?? []).join(', ') + }; + } + + private formDataToProject(formVal: Record): 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(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 | null) => { + if (!res) return; + const updated = this.formDataToProject(res); + this.saveProject(updated, false); + }); + } + + openAddProject(): void { + const config = this.getProjectFormConfig('Add Project'); + const data: Record = { projectId: 0 }; + + const ref = this.dialog.open(DynamicPopupComponent, { + data: { config, data }, + panelClass: 'dark-popup-panel' + }); + + ref.afterClosed() + .pipe(takeUntil(this.destroy$)) + .subscribe((res: Record | null) => { + if (!res) return; + const newProject = this.formDataToProject(res); + this.saveProject(newProject, true); + }); + } + + deleteProject(project: IProject): void { + if (!confirm(`Are you sure you want to delete "${project.name}"?`)) 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(); diff --git a/src/app/admin/resume/resume.ts b/src/app/admin/resume/resume.ts index 5bf06d5..748c36f 100644 --- a/src/app/admin/resume/resume.ts +++ b/src/app/admin/resume/resume.ts @@ -27,21 +27,21 @@ export class Resume implements OnInit, OnDestroy { loading$ = this.adminQuery.resumeLoading$; ngOnInit(): void { - const candidateId = this.adminQuery.getCandidateId(); - this.adminState.loadResume(candidateId) - .pipe(takeUntil(this.destroy$)) - .subscribe(); + if (!this.adminQuery.getResume()) { + this.adminState.loadResume() + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } } 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}`, + save: '/api/v1/admin/UpsertAcademics', method: 'POST', bodyKey: 'academics' }, @@ -80,13 +80,12 @@ export class Resume implements OnInit, OnDestroy { 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}`, + save: '/api/v1/admin/UpsertExperiences', method: 'POST', bodyKey: 'experiences' }, @@ -126,13 +125,12 @@ export class Resume implements OnInit, OnDestroy { 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}`, + save: '/api/v1/admin/UpsertSkills', method: 'POST', bodyKey: 'skills' }, @@ -169,13 +167,12 @@ export class Resume implements OnInit, OnDestroy { 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}`, + save: '/api/v1/admin/UpsertCertifications', method: 'POST', bodyKey: 'certifications' }, diff --git a/src/app/admin/services/admin.service.ts b/src/app/admin/services/admin.service.ts index 020bb2a..8b82b88 100644 --- a/src/app/admin/services/admin.service.ts +++ b/src/app/admin/services/admin.service.ts @@ -20,31 +20,31 @@ export class AdminService { return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`; } - getHobbies(candidateId: number): Observable { - return this.http.get(this.api(`/api/v1/admin/GetHobbies/${candidateId}`)); + getHobbies(): Observable { + return this.http.get(this.api('/api/v1/admin/GetHobbies')); } - getCandidateWithSocialLinks(candidateId: number): Observable { - return this.http.get(this.api(`/api/v1/admin/GetCandidateWithSocialLinks/${candidateId}`)); + getCandidateWithSocialLinks(): Observable { + return this.http.get(this.api('/api/v1/admin/GetCandidateWithSocialLinks')); } - getResume(candidateId: number): Observable { - return this.http.get(this.api(`/api/v1/admin/GetResume/${candidateId}`)); + getResume(): Observable { + return this.http.get(this.api('/api/v1/admin/GetResume')); } - getProjects(candidateId: number): Observable { - return this.http.get(this.api(`/api/v1/admin/GetProjects/${candidateId}`)); + getProjects(): Observable { + return this.http.get(this.api('/api/v1/admin/GetProjects')); } 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); + deleteProject(projectId: number): Observable { + return this.http.delete(this.api(`/api/v1/admin/DeleteProject/${projectId}`)); } - upsertResume(candidateId: number, resume: IResume): Observable { - return this.http.post(this.api(`/api/v1/admin/UpsertResume/${candidateId}`), resume); + upsertHobbies(hobbies: IHobby[]): Observable { + return this.http.post(this.api('/api/v1/admin/UpsertHobbies'), hobbies); } } diff --git a/src/app/admin/state/admin-state.service.ts b/src/app/admin/state/admin-state.service.ts index 9c0f246..ea62f3c 100644 --- a/src/app/admin/state/admin-state.service.ts +++ b/src/app/admin/state/admin-state.service.ts @@ -13,15 +13,11 @@ export class AdminStateService { private store = inject(AdminStore); private api = inject(AdminService); - setCandidateId(id: number) { - this.store.update({ candidateId: id }); - } - // ── About ────────────────────────────────────────────── - loadAbout(candidateId: number) { + loadAbout() { this.store.setSectionLoading('about', true); - return this.api.getHobbies(candidateId).pipe( + return this.api.getHobbies().pipe( tap({ next: (about: IAbout) => { this.store.update({ about }); @@ -40,9 +36,9 @@ export class AdminStateService { this.store.update({ about }); } - upsertHobbies(resumeId: number, hobbies: IHobby[]) { + upsertHobbies(hobbies: IHobby[]) { this.store.setSectionLoading('about', true); - return this.api.upsertHobbies(resumeId, hobbies).pipe( + return this.api.upsertHobbies(hobbies).pipe( tap({ next: (updatedHobbies: IHobby[]) => { this.store.update(state => ({ @@ -61,9 +57,9 @@ export class AdminStateService { // ── Contact ──────────────────────────────────────────── - loadContact(candidateId: number) { + loadContact() { this.store.setSectionLoading('contact', true); - return this.api.getCandidateWithSocialLinks(candidateId).pipe( + return this.api.getCandidateWithSocialLinks().pipe( tap({ next: (contact: IContactModel) => { this.store.update({ contact }); @@ -84,9 +80,9 @@ export class AdminStateService { // ── Projects ─────────────────────────────────────────── - loadProjects(candidateId: number) { + loadProjects() { this.store.setSectionLoading('projects', true); - return this.api.getProjects(candidateId).pipe( + return this.api.getProjects().pipe( tap({ next: (projects: IProjects) => { this.store.update({ projects }); @@ -107,9 +103,9 @@ export class AdminStateService { // ── Resume ───────────────────────────────────────────── - loadResume(candidateId: number) { + loadResume() { this.store.setSectionLoading('resume', true); - return this.api.getResume(candidateId).pipe( + return this.api.getResume().pipe( tap({ next: (resume: IResume) => { this.store.update({ resume }); diff --git a/src/app/admin/state/admin.query.ts b/src/app/admin/state/admin.query.ts index 0dbe1e9..1e18205 100644 --- a/src/app/admin/state/admin.query.ts +++ b/src/app/admin/state/admin.query.ts @@ -9,8 +9,6 @@ export class AdminQuery extends Query { constructor() { super(inject(AdminStore)); } - // Candidate - candidateId$ = this.select('candidateId'); // About about$ = this.select('about'); @@ -47,8 +45,4 @@ export class AdminQuery extends Query { 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 index 83ed4a5..19a7197 100644 --- a/src/app/admin/state/admin.store.ts +++ b/src/app/admin/state/admin.store.ts @@ -8,7 +8,6 @@ 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; @@ -19,7 +18,6 @@ export interface AdminState { export function createInitialState(): AdminState { return { - candidateId: 1, about: null, contact: null, projects: null, diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index ffd37b1..50188b3 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -2,7 +2,11 @@ import { RenderMode, ServerRoute } from '@angular/ssr'; export const serverRoutes: ServerRoute[] = [ { - path: '**', + path: 'admin/login', renderMode: RenderMode.Prerender + }, + { + path: '**', + renderMode: RenderMode.Client } ]; diff --git a/src/app/auth/auth.service.ts b/src/app/auth/auth.service.ts index a9e713a..1b62437 100644 --- a/src/app/auth/auth.service.ts +++ b/src/app/auth/auth.service.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable, PLATFORM_ID } from '@angular/core'; -import { BehaviorSubject, map, Observable, of } from 'rxjs'; +import { BehaviorSubject, map, Observable } from 'rxjs'; import { environment } from '../../environments/environment'; import { isPlatformBrowser } from '@angular/common'; import { Router } from '@angular/router'; @@ -107,11 +107,11 @@ export class AuthService { return this.accessToken; } - logout(): Observable { + logout(): void { + this.http.post(this.api('/api/v1/auth/logout'), {}).subscribe(); this.accessToken = null; this.safeRemoveToken(); this.router.navigate(['/admin/login']); - return of(); } refreshToken(): Observable { diff --git a/src/app/auth/otp/otp.component.html b/src/app/auth/otp/otp.component.html index 79c4782..315e9a3 100644 --- a/src/app/auth/otp/otp.component.html +++ b/src/app/auth/otp/otp.component.html @@ -28,6 +28,12 @@
    + @if (isError()) { +
    + + {{ message() }} +
    + } diff --git a/src/app/auth/otp/otp.component.scss b/src/app/auth/otp/otp.component.scss index 5ac85be..1678912 100644 --- a/src/app/auth/otp/otp.component.scss +++ b/src/app/auth/otp/otp.component.scss @@ -132,6 +132,40 @@ button { 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 { color: #ccc; font-size: 14px; diff --git a/src/app/auth/otp/otp.component.ts b/src/app/auth/otp/otp.component.ts index 79675b7..676fa10 100644 --- a/src/app/auth/otp/otp.component.ts +++ b/src/app/auth/otp/otp.component.ts @@ -82,9 +82,9 @@ export class OtpComponent implements OnInit { error: (err) => { this.isError.set(true); if (err.status === 401 && err.error?.message) { - console.log(err.error.message); // "OTP Expired" or "Invalid OTP" + this.message.set(err.error.message); } else { - console.log('Something went wrong. Please try again.'); + this.message.set('Something went wrong. Please try again.'); } } }); diff --git a/src/app/guards/auth-guard.ts b/src/app/guards/auth-guard.ts index 7ffdf78..7e47936 100644 --- a/src/app/guards/auth-guard.ts +++ b/src/app/guards/auth-guard.ts @@ -1,8 +1,16 @@ -import { inject } from '@angular/core'; +import { inject, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; 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 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 router = inject(Router); diff --git a/src/app/interceptors/auth-interceptor.ts b/src/app/interceptors/auth-interceptor.ts index 0909131..2e5de72 100644 --- a/src/app/interceptors/auth-interceptor.ts +++ b/src/app/interceptors/auth-interceptor.ts @@ -52,6 +52,12 @@ export const AuthInterceptor: HttpInterceptorFn = ( 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 (refreshInProgress) { return refreshSubject.pipe( From 1806c6fb1f733545b57396b839064121d8010eca Mon Sep 17 00:00:00 2001 From: Bangara Raju Kottedi Date: Sun, 15 Feb 2026 05:47:32 +0530 Subject: [PATCH 3/5] feat: implement confirm dialog for project deletion and item removal in dynamic forms; enhance form field accessibility --- src/app/admin/projects/projects.ts | 36 +++++--- .../shared/confirm-dialog/confirm-dialog.html | 15 ++++ .../shared/confirm-dialog/confirm-dialog.scss | 86 +++++++++++++++++++ .../shared/confirm-dialog/confirm-dialog.ts | 50 +++++++++++ src/app/shared/dynamic-form/dynamic-form.html | 4 +- src/app/shared/dynamic-form/dynamic-form.ts | 24 +++++- src/styles.scss | 12 +++ 7 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 src/app/shared/confirm-dialog/confirm-dialog.html create mode 100644 src/app/shared/confirm-dialog/confirm-dialog.scss create mode 100644 src/app/shared/confirm-dialog/confirm-dialog.ts diff --git a/src/app/admin/projects/projects.ts b/src/app/admin/projects/projects.ts index 9a1316d..c18f169 100644 --- a/src/app/admin/projects/projects.ts +++ b/src/app/admin/projects/projects.ts @@ -5,6 +5,7 @@ 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'; @@ -185,20 +186,33 @@ export class Projects implements OnInit, OnDestroy { } deleteProject(project: IProject): void { - if (!confirm(`Are you sure you want to delete "${project.name}"?`)) return; + 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' + }); - this.adminService.deleteProject(project.projectId) + ref.afterClosed() .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 ?? []))]; + .subscribe((confirmed: boolean) => { + if (!confirmed) return; - this.adminState.updateProjects({ - projects: updatedList, - projectsCategories: categories.length ? categories : [] - }); - this.filter = 'All'; + 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'; + }); }); } diff --git a/src/app/shared/confirm-dialog/confirm-dialog.html b/src/app/shared/confirm-dialog/confirm-dialog.html new file mode 100644 index 0000000..43c507e --- /dev/null +++ b/src/app/shared/confirm-dialog/confirm-dialog.html @@ -0,0 +1,15 @@ +
    +
    + +

    {{ title }}

    +
    + +

    {{ message }}

    + +
    + + +
    +
    diff --git a/src/app/shared/confirm-dialog/confirm-dialog.scss b/src/app/shared/confirm-dialog/confirm-dialog.scss new file mode 100644 index 0000000..a5ed86b --- /dev/null +++ b/src/app/shared/confirm-dialog/confirm-dialog.scss @@ -0,0 +1,86 @@ +.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); + } +} diff --git a/src/app/shared/confirm-dialog/confirm-dialog.ts b/src/app/shared/confirm-dialog/confirm-dialog.ts new file mode 100644 index 0000000..edd0985 --- /dev/null +++ b/src/app/shared/confirm-dialog/confirm-dialog.ts @@ -0,0 +1,50 @@ +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); + 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); + } +} diff --git a/src/app/shared/dynamic-form/dynamic-form.html b/src/app/shared/dynamic-form/dynamic-form.html index 5fbda29..d5dcc35 100644 --- a/src/app/shared/dynamic-form/dynamic-form.html +++ b/src/app/shared/dynamic-form/dynamic-form.html @@ -6,7 +6,7 @@ @if (f.type === 'text') { {{ f.label }} - + {{ f.placeholder }} This field is required. @@ -17,7 +17,7 @@ @if (f.type === 'textarea') { {{ f.label }} - + {{ f.placeholder }} This field is required. diff --git a/src/app/shared/dynamic-form/dynamic-form.ts b/src/app/shared/dynamic-form/dynamic-form.ts index 4d87056..5e34afc 100644 --- a/src/app/shared/dynamic-form/dynamic-form.ts +++ b/src/app/shared/dynamic-form/dynamic-form.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; +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'; @@ -9,7 +9,9 @@ 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", @@ -17,7 +19,7 @@ import { provideNativeDateAdapter } from '@angular/material/core'; standalone: true, styleUrls: ['./dynamic-form.scss'], providers: [provideNativeDateAdapter()], - imports: [ReactiveFormsModule, CommonModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule, MatIconModule, MatDatepickerModule] + imports: [ReactiveFormsModule, CommonModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule, MatIconModule, MatDatepickerModule, MatDialogModule] }) export class DynamicFormComponent implements OnInit { @Input() config!: DynamicFormConfig; @@ -25,6 +27,8 @@ export class DynamicFormComponent implements OnInit { @Output() formBuilt = new EventEmitter(); + private dialog = inject(MatDialog); + form!: FormGroup; ngOnInit() { @@ -71,7 +75,21 @@ addArrayItem(field: DynamicField) { removeArrayItem(field: DynamicField, index: number) { const array = this.form.get(field.name) as FormArray; - array.removeAt(index); + + 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[] { diff --git a/src/styles.scss b/src/styles.scss index 5ee9f7f..bfc1d0a 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -409,6 +409,18 @@ 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, From 3ef1a2682fd5ac985b3285f929ebeb533e61c16b Mon Sep 17 00:00:00 2001 From: Bangara Raju Kottedi Date: Sun, 15 Feb 2026 13:07:40 +0530 Subject: [PATCH 4/5] feat: update popup styles for improved aesthetics; add SVG favicon --- public/favicon.svg | 17 +++++++++++++++++ src/app/shared/dynamic-popup/dynamic-popup.scss | 6 ++++-- src/index.html | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 public/favicon.svg diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..2185db0 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/src/app/shared/dynamic-popup/dynamic-popup.scss b/src/app/shared/dynamic-popup/dynamic-popup.scss index bc3edaf..939b33f 100644 --- a/src/app/shared/dynamic-popup/dynamic-popup.scss +++ b/src/app/shared/dynamic-popup/dynamic-popup.scss @@ -6,8 +6,10 @@ 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); + /* 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; diff --git a/src/index.html b/src/index.html index 71b0f64..aa44f63 100644 --- a/src/index.html +++ b/src/index.html @@ -5,7 +5,7 @@ PortfolioAdmin - + From 641dd1e004fad6ac00ac1fa02dc17ae935901368 Mon Sep 17 00:00:00 2001 From: Bangara Raju Kottedi Date: Sun, 15 Feb 2026 14:38:16 +0530 Subject: [PATCH 5/5] fix: update outputPath in build options and remove unused server configurations --- angular.json | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/angular.json b/angular.json index 23ce3f9..392bf95 100644 --- a/angular.json +++ b/angular.json @@ -23,6 +23,7 @@ "build": { "builder": "@angular/build:application", "options": { + "outputPath": "dist/portfolio-admin", "browser": "src/main.ts", "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", @@ -34,12 +35,7 @@ ], "styles": [ "src/styles.scss" - ], - "server": "src/main.server.ts", - "outputMode": "server", - "ssr": { - "entry": "src/server.ts" - } + ] }, "configurations": { "production": {