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 @@
-
\ 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